AspectRatio
Media frame that holds content to a fixed width/height ratio. Eight named presets, arbitrary and responsive ratios, object-fit, an optional-clipping axis so overlays can cross the frame edge, and polymorphic render.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { AspectRatio } from "@tessinaui/ui";{/* Named preset. objectFit defaults to "cover". */}
<AspectRatio ratio="video" rounded="md">
<img src="/hero.jpg" alt="" />
</AspectRatio>
{/* Arbitrary ratio — a CSS string or a number */}
<AspectRatio ratio="2.35 / 1">…</AspectRatio>
<AspectRatio ratio={5 / 4}>…</AspectRatio>
{/* One ratio on phones, another from md up */}
<AspectRatio ratio={{ base: "square", md: "video" }}>…</AspectRatio>
{/* Let an overlay cross the frame edge */}
<AspectRatio ratio="video" clip={false}>
<img src="/cover.jpg" alt="" className="rounded-md" />
<Avatar className="absolute -bottom-5 start-4" />
</AspectRatio>
{/* An embed — iframes have no intrinsic size, so they need a frame */}
<AspectRatio ratio="video" rounded="md">
<iframe src="https://www.youtube.com/embed/…" title="…" />
</AspectRatio>Examples
Default
A 16:9 frame. The examples on this page use a flat themed plate rather than photography — docs ship no imagery, and the plate is the placeholder. In real use you pass an <img>, which objectFit fills the frame with by default rather than stretching.
Presets
All eight named ratios. square, video, portrait and landscape cover almost everything; poster is the streaming-poster shape; ultrawide and golden are there for the rare layout that wants them.
Custom ratios
Pass any CSS-valid string, or a number when the ratio is computed rather than written.
Responsive ratios
Pass { base, md } to hold one ratio on phones and another from the md breakpoint up. A 16:9 hero is a letterbox slot on a 375px screen, so the usual move is to go taller as the viewport narrows.
Object fit
How the media fills the frame. This is the one example on the page that ships an image rather than the plate: a flat fill renders identically under cover and fill, so it would demonstrate nothing. The source is deliberately off-ratio, and the circle is the tell — round under cover, oval under fill.
Rounded
The house radius scale. full resolves to the 24px panel cap, not a pill — a media frame is a rectangle, and a 9999px radius on a non-square box paints an ellipse.
Overlays
Two ways to put something on a frame: composited inside it, or straddling its edge. The second needs clip={false}.
Embeds
A YouTube player or a map iframe has no intrinsic size, so without a frame it collapses or guesses. This is the case the primitive exists for.
Loading
The frame already knows the height before the image arrives, which is why the page does not jump when it loads. Match ratio and rounded to the real thing.
Empty slot
An upload target or an unfilled gallery cell: the same frame at the same ratio, filled flat, with a glyph centred in it.
Polymorphic render
render replaces the frame's own element, so the frame becomes a link, a button or a <section>. A captioned figure does not need it.
When to use
Use it when the box has to hold a shape before its content arrives, or regardless of what the content is: video thumbnails and players, image grids and galleries, hero and cover media, product photos, posters, map and video embeds, upload slots, and the skeletons that stand in for any of those.
Reach for something else when:
- The content is text. Content-driven height has to win; a ratio-locked box either clips the text or breaks its own ratio.
- The media lives in a card.
Cardhas aCardMediapart that already owns the ratio, the scrim and the corner treatment for that context. - You want a gradient or scrim under overlaid text. That is
CardMedia'soverlayprop. A ratio box that ships a gradient has stopped being a layout primitive. - You are nesting one ratio box in another. The inner ratio silently wins and the outer one is lost.
Accessibility
- AspectRatio is presentational and adds no ARIA role. None of the thirteen design systems surveyed for this component add one either. A role on the wrapper would swallow the child's own semantics and break the decorative-image pattern below.
altstays on the media child, never on the frame. The frame cannot know whether the image is decorative, and movingaltonto it would create two places to set one value.- Decorative media takes
alt=""— present but empty, so assistive tech skips it. Media that carries meaning needs a real description of what it conveys, not what it depicts; keep it short, don't open with "image of…", and don't repeat text that is already next to it. If the image is a link, describe the destination. - Complex media — a chart, a diagram — needs its description in the page, not in
alt. You cannot navigate within alt text. - Interactive frames want
clip={false}. A clipped frame clips the focus ring along with everything else, and the ring is the one thing a keyboard user has to be able to see.
API Reference
AspectRatio props
| Prop | Type | Default | Description |
|---|---|---|---|
ratio | AspectRatioPreset | string | number | { base, md } | "video" | Target ratio. A preset name, any CSS-valid string, a number, or a { base, md } pair for a ratio that changes at the md band |
rounded | "none" | "sm" | "md" | "lg" | "full" | "none" | Corner radius. full caps at rounded-3xl (24px) |
objectFit | "cover" | "contain" | "fill" | "none" | "scale-down" | "cover" | Applied to the framed media child as object-* |
clip | boolean | true | Whether the frame clips its content. false lets an overlay — or a focus ring — cross the edge |
render | ReactElement | — | Polymorphic target; the frame becomes this element |
className | string | — | Merged last, so it wins |
style | CSSProperties | — | Merged after the ratio, so it can override it |
Every other div attribute — data-*, aria-*, event handlers — is forwarded to the root, and the root carries data-slot="aspect-ratio".
Named ratios
| Preset | Ratio | Typical use |
|---|---|---|
"square" | 1 / 1 | Album art, avatars, product tiles |
"video" (default) | 16 / 9 | Video thumbnails and players, hero banners |
"portrait" | 3 / 4 | Editorial photography |
"landscape" | 4 / 3 | Classic photography, map cards |
"poster" | 2 / 3 | Streaming posters, book and film covers |
"ultrawide" | 21 / 9 | Panoramas, cinematic headers. Rare |
"vertical" | 9 / 16 | Stories, reels, vertical video |
"golden" | 1.618 / 1 | Editorial layouts. Rare |
Anything the CSS aspect-ratio property accepts works as a string, so the presets are a convenience rather than a limit.
Notes
- Only the first child is fitted. It gets
size-full(in flow if it is media, absolutely positioned otherwise) plus theobject-*class. Every later child is passed through untouched so you can position it against the frame'srelativebox — that is how the corner badges in the Overlays example work. - Media means
<img>,<video>,<iframe>or<picture>. Anything else is treated as content and filled absolutely. - Centre content inside a plate, not against the frame. Because the first non-media child is filled with
absolute inset-0, a bare glyph passed straight in is pinned to the frame's top-left corner and the frame's ownplace-items-centercan never reach it. Wrap it:<AspectRatio><div className="grid size-full place-items-center">…</div></AspectRatio>. - A child's own
classNamewins, so a single tile can override the frame'sobjectFitwithout fighting it. clipalso applies atrounded="none". A frame that promises a ratio should hold it; without clipping, taller content silently grows the box and the ratio is a minimum rather than a rule.stylebeats the ratio.style={{ aspectRatio: "1 / 1" }}overridesratio— a deliberate escape hatch, not an accident.- On the
renderbranch the host's own props win, includingclassNameandstyle. This matchesSpacer. - No hooks in the render path, safe for SSR.
- Browser support: CSS
aspect-ratio— Safari 15+, Chrome 88+, Firefox 89+. - Deprecated:
rounded="xl" | "2xl" | "3xl"still work but are outside the house scale and will be removed. Uselg, orfullfor the 24px cap.