Skip to examples
Bento / Kitchen sink
Bento / foundations

Layout

Columns, grids, flow, surfaces, and dividers. Every width and step resolves to the active preset's tokens, so the same markup follows the preset you pick above.

Container

width · gutter — centres and caps a column

narrow
--narrow-max
measure
--measure
content
--content-max
page
--page-max
gutter={false}
--narrow-max, no gutter

The width caps the content; gutters sit outside it. The shaded band is --gutter. A measure column stays a readable number of characters however wide the gutter is. Content and page are wider than this frame, so they fill it.

Every cap is a token. --narrow-max, --measure, --content-max, --page-max, and --gutter live in each preset's space.css.

Source src/lib/components/layout/container/doc.ts · src/lib/components/layout/container/Container.svelte · src/lib/components/layout/container/container.variants.ts · src/lib/components/layout/container/container.module.css

src/lib/components/layout/container/doc.ts

/**
 * Container — centres content and caps its width.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     width?    "narrow" | "measure" | "content" | "page"   default "page"
 *     gutter?   boolean                                     default true
 *     …every attribute of a div, and a ref
 *
 * # Behaviour
 *
 * R1  Renders one div, centred in its parent's inline direction. It fills
 *     its parent up to its cap in any parent: block, flex, or grid.
 * R2  Each width is a cap, never a fixed size: in a parent narrower than the
 *     cap, the container is as wide as the parent allows.
 *       narrow    `--narrow-max`   a single focused task: sign-in, a short form
 *       measure   `--measure`      a reading column, capped in characters
 *       content   `--content-max`  a document or settings page
 *       page      `--page-max`     the full working width of an app screen
 * R3  The width caps the CONTENT. With `gutter`, `--gutter` is added on both
 *     inline sides outside that cap, so a measure stays a measure however wide
 *     the gutter is.
 * R4  Without `gutter` the content touches the container's edges; use it when
 *     the parent already provides side padding.
 * R5  All four caps and the gutter are tokens, so each preset may retune them.
 *
 * # Deliberately absent
 *
 * Vertical padding, a background, and breakpoint props. A container places a
 * column; what fills the column is its children's business.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * R3 is a border-box width of 100% capped at `cap + 2 × gutter`, with the
 * gutter as padding: the content box can never exceed the cap, and the
 * gutter never eats into it. The width is explicit because a container is
 * often a grid or flex child, where auto margins alone shrink an item to its
 * content. Centring is `margin-inline: auto` — the one margin in the layout
 * group, and the reason the group rule on margins names an exception.
 */
export {};

src/lib/components/layout/container/Container.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLAttributes } from 'svelte/elements';
	import { cn } from '$lib/utils/cn';
	import { containerVariants, type ContainerVariants } from './container.variants';

	export type ContainerProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> &
		ContainerVariants & {
			class?: string;
			children?: Snippet;
		};

	let { children, width, gutter, class: className = '', ...rest }: ContainerProps = $props();
</script>

<div {...rest} class={cn(containerVariants({ width, gutter }), className)}>
	{@render children?.()}
</div>

src/lib/components/layout/container/container.variants.ts

import { cva, type VariantProps } from 'class-variance-authority';

import styles from './container.module.css';

export const containerVariants = cva(styles.root, {
	variants: {
		width: {
			narrow: styles.narrow,
			measure: styles.measure,
			content: styles.content,
			page: styles.page
		},
		gutter: { true: styles.gutter, false: null }
	},
	defaultVariants: { width: 'page', gutter: true }
});

export type ContainerVariants = VariantProps<typeof containerVariants>;

src/lib/components/layout/container/container.module.css

