Aspect Ratio
A box that keeps its shape as it changes size.
src/components/rahti_ui/aspect_ratio.rs
Basic
A 16/9 box with a picture in it. The picture is 400×300 — a 4/3 shape — so what you are looking at is the box winning.
One element. `style="--ratio: 16 / 9"` carries the shape and `aspect-(--ratio)` reads it — that is the whole component, and the rest of this page is about what has to be true around it.
The box has a shape and no size. `w-full` here, and a `max-w-md` on the wrapper: something has to say how wide it is, and then the ratio says how tall. `overflow-hidden` is what keeps the image's corners inside the box's rounded ones.
Shapes
Six boxes, one class list. Nothing about the class string changes with the ratio — the shape is a custom property, because Tailwind compiles a fixed set of classes ahead of time and there is no `aspect-[16/9]` to generate for a value only known at request time.
square — the default
4 / 3
16 / 9
21 / 9
3 / 4 — portrait
1.5 — a bare number
`ratio` is written the way CSS writes the value it becomes. `"16/9"`, `"16 / 9"` and a single positive number are all accepted, and a bare number is a ratio against 1 — which is exactly what shadcn's `ratio={1.5}` means.
A square is what an unwritten `ratio` gives you, and that is Radix's default rather than an invention here. Anything unparseable falls back to it too, so a typo is never a broken page — and trips a `debug_assert` in development so it is not a silent one either.
What goes inside
Two utilities on the child, and the component writes neither of them.
size-full object-cover
no classes — the image ignores the box
object-contain — letterboxed instead of cropped
a 300×400 picture, cropped the other way
The box has a shape and no opinion about its contents, which is shadcn's arrangement and not an omission — the child says how it fills. `size-full` makes the image the size of the box and `object-cover` decides what to do about the mismatch; neither is written by this component.
The dashed one is what happens without them: the image keeps its intrinsic 400×300 and the box is just a box behind it. This is one place the port is easier than the original — under Radix the child had to be positioned inside an absolutely-positioned wrapper, and here it is an ordinary child of an ordinary box.
Things on top
The other kind of child: not the picture, but what sits over it.
A caption over the picture
`relative` in the base is not decoration. It makes the box a containing block, so a badge, a play button or a gradient inside it can be `absolute` without the call site arranging for anywhere to be absolute against.
It is the one utility the Base UI style carries that has nothing to do with the ratio, and it is there because Radix's version got it for free — the padding-bottom hack needs a positioned wrapper anyway.
A grid of boxes
Square thumbnails that stay square as the grid reflows, which is most of what this component is for.
`element="li"` so the `<ul>` contains list items and nothing else — a screen reader is then told there are four of them. It is also what keeps `sm:grid-cols-4` honest: each box is a real child of the grid, because a box with no binding renders as one literal element with no wrapper around it.
`figure` and `a` are deliberately not on the list. A `<figcaption>` inside this element would be inside the *ratio*, squeezed into the shape meant for the media — a figure wraps an AspectRatio rather than being one. And an `<a>` is an inline box, so `aspect-ratio` would do nothing until someone added a display utility the component does not write; put the link inside the box, or the box inside the link.
The style attribute
The ratio travels as a custom property, because Tailwind compiles a fixed set of classes ahead of time. That is a design decision with two consequences worth knowing.
<div data-slot="aspect-ratio" style="--ratio: 16 / 9"
class="relative aspect-(--ratio)">…</div>Nothing is interpolated into that attribute. A `Ratio` is two `f64`s and the string is built by formatting them, so `"16/9"` from a call site is parsed into a pair of numbers and printed back out — there is no path by which a caller's text reaches the stylesheet. `Ratio::try_parse` is the assertion-free half, for a value that came from somewhere less trusted than a template.
Keeping the two numbers apart is also why the attribute says `16 / 9` rather than `1.7777777777777777`. shadcn's prop is a bare `number`, because JSX can write `ratio={16 / 9}` and be done with it; a string prop cannot, and the pair is no less exact.
A caller's `style` replaced the component's, `--ratio` and all — so this box has no shape and is only as tall as this sentence.
`attrs` is a `{...props}` spread and wins, so writing a `style` of your own takes the ratio away with it. The box above proves it: the stripes arrived and the shape did not. Put the ratio in the props and everything else in `class`.
What an override costs
`class` is `className`: laid over the component's own, with a conflicting utility winning. Where that resolution happens is the thing to know, and on this component it is worth knowing before reaching for `rounded-lg`.
Every other box on this page uses `class`, and every one of them pays for it: there is no Rust tailwind-merge, so an override turns the class attribute into `{twMerge(…)}` — and `aspect-(--ratio)` is in there, so the box has no ratio at all until PulsePoint mounts. The `style` is still served; nothing reads `--ratio` until the class arrives.
It matters more here than on most components. An image reflowing from its intrinsic size into a ratio box is a visible jump, not a colour arriving late. The box above is `aspect_ratio_variants` on a native element, with the `style` and the `data-slot` written by hand — a finished class list, and a shape in the HTML.
The port
Which shadcn aspect ratio this is, and why it is the other one.
This is the one component in the library taken from shadcn's Base UI style rather than its registry, and the house rule is not being broken so much as read for its reason. The rule exists because the Base UI style usually writes its design into `cn-*` marker classes an external stylesheet defines — copying it would copy names with no rules behind them. This component has no markers at all.
registry <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
base ui <div data-slot="aspect-ratio" style={{"--ratio": ratio}}
className={cn("relative aspect-(--ratio)", className)} />The registry version is a bare wrapper around `radix-ui`'s `AspectRatio.Root`, so all of its design lives in that dependency — and what the primitive does is the padding-bottom hack: an outer `<div>` with `position: relative; width: 100%; padding-bottom: 56.25%`, and an inner one made `absolute` against all four edges, because `aspect-ratio` did not exist when Radix was written. Two elements, eight inline declarations, and a `data-radix-aspect-ratio-wrapper` the call site never asked for.
The Base UI style is the same component written in the CSS property that has existed since 2021. One element, a child that can simply be `size-full`, and no dependency — the `"use client"` does not cross either, because there is no state, no effect and no event anywhere in it.
What the port adds is the parsing. shadcn's `ratio` is a `number` and a Rahti tag's props are strings, so `ratio="16/9"` is read into a `Ratio` — which is also what keeps the fraction out of the floating-point representation and the caller's text out of the `style` attribute.
The boundary
An AspectRatio is a component, and a component is a PulsePoint scope.
Three ways across the boundary, and only one of them is the tag. The tag is right for everything static; `AspectRatioProps::render` is for a binding or a box a page drives, because interpolating it puts the element in the calling block's scope; `aspect_ratio_variants` is for an element the page has to own outright.
The builder is where a ratio is a number again: `Ratio::WIDESCREEN`, `Ratio::new(4.0, 3.0)`, or `.ratio_of(21.0, 9.0)`. The string prop exists for the tag, where every prop is a string and an unwritten `f64` would have been `0.0` — not a ratio at all, and not distinguishable from a caller who meant it.
Write the `style` alongside the variants helper. That class list reads `--ratio` and defines nothing, so without the attribute `aspect-ratio: var(--ratio)` is invalid at computed-value time and the box has no shape — which looks exactly like the component not working.
Controlled, page scope
The ratio is the one thing about this box a page is likely to change, and it lives in the `style` attribute — so driving it means binding `style`. A `style` binding replaces the whole attribute, which is why the expression writes `--ratio` itself rather than expecting the component's to survive.
--ratio: {wide ? "16 / 9" : "1"}
Built with `aspect_ratio()` and rendered into the page's own block. Through the `<AspectRatio>` tag the binding would be compiled in the component's scope, which has no `wide` — it would not error, it would simply never update. Every `render` in this library carries no `<script>`, which is what keeps this element inside the page's scope.