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.
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 * 100by 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.
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.
cover — fills the frame, crops the rest
contain — whole image, bars at the sides
fill — stretched to the frame
none — intrinsic 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.
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.
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.
plain — pure layout — no background, border or radius
outlined — a hairline frame, for light-on-light media
filled — a 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.
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.
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:
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.
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.
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:
alton the image. An emptyalt=""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.
| Property | Controls | Default |
|---|---|---|
--forte-aspect-ratio | The 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-fit | How direct media children fill the box. fit sets this. | cover |
--forte-aspect-position | Which part of a cropped image survives the crop — an object-position value. | 50% 50% |
--forte-aspect-radius | Corner radius. 0px for plain; both other variants raise it to --forte-radius-surface. | 0px |
--forte-aspect-background | What 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-color | Frame colour. Only painted when the width below is non-zero. | var(--forte-color-border-muted) |
--forte-aspect-border-width | Frame 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
| Prop | Type | Default | Description |
|---|---|---|---|
basis | AspectRatioBasis | inline | Which 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. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
clip | boolean | true | Clips 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. |
fit | AspectRatioFit | cover | How 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`. |
ratio | AspectRatioValue | 1 | The 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. |
render | RenderProp<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. | |
variant | AspectRatioVariant | plain | How 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. |