@layer primitive {
	.root {
		--container-gutter: 0px;
		box-sizing: border-box;
		/* Fill the parent, then cap. A width of 100% matters in a grid or flex
       parent, where auto margins alone would shrink the container to fit its
       content. */
		width: 100%;
		min-width: 0;
		/* The cap is the CONTENT width; gutters are added outside it, so a
       reading measure stays a reading measure however wide the gutter is. */
		max-width: calc(var(--container-max) + 2 * var(--container-gutter));
		margin-inline: auto;
		padding-inline: var(--container-gutter);
	}
	.narrow {
		--container-max: var(--narrow-max);
	}
	.measure {
		--container-max: var(--measure);
	}
	.content {
		--container-max: var(--content-max);
	}
	.page {
		--container-max: var(--page-max);
	}
	.gutter {
		--container-gutter: var(--gutter);
	}
}

Grid

columns · min · gap — fixed, fluid, or both

columns={3}
OneTwoThreeFourFiveSix
min="9rem"
OneTwoThreeFourFiveSix
columns={4} min="12rem"
OneTwoThreeFourFiveSix
gap
smsmsmsm
mdmdmdmd
lglglglg

Resize the window. columns alone never changes. min alone adds columns while they fit. Both together means “up to four, fewer when a cell would get narrower than 12rem”, which is the usual card grid.

Empty tracks are kept. A lone item in a wide fluid grid stays one column wide instead of stretching across the row.

Source src/lib/components/layout/grid/doc.ts · src/lib/components/layout/grid/Grid.svelte · src/lib/components/layout/grid/grid.variants.ts · src/lib/components/layout/grid/grid.module.css

src/lib/components/layout/grid/doc.ts

/**
 * Grid — children in equal columns, fixed or fitted to the space.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     columns?   1 | 2 | 3 | 4 | 5 | 6
 *     min?       a length in px, rem, em, or ch
 *     gap?       "sm" | "md" | "lg"                  default "md"
 *     …every attribute of a div, and a ref
 *
 * # Behaviour
 *
 * R1  Neither `columns` nor `min`: one column.
 * R2  `columns` alone: exactly that many equal columns at every width.
 * R3  `min` alone: as many equal columns as fit without any being narrower
 *     than `min`. Empty tracks are kept, so a lone item in a wide grid stays
 *     one column wide rather than stretching across the row.
 * R4  Both: as R3, but never more than `columns`. This is the responsive case:
 *     "up to four across, fewer when a card would get narrower than 14rem".
 * R5  In a container narrower than `min`, the single column shrinks to fit
 *     rather than overflowing.
 * R6  Columns are always equal, and a cell's long content wraps or shrinks
 *     within its column; it never widens its track.
 * R7  Gap steps resolve to `--space-3`, `--space-6`, and `--space-8` and apply
 *     between rows as well as columns — the same steps as Stack.
 *
 * # Deliberately absent
 *
 * Spanning, named areas, unequal tracks, and alignment props. A layout that
 * needs them is a screen's own grid rule; Grid covers the repeated-card case.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * `columns` and `min` travel as custom properties on the element, and one of
 * three classes chooses the template. R4's cap is the track minimum
 * `max(min(100%, min), (100% - (n - 1) × gap) / n)`: no track may be narrower
 * than an n-column track, so at most n fit. `auto-fill` rather than `auto-fit`
 * gives R3's "empty tracks are kept". `min` is typed to four units because a
 * percentage or bare number as a track minimum is almost always a mistake.
 */
export {};

src/lib/components/layout/grid/Grid.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLAttributes } from 'svelte/elements';
	import { cn } from '$lib/utils/cn';
	import { gridVariants, type GridVariants } from './grid.variants';

	/** A CSS length a caller can reason about; percentages and bare numbers are
	 *  excluded because a track minimum in either is almost always a mistake. */
	export type GridLength = `${number}${'px' | 'rem' | 'em' | 'ch'}`;

	export type GridProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> &
		GridVariants & {
			class?: string;
			children?: Snippet;
			/** Exactly this many columns, or with `min`, at most this many. */
			columns?: 1 | 2 | 3 | 4 | 5 | 6;
			/** The narrowest a column may be; columns are added while they fit. */
			min?: GridLength;
		};

	let { children, columns, min, gap, class: className = '', style, ...rest }: GridProps = $props();

	const layout = $derived(min ? (columns ? 'capped' : 'fluid') : 'fixed');
	const vars = $derived(
		[columns && `--grid-columns: ${columns}`, min && `--grid-min: ${min}`, style]
			.filter(Boolean)
			.join('; ') || undefined
	);
