Skip to content
Palette Neutral
Download ZIP

Neutral palette · 5.5 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_aspect_ratio_frame_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_aspect_ratio_frame_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Neutral
Version
1.0.0
Digest
Full digest
sha256-e8866c6189d787333f21d59edf01580e52c150b071c491d7ea41e1fa7567aceb
AspectFrame.svelte Svelte · 5.7 KB Raw
<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>

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.

Suggested location
src/lib/components/aspect-ratio-frame-01

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.

Example

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" />

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
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.
childrenSnippetNoNoneThe 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.
placeholderSnippetNoNoneYour own placeholder, laid over the whole frame when there is no media. Replaces the built-in crop marks and label.
placeholderLabelstringNoNoneOne short line centred in the built-in placeholder, such as "Floor plan to follow". Omitted, the placeholder shows its crop marks only.
captionstringNoNoneTurns the root into a figure with this text as its figcaption, set small and muted below the frame on its left edge.
roundedbooleanNotrueRounds the frame's corners with --aspect-ratio-frame-radius (12 px). false gives square corners.
classClassValueNoNoneClasses for the root element, such as a max width or mx-auto.

Customization#

On this page

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-*).

Public CSS variables

VariableToken
--aspect-ratio-frame-inkink
--aspect-ratio-frame-mutedmuted
--aspect-ratio-frame-hairlinehairline
--aspect-ratio-frame-surfacesurface
--aspect-ratio-frame-radiusradius

Accessibility#

On this page
  • 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.

Release details#

On this page
Integration
  • Presentational
  • Works without client-side JavaScript
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 1 October 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.