# Aspect-ratio frame

> A frame that reserves space at a fixed ratio (16:9, 4:3, 3:2, 1:1, 9:16, 21:9) for an image, video or iframe, so nothing shifts as it loads. Cover or contain, a crop-marked placeholder, an optional caption.

- ID: `cmp_aspect_ratio_frame_01`
- Slug: `aspect-ratio-frame-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-01
- Updated: 2026-10-01
- Available versions: `1.0.0`
- Kind: control
- Primary category: `layout`
- Detail page: https://pagesugar.com/components/aspect-ratio-frame-01
- Preview: https://pagesugar.com/preview/aspect-ratio-frame-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-e8866c6189d787333f21d59edf01580e52c150b071c491d7ea41e1fa7567aceb` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/aspect-ratio-frame-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Presentational only: the frame reserves space and makes your media fill it. It does not load, lazy-load or gate anything: no consent screen for embeds, no click-to-play, no error detection for a broken image (the frame's fill shows instead). Alt text and iframe titles are yours to write.

Required props: none

```svelte
<script lang="ts">
	import AspectFrame from '$lib/components/aspect-ratio-frame-01/AspectFrame.svelte';
</script>

<AspectFrame ratio="16/9" caption="New board in Halcyon. Every template opens with its columns set up.">
	<img src="/images/new-board.png" alt="Halcyon's New board screen with six templates." width="1600" height="900" />
</AspectFrame>

<AspectFrame ratio="16/9">
	<iframe src="https://www.youtube-nocookie.com/embed/VIDEO_ID" title="Halcyon in two minutes" allowfullscreen></iframe>
</AspectFrame>

<AspectFrame ratio="4/3" placeholderLabel="Photos follow when the refit finishes" />
```

Limitations:

- The frame shows one piece of media. Every direct child of the frame is stretched to fill it, so wrap several layers in one element of your own if you need an overlay.
- The placeholder shows when children is not passed at all. A children snippet that renders nothing (an {#if} that is false) still counts as media and leaves the frame empty, so pass children={ready ? media : undefined} instead.
- fit applies object-fit, which images and video follow. An inline svg follows its own preserveAspectRatio (meet contains, slice covers), and an iframe's document lays itself out inside the frame.
- Placeholder, not loader: the placeholder appears only when no media is passed. While an image loads the frame shows its fill colour; if it fails, the space stays reserved and the browser draws its own broken-image fallback (alt text, an icon). There is no shimmer and no custom error state.
- No lazy-loading or consent gating for embeds. Add loading="lazy" to your own img or iframe, and gate third-party embeds yourself where the law requires consent.
- A 9:16 frame's width stops at 22.5rem (360 px at a 16 px root) or 45svh, whichever is smaller, so the frame is at most 40rem or 80svh tall; a long caption adds to that. It aligns to the start of its column; add class="mx-auto" to centre it.
- Light appearance by default. The tokens retone it for a dark or tinted page, but no dark mode is declared or selected automatically.

## Usage guide

### Aspect-ratio frame

A box that holds its shape before the media inside it arrives. Pick a ratio, put one image,
video or iframe inside, and the page never jumps when it loads.

#### Media

Every direct child of the frame is stretched to fill it with `object-fit`, so you don't need
`w-full h-full object-cover` on your own markup. `object-position` stays yours:

```svelte
<AspectFrame ratio="9/16">
	<img src="/images/booking.png" alt="The booking screen on a phone." class="object-top" />
</AspectFrame>
```

`fit="contain"` shows the whole picture on the frame's fill colour. Use it for menus, diagrams
and posters, where cropping would lose information. `fit` sets `object-fit`, which images and
video follow; an inline `svg` follows its own `preserveAspectRatio` (`xMidYMid slice` covers),
and an iframe's page lays itself out inside the frame.

#### Embeds

Give every iframe a `title`. The frame removes the iframe's default border and stretches it,
so a video or map embed needs no wrapper of its own:

```svelte
<AspectFrame ratio="16/9" caption="Brook Street Lido is a five-minute walk from the station.">
	<iframe
		src="https://maps.example.com/embed?q=brook-street-lido"
		title="Map: Brook Street Lido"
		loading="lazy"
	></iframe>
</AspectFrame>
```

The frame does no consent gating. If an embed needs consent, pass your consent prompt as the
`placeholder` snippet and pass `children` only once consent is given. An `{#if}` inside the
frame is not enough: a snippet that renders nothing still counts as media, so the placeholder
would never show.

```svelte
{#snippet map()}
	<iframe src="https://maps.example.com/embed?q=brook-street-lido" title="Map: Brook Street Lido"
	></iframe>
{/snippet}
{#snippet consent()}
	<button type="button" onclick={() => (allowed = true)}>Load the map</button>
{/snippet}

<AspectFrame ratio="16/9" children={allowed ? map : undefined} placeholder={consent} />
```

#### Placeholder

With no child the frame keeps its space and draws four crop marks. `placeholderLabel` adds one
short line in the middle. For anything else, pass a `placeholder` snippet; it is laid over the
whole frame.

#### Dark page

```css
.media-on-dark {
	--aspect-ratio-frame-surface: #27272a;
	--aspect-ratio-frame-hairline: rgb(255 255 255 / 0.1);
	--aspect-ratio-frame-muted: #a1a1aa;
	--aspect-ratio-frame-ink: #fafafa;
}
```

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `ratio` | '16/9' \| '4/3' \| '3/2' \| '1/1' \| '9/16' \| '21/9' | no | `'16/9'` | Width to height. Each maps to a static aspect class: aspect-video, aspect-4/3, aspect-3/2, aspect-square, aspect-9/16, aspect-21/9. |
| `fit` | 'cover' \| 'contain' | no | `'cover'` | For images and video: cover crops the media to fill the frame; contain shows all of it, with the frame's fill on the sides or top and bottom. An svg follows its preserveAspectRatio instead. |
| `children` | `Snippet` | no |  | The media: one img, picture, video, iframe or svg. It is stretched to fill the frame. Omitted, the placeholder renders; pass undefined rather than an empty snippet when the media is not ready. |
| `placeholder` | `Snippet` | no |  | Your own placeholder, laid over the whole frame when there is no media. Replaces the built-in crop marks and label. |
| `placeholderLabel` | `string` | no |  | One short line centred in the built-in placeholder, such as "Floor plan to follow". Omitted, the placeholder shows its crop marks only. |
| `caption` | `string` | no |  | Turns the root into a figure with this text as its figcaption, set small and muted below the frame on its left edge. |
| `rounded` | `boolean` | no | `true` | Rounds the frame's corners with --aspect-ratio-frame-radius (12 px). false gives square corners. |
| `class` | `ClassValue` | no |  | Classes for the root element, such as a max width or mx-auto. |

## Customization

Pass any media as the child, retone the frame through five --aspect-ratio-frame-\* variables (ink, muted, hairline, surface, radius), and edit the source for caption type or spacing.

- Media: put one img, picture, video or iframe inside the frame. It fills the frame whatever size, margin or max-size classes it carries; object-position (object-top, for example) and filters are still yours.
- Images: give img a width and height that match the file. The frame already holds the space, but the attributes help the browser pick a source from srcset.
- Embeds: give every iframe a title that says what it shows ("Map: Brook Street Lido"). Add loading="lazy" for anything below the fold.
- Fit: fit="contain" shows the whole picture on the frame's fill, which suits menus, diagrams and posters; cover suits photographs and screenshots. For an inline svg set preserveAspectRatio instead (xMidYMid slice to cover).
- Placeholder: placeholderLabel sets one line in the built-in placeholder. Pass a placeholder snippet to draw your own, for a brand mark or a consent prompt, and pass children only once the media should show.
- Text: --aspect-ratio-frame-muted sets the caption and placeholder label (#52525b, 7.7:1 on white, 7.0:1 on the fill). --aspect-ratio-frame-ink tints the crop marks at 28%.
- Surfaces: --aspect-ratio-frame-surface fills the frame behind loading media, contain letterboxing and the placeholder (#f4f4f5); --aspect-ratio-frame-hairline draws the edge over the media.
- Radius: --aspect-ratio-frame-radius sets the corners (12 px). Inside a card with 8 px padding and 16 px corners, keep 8 px so the corners share a centre.
- Dark page retone: surface #27272a, hairline rgb(255 255 255 / 0.1), muted #a1a1aa, ink #fafafa (all --aspect-ratio-frame-\*).

| Token | Public CSS variable |
| --- | --- |
| `ink` | `--aspect-ratio-frame-ink` |
| `muted` | `--aspect-ratio-frame-muted` |
| `hairline` | `--aspect-ratio-frame-hairline` |
| `surface` | `--aspect-ratio-frame-surface` |
| `radius` | `--aspect-ratio-frame-radius` |

## Accessibility

- With a caption the frame is a figure and the caption its figcaption, so assistive technology reads the caption with the media. Without one it is a plain div and adds nothing to the reading order.
- Alt text is yours: describe what the image shows, or alt="" when the caption already says it.
- Every iframe needs a title naming what it shows; a video with no visible caption needs an aria-label. A figcaption describes the video; it does not replace caption tracks or a transcript where the video has speech.
- The built-in placeholder's crop marks are hidden from assistive technology; its label is plain text and is read.
- The frame adds no tab stops and no hover. Controls inside your media (video controls, an embed's buttons) keep their own focus; the hairline over them passes every click through.
- Caption text (#52525b) measures 7.7:1 on white; the placeholder label measures 7.0:1 on the frame's fill (#f4f4f5).
- The caption uses text-start and logical properties, so it follows dir="rtl". Japanese captions break at phrase boundaries where the browser supports word-break: auto-phrase (Chromium) and break normally elsewhere.

Known limitations:

- Contrast is computed for the default tokens only; re-check any token you change.
- The hairline edge is decorative and under 3:1; the media is identified by its alt text, title or caption.
- A broken image keeps its reserved space but shows the browser's own fallback (alt text, a broken-image icon); the component does not detect load errors.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `AspectFrame.svelte`
- Suggested directory: `src/lib/components/aspect-ratio-frame-01`
- Files: 1
- Artifact digest: `sha256-e8866c6189d787333f21d59edf01580e52c150b071c491d7ea41e1fa7567aceb`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `AspectFrame.svelte`

Role: entry · 5822 bytes · SHA-256 `cb4fb0615dab4aa84c80cc820e5ca72b8eade50b58975f8c92cdf87bf42478b2`

```svelte
<script module lang="ts">
	/** Width to height. Each maps to a static Tailwind aspect class. */
	export type AspectFrameRatio = '16/9' | '4/3' | '3/2' | '1/1' | '9/16' | '21/9';
	/** How media meets the frame: crop to fill it, or show all of it on the frame's fill. */
	export type AspectFrameFit = 'cover' | 'contain';
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { ClassValue } from 'svelte/elements';

	interface Props {
		/** Width-to-height ratio of the frame. */
		ratio?: AspectFrameRatio;
		/** How the media fills the frame. */
		fit?: AspectFrameFit;
		/** The media: one img, picture, video, iframe or svg. It fills the frame. */
		children?: Snippet;
		/** Replaces the built-in placeholder while there is no media. */
		placeholder?: Snippet;
		/** One short line set in the built-in placeholder, such as "Floor plan to follow". */
		placeholderLabel?: string;
		/** Renders the frame as a figure with this text as its figcaption, below the frame. */
		caption?: string;
		/** Rounds the frame's corners with --aspect-ratio-frame-radius. */
		rounded?: boolean;
		/** Classes for the root element, such as a width or mx-auto. */
		class?: ClassValue;
	}

	let {
		ratio = '16/9',
		fit = 'cover',
		children,
		placeholder,
		placeholderLabel,
		caption,
		rounded = true,
		class: className
	}: Props = $props();

	/* Complete, static class names, so Tailwind finds every ratio in the source. */
	const ratios: Record<AspectFrameRatio, string> = {
		'16/9': 'aspect-video',
		'4/3': 'aspect-4/3',
		'3/2': 'aspect-3/2',
		'1/1': 'aspect-square',
		'9/16': 'aspect-9/16',
		'21/9': 'aspect-21/9'
	};
</script>

<!--
	A portrait frame is capped by width, not height: at 9:16 a full column would run off the screen.
	Its width stops at 22.5rem or 45svh, whichever is smaller, so the frame is at most 40rem or
	80svh tall. The cap sits on the root so a caption keeps the frame's width.
-->
<svelte:element
	this={caption ? 'figure' : 'div'}
	class={[
		'aspect-ratio-frame m-0 w-full min-w-0',
		ratio === '9/16' && 'max-w-[min(22.5rem,45svh)]',
		fit === 'contain' && 'aspect-ratio-frame--contain',
		className
	]}
>
	<div
		class={[
			'aspect-ratio-frame__box relative isolate overflow-hidden bg-(--_surface)',
			ratios[ratio],
			rounded && 'rounded-(--_radius)'
		]}
	>
		{#if children}
			{@render children()}
		{:else if placeholder}
			<div class="aspect-ratio-frame__placeholder absolute inset-0">{@render placeholder()}</div>
		{:else}
			<!--
				Reserved space, drawn as crop marks rather than a picture of a picture: it says "media
				goes here" without pretending to be media. The marks are decoration; the label is read.
			-->
			<div
				class="aspect-ratio-frame__placeholder absolute inset-0 grid place-items-center p-6 text-center"
			>
				<span
					class="pointer-events-none absolute start-3 top-3 size-3 border-s border-t border-(--_mark)"
					aria-hidden="true"
				></span>
				<span
					class="pointer-events-none absolute end-3 top-3 size-3 border-e border-t border-(--_mark)"
					aria-hidden="true"
				></span>
				<span
					class="pointer-events-none absolute start-3 bottom-3 size-3 border-s border-b border-(--_mark)"
					aria-hidden="true"
				></span>
				<span
					class="pointer-events-none absolute end-3 bottom-3 size-3 border-e border-b border-(--_mark)"
					aria-hidden="true"
				></span>
				{#if placeholderLabel}
					<p class="m-0 max-w-[24em] text-[13px]/4.5 text-balance break-words text-(--_muted)">
						{placeholderLabel}
					</p>
				{/if}
			</div>
		{/if}
	</div>

	{#if caption}
		<figcaption
			class="aspect-ratio-frame__caption mt-3 max-w-[32em] text-start text-[13px]/4.5 text-pretty break-words text-(--_muted)"
		>
			{caption}
		</figcaption>
	{/if}
</svelte:element>

<style>
	/* Public tokens: set --aspect-ratio-frame-* on the frame or any ancestor to retone it. */
	.aspect-ratio-frame {
		--_ink: var(--aspect-ratio-frame-ink, #18181b);
		--_muted: var(--aspect-ratio-frame-muted, #52525b);
		--_hairline: var(--aspect-ratio-frame-hairline, rgb(0 0 0 / 0.08));
		--_surface: var(--aspect-ratio-frame-surface, #f4f4f5);
		--_radius: var(--aspect-ratio-frame-radius, 12px);

		/* Formulas, read by utilities in the markup. */
		--_mark: color-mix(in oklab, var(--_ink) 28%, transparent);
		--_fit: cover;
	}
	.aspect-ratio-frame--contain {
		--_fit: contain;
	}

	/*
	 * Whatever the media snippet renders fills the frame. It is content the component does not
	 * render, so CSS; it stays outside the cascade layers on purpose, because a stray h-auto or
	 * inline display from the consumer's markup would otherwise break the reserved box. Position,
	 * filters and object-position stay yours. The placeholder is the component's own markup and
	 * keeps its utilities.
	 */
	.aspect-ratio-frame__box > :global(:not(.aspect-ratio-frame__placeholder)) {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		min-width: 0;
		min-height: 0;
		max-width: none;
		max-height: none;
		margin: 0;
		border: 0;
		object-fit: var(--_fit);
	}
	.aspect-ratio-frame__box > :global(picture > img) {
		display: block;
		width: 100%;
		height: 100%;
		min-width: 0;
		min-height: 0;
		max-width: none;
		max-height: none;
		margin: 0;
		object-fit: var(--_fit);
	}

	/*
	 * One hairline drawn over the media, so a pale image still has an edge on a pale page. It sits
	 * above the media and lets every click through to it.
	 */
	.aspect-ratio-frame__box::after {
		content: '';
		position: absolute;
		inset: 0;
		z-index: 1;
		border-radius: inherit;
		box-shadow: inset 0 0 0 1px var(--_hairline);
		pointer-events: none;
	}

	/* Japanese captions break at phrase boundaries rather than mid-word. */
	.aspect-ratio-frame__caption:lang(ja) {
		word-break: auto-phrase;
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-e8866c6189d787333f21d59edf01580e52c150b071c491d7ea41e1fa7567aceb`
- Entry: `AspectFrame.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_aspect_ratio_frame_01/1.0.0/neutral/sha256-e8866c6189d787333f21d59edf01580e52c150b071c491d7ea41e1fa7567aceb/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_aspect_ratio_frame_01/1.0.0/neutral/sha256-e8866c6189d787333f21d59edf01580e52c150b071c491d7ea41e1fa7567aceb/bundle.zip (5584 bytes, sha256 `a4147d9f277021b90201d1319a67fcfa17215ef37f1e9a483a081e57b2746c1b`)

Files:

- `AspectFrame.svelte` (entry, 5822 bytes): https://pagesugar.com/artifacts/cmp_aspect_ratio_frame_01/1.0.0/neutral/sha256-e8866c6189d787333f21d59edf01580e52c150b071c491d7ea41e1fa7567aceb/source/AspectFrame.svelte