</script>

<div {...rest} class={cn(gridVariants({ gap, layout }), className)} style={vars}>
	{@render children?.()}
</div>

src/lib/components/layout/grid/grid.variants.ts

import { cva, type VariantProps } from 'class-variance-authority';

import styles from './grid.module.css';

export const gridVariants = cva(styles.root, {
	variants: {
		gap: { sm: styles.sm, md: styles.md, lg: styles.lg },
		// Derived from `columns` and `min` by the component, never passed directly.
		layout: { fixed: styles.fixed, fluid: styles.fluid, capped: styles.capped }
	},
	defaultVariants: { gap: 'md', layout: 'fixed' }
});

export type GridVariants = Omit<VariantProps<typeof gridVariants>, 'layout'>;

src/lib/components/layout/grid/grid.module.css

@layer primitive {
	.root {
		display: grid;
		min-width: 0;
		gap: var(--grid-gap);
	}

	/* `columns` alone: exactly N equal columns. minmax(0, 1fr) rather than 1fr,
     so a long word shrinks its cell instead of widening the track. */
	.fixed {
		grid-template-columns: repeat(var(--grid-columns, 1), minmax(0, 1fr));
	}

	/* `min` alone: as many columns as fit, none narrower than `min`. min(100%)
     keeps one column from overflowing a container narrower than `min`. */
	.fluid {
		grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--grid-min)), 1fr));
	}

	/* Both: fluid, but capped at N. A track can never be narrower than an
     N-column track, so at most N fit; it is never narrower than `min` either,
     so fewer fit as the container shrinks. */
	.capped {
		grid-template-columns: repeat(
			auto-fill,
			minmax(
				max(
					min(100%, var(--grid-min)),
					(100% - (var(--grid-columns) - 1) * var(--grid-gap)) / var(--grid-columns)
				),
				1fr
			)
		);
	}

	.sm {
		--grid-gap: var(--space-3);
	}
	.md {
		--grid-gap: var(--space-6);
	}
	.lg {
		--grid-gap: var(--space-8);
	}
}

Stack

direction · gap · wrap

vertical
First Second Third
horizontal
Short Taller item Short
gap
sm sm sm
md md md
lg lg lg
wrap
OneTwoThreeFourFiveSixSeven

Horizontal centres on the cross axis. A row of controls or labels with different heights shares a centre line; vertical stacks stretch children to the full width instead.

No alignment props. A layout that needs them is a screen's own flex or grid rule. The gap is the only spacing a Stack adds.

Source src/lib/components/layout/stack/doc.ts · src/lib/components/layout/stack/Stack.svelte · src/lib/components/layout/stack/stack.variants.ts · src/lib/components/layout/stack/stack.module.css

src/lib/components/layout/stack/doc.ts

/**
 * Stack — children in one direction, with a gap between them.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     direction?   "vertical" | "horizontal"      default "vertical"
 *     gap?         "sm" | "md" | "lg"             default "md"
 *     wrap?        boolean                        default false
 *     …every attribute of a div, and a ref
 *
 * # Behaviour
 *
 * R1  Renders one flex container; children are laid out in source order.
 * R2  `vertical` flows top to bottom and stretches children to its width.
 * R3  `horizontal` flows in the inline direction and centres children on the
 *     cross axis, because a row of controls or labels of different heights
 *     should share a centre line.
 * R4  Gap steps resolve to `--space-3`, `--space-6`, and `--space-8`. The gap
 *     is the only spacing Stack adds; children keep their own padding.
 * R5  With `wrap`, children that do not fit move to a new line, and the gap
 *     applies between lines as well as between items.
 *
 * # Deliberately absent
 *
 * Alignment and justification props. A layout that needs them is a screen's
 * own grid or flex rule; a Stack with every flexbox property as a prop is a
 * stylesheet with worse ergonomics.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * `vertical` is `flex-direction: column` and relies on the default
 * `align-items: stretch`; `horizontal` sets `align-items: center` explicitly.
 */
