DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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/ui

Usage

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. Card has a CardMedia part 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's overlay prop. 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.
  • alt stays on the media child, never on the frame. The frame cannot know whether the image is decorative, and moving alt onto 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

PropTypeDefaultDescription
ratioAspectRatioPreset | 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-*
clipbooleantrueWhether the frame clips its content. false lets an overlay — or a focus ring — cross the edge
renderReactElement—Polymorphic target; the frame becomes this element
classNamestring—Merged last, so it wins
styleCSSProperties—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

PresetRatioTypical use
"square"1 / 1Album art, avatars, product tiles
"video" (default)16 / 9Video thumbnails and players, hero banners
"portrait"3 / 4Editorial photography
"landscape"4 / 3Classic photography, map cards
"poster"2 / 3Streaming posters, book and film covers
"ultrawide"21 / 9Panoramas, cinematic headers. Rare
"vertical"9 / 16Stories, reels, vertical video
"golden"1.618 / 1Editorial 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 the object-* class. Every later child is passed through untouched so you can position it against the frame's relative box — 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 own place-items-center can never reach it. Wrap it: <AspectRatio><div className="grid size-full place-items-center">…</div></AspectRatio>.
  • A child's own className wins, so a single tile can override the frame's objectFit without fighting it.
  • clip also applies at rounded="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.
  • style beats the ratio. style={{ aspectRatio: "1 / 1" }} overrides ratio — a deliberate escape hatch, not an accident.
  • On the render branch the host's own props win, including className and style. This matches Spacer.
  • 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. Use lg, or full for the 24px cap.
Area ChartAvatar

On this page

PlaygroundInstallationUsageExamplesDefaultPresetsCustom ratiosResponsive ratiosObject fitRoundedOverlaysEmbedsLoadingEmpty slotPolymorphic renderWhen to useAccessibilityAPI ReferenceAspectRatio propsNamed ratiosNotes