Skip to content

Aspect Ratio

A box that keeps its shape, whatever goes in it. The reason to reach for one is layout shift: the frame occupies its final size before the media has downloaded a byte, so a picture that arrives half a second later lands in space that was already there instead of shoving the rest of the page down.

aspect-ratio/basic.tsx
A harbour at dusk, seen from the water

Cais do Sodré, 19:40

Import

import { AspectRatio } from "@forte-ui/react";

Built on the property, not the trick

Radix — and therefore shadcn — draws an aspect box with the padding hack: a wrapper with padding-bottom: 56.25% and a single child pinned to position: absolute; inset: 0. That predates the aspect-ratio property, and it costs three things:

  • The child has to leave flow. A second one — a caption, a play button, a gradient — needs its own absolute positioning and its own stacking order.
  • A percentage padding always resolves against the container's inline size. So the box can only ever turn a width into a height; a height-constrained parent is not expressible at all.
  • The ratio arrives as a pre-computed percentage. Restating it at another breakpoint means working out 9 / 16 * 100 by hand.

This component uses the property. Three of its features fall straight out of that: children stack without leaving flow, basis="block" measures on the other axis, and the ratio stays a CSS value a media query can restate.

Examples

Named ratios

ratio takes a number (16 / 9), a CSS ratio string ("16 / 9"), the spoken form ("16:9"), or one of seven names. Prefer a name where one fits: "story" says what the box is for, where 0.5625 says only what it measures.

aspect-ratio/presets.tsx
1:1
square
16:9
video
21:9
wide
4:3
photo
3:4
portrait
9:16
story
1.618:1
golden

Fitting the media

An <img> at its intrinsic size inside a fixed-shape box is either letterboxed or overflowing, so AspectRatio sizes direct media children to the frame and applies object-fit for you. Without that, every caller writes the same width: 100%; height: 100%; object-fit: cover on the child, forever.

cover is the default and what a media slot almost always wants. contain is right when the subject must not be cut — a logo, a diagram, a product shot on white.

aspect-ratio/fit.tsx

coverfills the frame, crops the rest

containwhole image, bars at the sides

fillstretched to the frame

noneintrinsic size, cropped by the frame

Choosing what survives the crop

cover has to throw something away. --forte-aspect-position is how you say which something — it is an object-position value, and its 50% 50% default is the one choice guaranteed to behead a portrait.

aspect-ratio/focal.tsx
50% 0%
50% 50% (default)
50% 100%

Stacking, without position: absolute

The box is a one-cell grid and every direct child is placed in that cell, in source order. So a caption over a photo is two elements and an alignment — nothing leaves flow, nothing needs a z-index, and each layer still positions itself with place-self.

aspect-ratio/overlay.tsx
A harbour at dusk, seen from the water
Cais do Sodré, 19:40

Variants

variant is how much chrome the box carries. plain is pure layout. outlined adds a hairline frame, which gives light-on-light media an edge. filled adds a recessed panel — and because that panel is visible while the box is still empty, it is also the loading placeholder.

aspect-ratio/variants.tsx

plainpure layout — no background, border or radius

outlineda hairline frame, for light-on-light media

filleda recessed panel, and the loading placeholder

That last one is the anti-layout-shift story finished rather than started: the space is reserved and it looks reserved.

aspect-ratio/placeholder.tsx

This paragraph does not move.

Measuring on the block axis

basis="block" takes a definite height and derives the width, which is the case a percentage padding cannot express. A fixed-height row of thumbnails is the usual reason to want it.

aspect-ratio/basis.tsx
square
video
portrait

The row sets the height. Each frame derives its own width.

A ratio that changes with the layout

ratio does not set --forte-aspect-ratio. It seeds it: the prop writes --forte-aspect-ratio-seed as an inline style, and the stylesheet reads that into the knob from inside @layer forte.components.

The indirection exists because a ratio cannot be enumerated the way fit or variant can — there is no [data-ratio="…"] rule to write — so the value has to arrive inline, and an inline declaration outranks every author rule short of !important. ratio would then be the one prop in this library that plain CSS could not override. Seeding instead keeps the knob in a layer, where an ordinary class beats it:

.hero {
  --forte-aspect-ratio: 3 / 4;
}

@media (width >= 48rem) {
  .hero {
    --forte-aspect-ratio: 16 / 9;
  }
}

Which is a container query away from being the right thing in a component library:

aspect-ratio/responsive.tsx
A harbour at dusk, seen from the water

Letting something break the frame

clip is on by default — media overflowing its own frame is a bug, not a feature. Turn it off for the decoration that is meant to escape: a badge on a corner, a focus ring on a child, a tooltip anchored inside.

aspect-ratio/unclipped.tsx
New
clip={true}
New
clip={false}

Changing the element

render swaps the <div> for whatever the box actually is — a <figure> around a caption, or the <a> that wraps a whole thumbnail.