export {};

src/lib/components/layout/stack/Stack.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLAttributes } from 'svelte/elements';
	import { cn } from '$lib/utils/cn';
	import { stackVariants, type StackVariants } from './stack.variants';

	export type StackProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> &
		StackVariants & {
			class?: string;
			children?: Snippet;
		};

	let { children, direction, gap, wrap, class: className = '', ...rest }: StackProps = $props();
</script>

<div {...rest} class={cn(stackVariants({ direction, gap, wrap }), className)}>
	{@render children?.()}
</div>

src/lib/components/layout/stack/stack.variants.ts

import { cva, type VariantProps } from 'class-variance-authority';
import styles from './stack.module.css';

export const stackVariants = cva(styles.root, {
	variants: {
		direction: { vertical: styles.vertical, horizontal: styles.horizontal },
		gap: { sm: styles.sm, md: styles.md, lg: styles.lg },
		wrap: { true: styles.wrap }
	},
	defaultVariants: { direction: 'vertical', gap: 'md' }
});

export type StackVariants = VariantProps<typeof stackVariants>;

src/lib/components/layout/stack/stack.module.css

@layer primitive {
	.root {
		display: flex;
		min-width: 0;
	}
	.vertical {
		flex-direction: column;
	}
	.horizontal {
		flex-direction: row;
		align-items: center;
	}
	.sm {
		gap: var(--space-3);
	}
	.md {
		gap: var(--space-6);
	}
	.lg {
		gap: var(--space-8);
	}
	.wrap {
		flex-wrap: wrap;
	}
}

Box

surface · padding — one div, no wrapper

surface
plain No appearance
panel The work surface
raised Above the page
padding
none
sm
md
lg

Surface and padding are independent. A panel with no padding is valid, and so is a plain box with padding; the defaults are plain and none, so an unconfigured Box is an ordinary div.

Raised is a position, not emphasis. Use it for a surface that sits above the page. Content that only needs to stand out belongs on a panel.

Source src/lib/components/layout/box/doc.ts · src/lib/components/layout/box/Box.svelte · src/lib/components/layout/box/box.variants.ts · src/lib/components/layout/box/box.module.css

src/lib/components/layout/box/doc.ts

/**
 * Box — a surface with padding.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     surface?   "plain" | "panel" | "raised"      default "plain"
 *     padding?   "none" | "sm" | "md" | "lg"       default "none"
 *     …every attribute of a div, and a ref
 *
 * # Behaviour
 *
 * R1  Renders exactly one div. It never adds a wrapper around its children.
 * R2  `plain` has no appearance: no background, border, radius, or shadow.
 * R3  `panel` is the ordinary work surface: `--surface-panel` background, a
 *     `--line` border, and `--radius-3` corners.
 * R4  `raised` is `panel`'s shape on `--surface-raised`, with `--shadow`. It is
 *     for a surface that sits above the page: a focused task or a popover-like
 *     region. It is not a way to add emphasis to ordinary content.
 * R5  Padding steps resolve to `--space-4`, `--space-6`, and `--space-8`;
 *     `none` sets no padding.
 * R6  Surface and padding are independent: any combination is valid.
 *
 * # Deliberately absent
 *
 * Margin, colour, arbitrary spacing values, and a polymorphic element prop.
 * A different element with a surface is a job for that element's own class.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * This differs from Flover's Box, which has no appearance at all. Bento's
 * presets restyle surfaces heavily (v2's warm cards, v5's bounded panels), and
 * a surface primitive gives those tokens one consumer instead of one per
 * screen. `plain` is the Flover behaviour, and it is the default.
 */
