Skip to content

Aspect ratio

Holds media to a fixed ratio while it loads.

Level
Utility
Category
Layout
Adoption
Not used yet
import { AspectRatio } from "@oration/canon/components/aspect-ratio";
packages/canon/src/components/aspect-ratio.tsx
24:18
Call with Northwind Freight about INV-20944Jordan Lee, Monday, Sep 28 at 10:00 AM
import { AspectRatio } from "@oration/canon/components/aspect-ratio";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { PlayIcon } from "lucide-react";export function Hero() {    return (        <figure className="flex w-full max-w-md flex-col gap-2">            <AspectRatio                ratio={16 / 9}                className="overflow-hidden rounded-xl bg-muted"            >                <div className="absolute inset-0 flex items-center justify-center">                    <Tooltip>                        <TooltipTrigger                            render={                                <Button                                    type="button"                                    variant="outline"                                    size="icon-lg"                                    aria-label="Play recording"                                    className="rounded-full bg-background"                                    onClick={() =>                                        toast.add({                                            title: "Playing recording",                                            description:                                                "Call with Northwind Freight, 24 minutes.",                                        })                                    }                                />                            }                        >                            <PlayIcon aria-hidden="true" />                        </TooltipTrigger>                        <TooltipContent>Play recording</TooltipContent>                    </Tooltip>                </div>                <span className="absolute right-2 bottom-2 rounded-md bg-background/85 px-1.5 py-0.5 font-mono text-xs text-foreground">                    24:18                </span>            </AspectRatio>            <figcaption className="flex flex-col">                <span className="text-13 font-medium text-foreground">                    Call with Northwind Freight about INV-20944                </span>                <span className="text-xs text-muted-foreground">                    Jordan Lee, Monday, Sep 28 at 10:00 AM                </span>            </figcaption>        </figure>    );}

Usage#

AspectRatio is a <div> that holds a width-to-height ratio with the CSS aspect-ratio property, so media and its placeholder keep their box while loading and the layout doesn't jump. Pass ratio as a number (16 / 9); it's set as --ratio. Nothing in the product uses it yet: the recording player, the meeting detail skeleton and Video facade use Tailwind's aspect-video directly, which does the same job for 16:9. The usual mistake is letting tall children stretch the box past its ratio.

When to use

  • Media with a known ratio that loads after the layout: recording thumbnails, receipt photos, document scans.
  • A skeleton that must occupy exactly the space the media will.
  • Ratios Tailwind doesn't name, such as a letter-size W-9 at 8.5 / 11 or a 4:3 receipt.
  • A ratio that comes from data, such as an uploaded image's own width and height.

When not to use

  • A 16:9 video with a poster and a play button. The facade already holds the ratio and loads the player on demand. Use Video facade
  • A fixed 16:9 or 1:1 box in your own markup. aspect-video or aspect-square is enough.
  • People and company images. Use Avatar
  • Content-driven blocks such as cards of text, whose height should follow their content. Use Card

The Tint Well Rule

Inside a card, a sub-region is a Well Gray tint at 70% with a 10px radius, never a second bordered or shadowed card. A media placeholder inside a card follows it: rounded-[10px] bg-muted/70.

Anatomy#

  1. Box. relative aspect-(--ratio). Width comes from the parent; height follows from the ratio.
  2. Media. An absolutely positioned child (absolute inset-0 size-full), usually an image with object-cover, a video or a placeholder.
  3. Overlay. Optional controls or meta on top of the media, such as a play button or a timecode.

Examples#

Ratios

ratio is width over height. Every box takes its column's width and derives its height, so a row of mixed media lines up on the bottom edge.

1 : 1
Logo crop
4 : 3
Receipt photo
16 : 9
Recording
8.5 : 11
W-9 scan
import { AspectRatio } from "@oration/canon/components/aspect-ratio";export function Ratios() {    const ratios = [        { ratio: 1, label: "1 : 1", use: "Logo crop" },        { ratio: 4 / 3, label: "4 : 3", use: "Receipt photo" },        { ratio: 16 / 9, label: "16 : 9", use: "Recording" },        { ratio: 8.5 / 11, label: "8.5 : 11", use: "W-9 scan" },    ];    return (        <div className="grid w-full max-w-xl grid-cols-2 items-end gap-4 sm:grid-cols-4">            {ratios.map((item) => (                <div key={item.label} className="flex flex-col gap-1.5">                    <AspectRatio                        ratio={item.ratio}                        className="flex items-center justify-center rounded-[10px] bg-muted"                    >                        <span className="text-13 font-medium text-foreground tabular-nums">                            {item.label}                        </span>                    </AspectRatio>                    <span className="text-xs text-muted-foreground">                        {item.use}                    </span>                </div>            ))}        </div>    );}

Holding space while it loads

A W-9 preview in a document card. Reload it and the shimmer fills the same letter-size box, so the title and actions below never move.

