diff --git a/pages/common/heroshot.tsx b/pages/common/heroshot.tsx
new file mode 100644
index 0000000000..97c0e1c37e
--- /dev/null
+++ b/pages/common/heroshot.tsx
@@ -0,0 +1,102 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React from 'react';
+
+import {
+ colorBackgroundLayoutMain,
+ colorBorderDividerDefault,
+ colorTextBodySecondary,
+ fontSizeBodyS,
+} from '~design-tokens';
+
+// Thumbnail size used by the components overview pages of the documentation website.
+export const HEROSHOT_WIDTH = 346;
+export const HEROSHOT_HEIGHT = 170;
+
+interface HeroshotProps {
+ /** Caption rendered above the frame, outside of the captured area. */
+ label?: string;
+ /**
+ * Vertical placement of the content inside the frame. Content is centered by default; use `start`
+ * only when it is taller than the frame, so the crop keeps the top instead of cutting both ends.
+ */
+ align?: 'center' | 'start';
+ /** Inset between the frame edges and the content. Set to 0 for compositions that bleed to the edges. */
+ padding?: number;
+ /** Stretches the content to the full frame width. Use for block components such as alert or container. */
+ stretch?: boolean;
+ /**
+ * Lays the content out at this pixel size and scales it down to fit the frame. Use for page-level
+ * layouts (app layout, wizard) that only read as themselves at a full viewport width. Pick a size
+ * with the same 346:170 aspect ratio to avoid letterboxing.
+ */
+ contentSize?: { width: number; height: number };
+ children: React.ReactNode;
+}
+
+/**
+ * Renders its children inside a frame that matches the documentation website thumbnail size exactly.
+ * The dashed guide is drawn with an outline so it sits outside of the 346x170 box and is not captured
+ * when the frame is cropped to its bounding box.
+ *
+ * Animations are stopped by the `disableAnimations` screenshot area the page renders, so the frames
+ * capture the same pixels every time.
+ */
+export function Heroshot({
+ label,
+ align = 'center',
+ padding = 16,
+ stretch = false,
+ contentSize,
+ children,
+}: HeroshotProps) {
+ // Scaled content is laid out at its own size and shrunk to fit, so it ignores the flex alignment below.
+ const scaled = contentSize ? (
+
+ ) : null;
+
+ return (
+
+ {label ? (
+
{label}
+ ) : null}
+
+ {scaled ?? children}
+
+
+ );
+}
diff --git a/pages/heroshot/overview.page.tsx b/pages/heroshot/overview.page.tsx
new file mode 100644
index 0000000000..c482c4bfe9
--- /dev/null
+++ b/pages/heroshot/overview.page.tsx
@@ -0,0 +1,953 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React from 'react';
+
+import {
+ ActionCard,
+ Alert,
+ AnchorNavigation,
+ AppLayout,
+ AppLayoutToolbar,
+ AreaChart,
+ AttributeEditor,
+ Autosuggest,
+ Badge,
+ BarChart,
+ Box,
+ BreadcrumbGroup,
+ Button,
+ ButtonDropdown,
+ ButtonGroup,
+ Calendar,
+ Cards,
+ Checkbox,
+ ColumnLayout,
+ Container,
+ ContentLayout,
+ CopyToClipboard,
+ DateInput,
+ DatePicker,
+ DateRangePicker,
+ Dialog,
+ Divider,
+ ExpandableSection,
+ FileDropzone,
+ FileInput,
+ FileTokenGroup,
+ FileUpload,
+ Flashbar,
+ Form,
+ FormField,
+ Grid,
+ Header,
+ HelpPanel,
+ Icon,
+ Input,
+ ItemCard,
+ KeyValuePairs,
+ LineChart,
+ Link,
+ List,
+ MixedLineBarChart,
+ Multiselect,
+ Pagination,
+ PieChart,
+ ProgressBar,
+ PromptInput,
+ PropertyFilter,
+ RadioButton,
+ RadioGroup,
+ SegmentedControl,
+ Select,
+ SideNavigation,
+ Skeleton,
+ Slider,
+ SpaceBetween,
+ Spinner,
+ StatusIndicator,
+ Steps,
+ Table,
+ Tabs,
+ TagEditor,
+ Textarea,
+ TextContent,
+ TextFilter,
+ Tiles,
+ TimeInput,
+ Toggle,
+ ToggleButton,
+ Token,
+ TokenGroup,
+ TopNavigation,
+ TreeView,
+} from '~components';
+import Dropdown from '~components/dropdown/internal';
+import Option from '~components/internal/components/option';
+import OptionsList from '~components/internal/components/options-list';
+import SelectableItem from '~components/internal/components/selectable-item';
+
+import { SimplePage } from '../app/templates';
+import { Heroshot } from '../common/heroshot';
+import { IframeWrapper } from '../utils/iframe-wrapper';
+
+export default function HeroshotOverviewPage() {
+ return (
+
+ {/* The frames have a fixed size, so the gallery wraps them instead of stacking 70+ rows. */}
+
+
+ }
+ href="#"
+ />
+
+
+
+
+ The change applies after the next restart. Learn more
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ },
+ { label: 'Value', control: item => },
+ ]}
+ />
+
+
+
+
+
+
+
+
+ Default
+ Blue
+ Green
+ Red
+
+
+
+
+
+
+
+
+
+ Instance details
+
+ Review the configuration before you launch the instance.
+
+
+
+
+
+
+
+
+
+
+ Cancel
+ Create resource
+
+
+
+
+
+ Actions
+
+
+
+
+
+
+
+
+
+
+
+
+ item.id }}
+ cardDefinition={{
+ header: item => item.id,
+ sections: [{ id: 'type', header: 'Type', content: item => item.type }],
+ }}
+ />
+
+
+
+
+
+ Enable monitoring
+
+
+ Enable termination protection
+
+
+
+
+
+
+
+ Instance type
+ t3.medium
+
+
+ Region
+ us-east-1
+
+
+
+
+
+ Instance details}>
+
+
+
+
+
+ Instances}>
+ Content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ({ valid: true })}
+ onChange={noop}
+ />
+
+
+
+
+
+ Cancel
+ Delete
+
+ }
+ >
+ This action cannot be undone.
+
+
+
+
+
+ Instance settings
+
+ Network settings
+
+
+
+
+
+
+
+
+
+
+ Drop files to upload
+
+
+
+
+ Choose file
+
+
+
+
+ `Remove file ${index + 1}` }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Main
+ Side
+
+
+
+
+ Launch instance}
+ >
+ Instances
+
+
+
+
+ Instances}>
+ An instance is a virtual server in the cloud.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Compare types}
+ />
+
+
+
+ Running },
+ { label: 'Launched', value: 'Oct 6, 2026' },
+ ]}
+ />
+
+
+
+
+
+
+
+
+ Secondary link
+
+ Primary link
+
+
+ External link
+
+
+
+
+
+ ({ id: item.id, content: item.id, secondaryContent: item.type })}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ On-demand
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ First
+ Second
+
+
+
+
+
+
+
+
+
+ Running
+ Pending
+ Failed
+
+
+
+
+
+
+
+
+ item.id },
+ { id: 'type', header: 'Type', cell: item => item.type },
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ Instances
+ An instance is a virtual server in the cloud.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Detailed monitoring
+
+
+ Auto scaling
+
+
+
+
+
+
+
+ Favorite
+
+
+ Favorite
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ item.id}
+ getItemChildren={item => item.children}
+ renderItem={item => ({ content: item.label })}
+ />
+
+
+
+ );
+}
+
+const noop = () => {};
+
+const instances = [
+ { id: 'i-01af2c', type: 't3.medium' },
+ { id: 'i-02bd3e', type: 'm5.large' },
+ { id: 'i-03ce4f', type: 'c5.xlarge' },
+];
+
+/**
+ * Viewport the page-level layouts are rendered at before being scaled into the frame. Matches the
+ * 346:170 frame ratio so nothing is letterboxed, and is wide enough for the layout to lay out its
+ * panels side by side rather than collapsing to its narrow-viewport behaviour.
+ */
+const LAYOUT_CONTENT_SIZE = { width: 1280, height: 629 };
+
+/**
+ * Shared by both layout heroshots so the only visible difference between them is the toolbar that
+ * app layout toolbar adds.
+ */
+const layoutSlots = {
+ navigationOpen: true,
+ toolsOpen: true,
+ onNavigationChange: noop,
+ onToolsChange: noop,
+ breadcrumbs: (
+
+ ),
+ navigation: (
+
+ ),
+ tools: (
+ Instances}>
+ An instance is a virtual server in the cloud.
+
+ ),
+ content: (
+
+ Launch instance}>
+ Instances
+
+ Running instances}
+ items={instances}
+ columnDefinitions={[
+ { id: 'id', header: 'Instance ID', cell: item => item.id },
+ { id: 'type', header: 'Type', cell: item => item.type },
+ { id: 'status', header: 'Status', cell: () => Running },
+ ]}
+ />
+
+ ),
+} as const;
+
+/**
+ * App layout measures the viewport to place its panels, so scaling a plain `div` would leave it
+ * laying out against the browser window instead of the frame. Rendering it in an iframe gives it a
+ * viewport of exactly `LAYOUT_CONTENT_SIZE`, which the frame then scales down as a whole.
+ */
+function AppLayoutHeroshot() {
+ return ;
+}
+
+/** Same technique as {@link AppLayoutHeroshot}; the toolbar is what distinguishes the thumbnail. */
+function AppLayoutToolbarHeroshot() {
+ return ;
+}
+
+const anchors = [
+ { text: 'Overview', href: '#overview', level: 1 },
+ { text: 'Networking', href: '#networking', level: 1 },
+ { text: 'Subnets', href: '#subnets', level: 2 },
+];
+
+const timeSeries = [
+ { x: 'Mon', y: 120 },
+ { x: 'Tue', y: 180 },
+ { x: 'Wed', y: 140 },
+ { x: 'Thu', y: 220 },
+ { x: 'Fri', y: 190 },
+];
+
+const pieData = [
+ { title: 'Running', value: 84 },
+ { title: 'Stopped', value: 32 },
+ { title: 'Pending', value: 12 },
+];
+
+interface TreeItem {
+ id: string;
+ label: string;
+ children?: TreeItem[];
+}
+
+const treeItems: TreeItem[] = [
+ {
+ id: 'vpc',
+ label: 'vpc-0a1b2c',
+ children: [
+ { id: 'subnet-a', label: 'subnet-public-a' },
+ { id: 'subnet-b', label: 'subnet-private-b' },
+ ],
+ },
+];
+
+const certificateFile = new File([new Uint8Array(2048)], 'certificate.pem', { type: 'application/x-pem-file' });
+
+const autosuggestOptions = [
+ { value: 'us-east-1' },
+ { value: 'us-east-2' },
+ { value: 'us-west-1' },
+ { value: 'eu-west-1' },
+];
+
+const enteredTextLabel = (value: string) => `Use: ${value}`;
+
+/**
+ * The public Autosuggest only opens its dropdown while the input is focused, which a heroshot
+ * can't rely on. This reproduces the open state statically from the same internal building
+ * blocks the real dropdown uses, so it stays open without focus or interaction.
+ */
+function OpenAutosuggest() {
+ // Narrow enough that the entered-text row plus the matches fit the frame without being clipped.
+ const highlightText = 'us-e';
+ const matches = autosuggestOptions.filter(option => option.value.startsWith(highlightText));
+
+ return (
+
+ }
+ content={
+
+
+ {enteredTextLabel(highlightText)}
+
+ {matches.map(option => (
+
+
+
+ ))}
+
+ }
+ />
+ );
+}
diff --git a/pages/utils/iframe-wrapper.tsx b/pages/utils/iframe-wrapper.tsx
index c78da7c1fd..1e8abdd850 100644
--- a/pages/utils/iframe-wrapper.tsx
+++ b/pages/utils/iframe-wrapper.tsx
@@ -39,7 +39,19 @@ function syncClasses(from: HTMLElement, to: HTMLElement) {
};
}
-export function IframeWrapper({ id, AppComponent }: { id: string; AppComponent: React.ComponentType }) {
+export function IframeWrapper({
+ id,
+ AppComponent,
+ size,
+}: {
+ id: string;
+ AppComponent: React.ComponentType;
+ /**
+ * Renders the iframe at this fixed pixel size instead of filling the screen. The iframe is its own
+ * viewport, so this is how a page-level layout can be given a viewport that differs from the browser's.
+ */
+ size?: { width: number; height: number };
+}) {
const cleanupRef = useRef<(() => void) | null>(null);
// use callback ref instead of useEffect to avoid double effect issues in React 18+ strict mode
@@ -51,7 +63,14 @@ export function IframeWrapper({ id, AppComponent }: { id: string; AppComponent:
return;
}
const iframeEl = container.ownerDocument.createElement('iframe');
- iframeEl.className = styles['full-screen'];
+ if (size) {
+ iframeEl.style.inlineSize = `${size.width}px`;
+ iframeEl.style.blockSize = `${size.height}px`;
+ iframeEl.style.border = '0';
+ iframeEl.style.display = 'block';
+ } else {
+ iframeEl.className = styles['full-screen'];
+ }
iframeEl.id = id;
iframeEl.title = id;
container.appendChild(iframeEl);
@@ -67,6 +86,15 @@ export function IframeWrapper({ id, AppComponent }: { id: string; AppComponent:
const innerAppRoot = iframeDocument.createElement('div');
iframeDocument.body.appendChild(innerAppRoot);
iframeDocument.dir = document.dir;
+ if (size) {
+ // A fixed-size iframe is used to give a page-level layout an exact viewport, so the document
+ // has to fill it: without this the default body margin shows as a gutter and the layout only
+ // grows to its content height instead of the full frame.
+ iframeDocument.documentElement.style.blockSize = '100%';
+ iframeDocument.body.style.blockSize = '100%';
+ iframeDocument.body.style.margin = '0';
+ innerAppRoot.style.blockSize = '100%';
+ }
const syncClassesCleanup = syncClasses(document.body, iframeDocument.body);
// Wait for the copied stylesheets to load before mounting the app. Mounting synchronously
@@ -87,7 +115,7 @@ export function IframeWrapper({ id, AppComponent }: { id: string; AppComponent:
container.removeChild(iframeEl);
};
},
- [AppComponent, id]
+ [AppComponent, id, size]
);
return
;