export {};

src/lib/components/layout/box/Box.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLAttributes } from 'svelte/elements';
	import { cn } from '$lib/utils/cn';
	import { boxVariants, type BoxVariants } from './box.variants';

	export type BoxProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> &
		BoxVariants & {
			class?: string;
			children?: Snippet;
		};

	let { children, surface, padding, class: className = '', ...rest }: BoxProps = $props();
</script>

<div {...rest} class={cn(boxVariants({ surface, padding }), className)}>
	{@render children?.()}
</div>

src/lib/components/layout/box/box.variants.ts

import { cva, type VariantProps } from 'class-variance-authority';
import styles from './box.module.css';

export const boxVariants = cva(styles.root, {
	variants: {
		surface: { plain: null, panel: styles.panel, raised: styles.raised },
		padding: { none: null, sm: styles.sm, md: styles.md, lg: styles.lg }
	},
	defaultVariants: { surface: 'plain', padding: 'none' }
});

export type BoxVariants = VariantProps<typeof boxVariants>;

src/lib/components/layout/box/box.module.css

@layer primitive {
	.root {
		min-width: 0;
	}
	.panel {
		border: 1px solid var(--line);
		border-radius: var(--radius-3);
		background: var(--surface-panel);
	}
	.raised {
		border: 1px solid var(--line);
		border-radius: var(--radius-3);
		background: var(--surface-raised);
		box-shadow: var(--shadow);
	}
	.sm {
		padding: var(--space-4);
	}
	.md {
		padding: var(--space-6);
	}
	.lg {
		padding: var(--space-8);
	}
}

Separator

orientation · decorative — announced by default

horizontal
Account Billing
vertical
Docs
Changelog
Status

Announced unless you say otherwise. Pass decorative for a rule a reader already gets from the headings around it, which is most of them. The vertical links above are decorative; the horizontal rule is announced.

No margin. Space around a separator comes from the parent's gap, like any other child.

Source src/lib/components/layout/separator/doc.ts · src/lib/components/layout/separator/Separator.svelte · src/lib/components/layout/separator/separator.module.css

src/lib/components/layout/separator/doc.ts

/**
 * Separator — a line between groups of content.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     orientation?   "horizontal" | "vertical"      default "horizontal"
 *     decorative?    boolean                        default false
 *     …every attribute of a div except role and children, and a ref
 *
 * # Behaviour
 *
 * R1  A 1px line in `--line`. Horizontal spans its parent's width; vertical
 *     spans the height of a flex row, and one line of text elsewhere.
 * R2  By default it is announced as a separator. A vertical one also announces
 *     its orientation; a horizontal one does not need to, because horizontal
 *     is what a separator is assumed to be.
 * R3  With `decorative` it is hidden from assistive technology and carries no
 *     orientation.
 * R4  It adds no margin. Space around it comes from the parent's gap, like any
 *     other child of a Stack or Grid.
 *
 * # Deliberately absent
 *
 * Labels ("— or —"), thickness, and colour variants. A labelled divider is a
 * composition; a heavier rule is a different element with a different meaning.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Announced by default, as Radix and bits-ui do: over-announcing is noise,
 * under-announcing is lost structure. Pass `decorative` for a rule a reader
 * already gets from the headings around it, which is most of them.
 *
 * Hand-rolled rather than wrapped: the whole behaviour is a role and one
 * attribute, and the headless primitives render the same div. It is a div
 * rather than <hr> because a vertical <hr> is not a thing the platform styles
 * or announces sensibly.
 */
export {};

src/lib/components/layout/separator/Separator.svelte

<script lang="ts">
	import type { HTMLAttributes } from 'svelte/elements';
	import { cn } from '$lib/utils/cn';
	import styles from './separator.module.css';

	export type SeparatorProps = Omit<
		HTMLAttributes<HTMLDivElement>,
		'class' | 'role' | 'children'
	> & {
		class?: string;
		orientation?: 'horizontal' | 'vertical';
		/** Visual grouping only: hidden from assistive technology. */
		decorative?: boolean;
	};

	let {
		orientation = 'horizontal',
		decorative = false,
		class: className = '',
		...rest
	}: SeparatorProps = $props();
