Aspect ratio
Holds media to a fixed ratio while it loads.
- Status
- Experimental
- Level
- Utility
- Category
- Layout
- Adoption
- Not used yet
import { AspectRatio } from "@oration/canon/components/aspect-ratio";packages/canon/src/components/aspect-ratio.tsximport { 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 / 11or 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-videooraspect-squareis 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
rounded-[10px] bg-muted/70.Anatomy#
- Box.
relative aspect-(--ratio). Width comes from the parent; height follows from the ratio. - Media. An absolutely positioned child (
absolute inset-0 size-full), usually an image withobject-cover, a video or a placeholder. - 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.
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.
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#
| State | Treatment |
|---|---|
| Loading | A skeleton or tint fills the box at its final size. |
| Loaded | The media replaces the placeholder in place. |
| Failed | Keep the box and show a quiet fallback inside it: an icon and one line, such as Preview unavailable. |
Behavior#
ratiois 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 addoverflow-hiddenso the ratio holds. - It's a server-safe component: no hooks, no
"use client".
Do and don't#
object-cover.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>withalt, a<video>with controls and captions). - Mark skeletons and decorative fills
aria-hidden, and expose loading on the region witharia-busy. - Controls laid over media need a visible focus ring against any frame, and a name: Play recording, not Play.
Design tokens#
| Token | Used for |
|---|---|
--ratio | Set inline from ratio, read by aspect-(--ratio) |
--muted | Placeholder and letterbox fill in the examples |
--radius-xl | Frame corners for standalone media (rounded-xl) |
API reference#
AspectRatio
From @oration/canon/components/aspect-ratio.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
ratioRequired | number | No default | Width divided by height, e.g. 16 / 9. |
className | string | No default | Merged after relative aspect-(--ratio). |
style | CSSProperties | No default | Avoid 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.