aspect-ratio/link.tsx

A focusable box rings: AspectRatio carries .forte-focus-ring, which costs a plain <div> nothing — the class only paints on :focus-visible, which a non-focusable element never matches. And the component declares no cursor at all, precisely so that a linked frame keeps the pointer the anchor already earns. Both are cases of doing less rather than more.

The UA's own defaults are left alone for the same reason: <figure> keeps its margin and <a> its colour, because a box that quietly stripped the element it was handed would be the harder thing to work around. Set them where you use them.

Accessibility

The box is layout. It renders no role, takes no focus, and adds nothing to the accessibility tree — which is correct, and means the accessibility of what you put in it is unchanged by putting it there.

Two things are still yours:

  • alt on the image. An empty alt="" where the caption beside the frame already says it; real text where the picture carries information nothing else does. A decorative frame does not make its contents decorative.
  • Contrast for anything laid over media. Text on a photograph has no guaranteed background, so SC 1.4.3 cannot be met by the text colour alone. Put it on a band or a scrim, as the overlay example does, rather than trusting the image to stay dark where the words are.

Theming

Every visual decision is a --forte-aspect-* custom property.

Theming tokens for AspectRatio
PropertyControlsDefault
--forte-aspect-ratioThe shape the box holds, as a CSS aspect-ratio value. The ratio prop seeds it; declare it yourself — in a media or container query — for a shape that changes with the layout, and your declaration wins.var(--forte-aspect-ratio-seed, 1)
--forte-aspect-fitHow direct media children fill the box. fit sets this.cover
--forte-aspect-positionWhich part of a cropped image survives the crop — an object-position value.50% 50%
--forte-aspect-radiusCorner radius. 0px for plain; both other variants raise it to --forte-radius-surface.0px
--forte-aspect-backgroundWhat sits behind the content. variant="filled" sets it — which is what makes the box its own placeholder while an image loads.transparent
--forte-aspect-border-colorFrame colour. Only painted when the width below is non-zero.var(--forte-color-border-muted)
--forte-aspect-border-widthFrame width. 0px unless variant="outlined" — or forced colours are active, where filled takes one too so its placeholder does not vanish into the page.0px

Nothing in the file transitions. The box has no states — no hover, no focus, no open or closed — so there is nothing to animate, and a colour transition on the placeholder would only make it lag the rest of the page on a theme switch.

State is on data-variant, data-fit, data-basis and data-clip, so a consumer can reach all four from plain CSS or a Tailwind arbitrary variant (data-[variant=filled]:...) without wrapping the component.

API reference

Props for AspectRatio
PropTypeDefaultDescription
basisAspectRatioBasisinlineWhich axis the box measures itself on. `inline` takes the container's width and derives the height — the usual case, and the only one the padding-bottom technique can express. `block` does the reverse: it takes a definite height and derives the width, which is what a media tile in a fixed-height row or a full-bleed hero needs. `block` requires the parent to actually supply a height — a grid row, a stretched flex cross size, an explicit `height`. Given an indefinite one, `100%` has nothing to resolve against and the box falls back to sizing from its content.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
clipbooleantrueClips content to the box, and to its corner radius. On by default, because media that overflows its own frame is a bug rather than a feature. Turn it off for the overlay that is *meant* to break out — a badge hanging off a corner, a focus ring on a child, a tooltip anchored inside.
fitAspectRatioFitcoverHow direct media children — `img`, `video`, `canvas`, `iframe`, `embed`, `object`, and the `img` inside a `picture` — fill the box. It is an `object-fit` value, applied for you: media is stretched to the frame on both axes first, so without it an image is either letterboxed or overflowing, and every caller ends up writing the same three declarations on the child by hand. `cover` crops to fill and is what a media slot almost always wants; `contain` fits the whole image inside and leaves bars, which is right when the subject must not be cut — a logo, a diagram, a product shot on white. To keep an image from being cropped in the wrong place, set `--forte-aspect-position`.
ratioAspectRatioValue1The shape to hold. Takes a number (`16 / 9`), a CSS ratio string (`"16 / 9"`), the spoken form (`"16:9"`), or one of seven names: `square`, `video` (16:9), `wide` (21:9), `photo` (4:3), `portrait` (3:4), `story` (9:16), `golden` (1.618:1). Prefer the name where one fits — it says what the box is for, where `0.5625` says only what it measures.
renderRenderProp<Record<string, unknown>>Replaces the rendered `<div>` with another element or component. This is how the box becomes a `<figure>` around a caption, or the `<a>` that wraps a whole thumbnail.
variantAspectRatioVariantplainHow much chrome the box carries. `plain` is a pure layout box: no background, no border, no radius. `outlined` adds a hairline frame and a surface radius, which gives light-on-light media an edge. `filled` adds a recessed panel instead — and because that panel is visible while the box is still empty, it doubles as the loading placeholder for the image that is about to arrive.