</script>

<!-- Horizontal is the separator role's implicit orientation. -->
<div
	{...rest}
	role={decorative ? 'none' : 'separator'}
	aria-orientation={!decorative && orientation === 'vertical' ? 'vertical' : undefined}
	data-orientation={orientation}
	class={cn(styles.root, styles[orientation], className)}
></div>

src/lib/components/layout/separator/separator.module.css

@layer primitive {
	.root {
		flex: none;
		border: 0;
		background: var(--line);
	}
	.horizontal {
		width: 100%;
		height: 1px;
	}
	.vertical {
		width: 1px;
		/* Full height of a flex row; a line of text tall anywhere else. */
		align-self: stretch;
		min-height: 1em;
	}
}

AspectRatio

ratio — height follows width

ratio
16 / 9
4 / 3
1
3 / 4
media

The ratio is binding. Content larger than the box is cropped rather than stretching it. Media children fill the box and are cropped to it, keeping their own proportions.

No appearance. The rounded corners here are a class on the AspectRatio, not part of it.

Source src/lib/components/layout/aspect-ratio/doc.ts · src/lib/components/layout/aspect-ratio/AspectRatio.svelte · src/lib/components/layout/aspect-ratio/aspect-ratio.module.css

src/lib/components/layout/aspect-ratio/doc.ts

/**
 * AspectRatio — a box whose height follows its width.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     ratio?   number, width ÷ height                  default 1
 *     …every attribute of a div, and a ref
 *
 * # Behaviour
 *
 * R1  Renders one div as wide as its parent, with height = width ÷ ratio.
 * R2  The ratio is binding: content larger than the box is cropped, never
 *     allowed to stretch it.
 * R3  An image, video, iframe, canvas, or svg child fills the box and is
 *     cropped to it, keeping its own proportions (it covers, it does not
 *     distort). Any other child fills the box.
 * R4  A ratio that is not a positive finite number is treated as 1.
 * R5  No appearance: no radius, border, or background. A rounded thumbnail is
 *     the caller's class on this element.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The platform `aspect-ratio` property on a single element, rather than the
 * headless libraries' padding-bottom wrapper, which renders two divs. R2 needs
 * `overflow: hidden`: with overflow visible, a box's automatic minimum height
 * lets tall content override the ratio. The box is a one-cell grid so any
 * child stretches to fill both axes (R3) without absolute positioning.
 */
export {};

src/lib/components/layout/aspect-ratio/AspectRatio.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLAttributes } from 'svelte/elements';
	import { cn } from '$lib/utils/cn';
	import styles from './aspect-ratio.module.css';

	export type AspectRatioProps = Omit<HTMLAttributes<HTMLDivElement>, 'class'> & {
		class?: string;
		children?: Snippet;
		/** Width divided by height: 16 / 9, 4 / 3, 1. */
		ratio?: number;
	};

	let { children, ratio = 1, class: className = '', style, ...rest }: AspectRatioProps = $props();

	const safe = $derived(Number.isFinite(ratio) && ratio > 0 ? ratio : 1);
</script>

<div
	{...rest}
	class={cn(styles.root, className)}
	style={[`aspect-ratio: ${safe}`, style].filter(Boolean).join('; ')}
>
	{@render children?.()}
</div>

src/lib/components/layout/aspect-ratio/aspect-ratio.module.css

@layer primitive {
	.root {
		display: grid;
		width: 100%;
		min-width: 0;
		/* Also what makes the ratio binding: with overflow visible, content
       taller than the ratio would stretch the box instead of being cropped. */
		overflow: hidden;
	}
	.root > * {
		min-width: 0;
		min-height: 0;
	}
	.root > :where(img, video, iframe, canvas, svg) {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}
}