W-9, Orchard Street SupplyReceived Mar 3, 2026
import { AspectRatio } from "@oration/canon/components/aspect-ratio";import { Button } from "@oration/canon/components/button";import { SkeletonReveal } from "@oration/canon/components/skeleton-reveal";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { DownloadIcon, RotateCcwIcon } from "lucide-react";import * as React from "react";export function LoadingPreview() {    const [loading, setLoading] = React.useState(false);    React.useEffect(() => {        if (!loading) return;        const id = window.setTimeout(() => setLoading(false), 900);        return () => window.clearTimeout(id);    }, [loading]);    return (        <div className="flex w-full max-w-xs flex-col gap-3 rounded-xl bg-card p-3 shadow-border">            <AspectRatio                ratio={8.5 / 11}                className="overflow-hidden rounded-[10px] bg-muted/70"            >                <SkeletonReveal                    loading={loading}                    label="Loading scan"                    className="absolute inset-0"                    skeleton={                        <div                            aria-hidden="true"                            className="absolute inset-0 skeleton-shimmer"                        />                    }                >                    <div                        role="img"                        aria-label="Scanned W-9 from Orchard Street Supply, signed March 3, 2026"                        className="absolute inset-3 flex flex-col gap-2 rounded-md bg-background p-3 shadow-border"                    >                        <span className="text-xs font-medium text-foreground">                            Form W-9                        </span>                        <span className="text-xs text-muted-foreground">                            Orchard Street Supply LLC                        </span>                        {[90, 75, 82, 60, 88, 70].map((width) => (                            <span                                key={width}                                className="h-1.5 rounded-full bg-muted"                                style={{ width: `${width}%` }}                            />                        ))}                        <span className="mt-auto self-end font-mono text-xs text-muted-foreground">                            EIN ending 4471                        </span>                    </div>                </SkeletonReveal>            </AspectRatio>            <div className="flex items-center justify-between gap-2">                <div className="flex min-w-0 flex-col">                    <span className="truncate text-13 font-medium text-foreground">                        W-9, Orchard Street Supply                    </span>                    <span className="text-xs text-muted-foreground">                        Received Mar 3, 2026                    </span>                </div>                <div className="flex gap-1">                    <Tooltip>                        <TooltipTrigger                            render={                                <Button                                    type="button"                                    variant="ghost"                                    size="icon-sm"                                    aria-label="Reload preview"                                    onClick={() => setLoading(true)}                                />                            }                        >                            <RotateCcwIcon aria-hidden="true" />                        </TooltipTrigger>                        <TooltipContent>Reload preview</TooltipContent>                    </Tooltip>                    <Tooltip>                        <TooltipTrigger                            render={                                <Button                                    type="button"                                    variant="ghost"                                    size="icon-sm"                                    aria-label="Download PDF"                                    onClick={() =>                                        toast.add({                                            title: "Downloading W-9 for Orchard Street Supply",                                        })                                    }                                />                            }                        >                            <DownloadIcon aria-hidden="true" />                        </TooltipTrigger>                        <TooltipContent>Download PDF</TooltipContent>                    </Tooltip>                </div>            </div>        </div>    );}

States#

States
StateTreatment
LoadingA skeleton or tint fills the box at its final size.
LoadedThe media replaces the placeholder in place.
FailedKeep the box and show a quiet fallback inside it: an icon and one line, such as Preview unavailable.

Behavior#

  • ratio is required and is width divided by height: 16 / 9, 4 / 3, 8.5 / 11, 1.
  • The box takes the parent's width. Constrain it with the parent or a max-w-* class on the box.
  • With aspect-ratio, content taller than the box makes it grow. Position children absolutely (absolute inset-0) or add overflow-hidden so the ratio holds.
  • It's a server-safe component: no hooks, no "use client".

Do and don't#

Call with Halcyon Packaging
Do. Reserve the media's box before it loads, with a placeholder at the final size.
Loading preview…
Call with Halcyon Packaging
Don't. Let the media set its own height when it arrives, so everything below it jumps.
Do. Fill the box with an absolutely positioned child and object-cover.
Don't. Put flowing content inside it, which stretches the box past its ratio.

Content#

  • Give images real alt text that says what's shown: Scanned W-9 from Orchard Street Supply, signed March 3, 2026. Decorative placeholders get aria-hidden.
  • Captions and meta go below the frame, not over the media.
  • A failed preview says what's missing and what still works: Preview unavailable. Download the PDF instead.

Accessibility#

  • The box is a plain <div> with no role; the media inside carries the semantics (an <img> with alt, a <video> with controls and captions).
  • Mark skeletons and decorative fills aria-hidden, and expose loading on the region with aria-busy.
  • Controls laid over media need a visible focus ring against any frame, and a name: Play recording, not Play.

Design tokens#

Design tokens
TokenUsed for
--ratioSet inline from ratio, read by aspect-(--ratio)
--mutedPlaceholder and letterbox fill in the examples
--radius-xlFrame corners for standalone media (rounded-xl)

API reference#

AspectRatio

From @oration/canon/components/aspect-ratio.

Other props spread onto <div>.

Props of AspectRatio
PropTypeDefaultDescription
ratioRequirednumberNo defaultWidth divided by height, e.g. 16 / 9.
classNamestringNo defaultMerged after relative aspect-(--ratio).
styleCSSPropertiesNo defaultAvoid passing it: it replaces the inline --ratio set from ratio.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Not used in apps/web. The recording player, the meeting detail skeleton and Video facade hand-roll aspect-video instead.

Props spread after style, so passing style drops --ratio and the box loses its ratio.