Aspect Ratio
Holds media to a fixed width-to-height ratio, reserving its space before the content loads.
import { AspectRatio } from "@/components/ui/aspect-ratio"
export default function AspectRatioDemo() {
return (
<figure className="w-full max-w-sm">
<AspectRatio
ratio={16 / 9}
className="flex items-center justify-center rounded-xl bg-muted"
>
<span className="font-mono text-sm text-muted-foreground numeric">
16 : 9
</span>
</AspectRatio>
<figcaption className="mt-2 text-sm text-muted-foreground">
Meridian
</figcaption>
</figure>
)
}Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/aspect-ratio.jsonUsage
import { AspectRatio } from "@/components/ui/aspect-ratio"<AspectRatio ratio={16 / 9} className="overflow-hidden rounded-xl">
<img src="/cover.jpg" alt="…" className="size-full object-cover" />
</AspectRatio>ratio is width divided by height. The component is a positioned <div> sized with the CSS aspect-ratio property — no JavaScript, no padding hacks — so it works in server components and never shifts after hydration.
Examples
Common ratios
Choose the ratio from the content, then keep it consistent across a collection so grids line up.
import { AspectRatio } from "@/components/ui/aspect-ratio"
const ratios = [
{ label: "1:1", value: 1, use: "Avatars, products" },
{ label: "4:3", value: 4 / 3, use: "Photos, thumbnails" },
{ label: "3:2", value: 3 / 2, use: "Editorial images" },
{ label: "16:9", value: 16 / 9, use: "Video, hero media" },
{ label: "21:9", value: 21 / 9, use: "Cinematic banners" },
]
export default function AspectRatioRatios() {
return (
<div className="grid w-full max-w-2xl grid-cols-2 items-end gap-4 sm:grid-cols-5">
{ratios.map((ratio) => (
<div key={ratio.label} className="flex flex-col gap-2">
<AspectRatio
ratio={ratio.value}
className="flex items-center justify-center rounded-lg border border-dashed border-brand-7 bg-brand-2"
>
<span className="font-mono text-xs font-medium text-brand-11">
{ratio.label}
</span>
</AspectRatio>
<span className="text-xs text-muted-foreground">{ratio.use}</span>
</div>
))}
</div>
)
}With Next.js Image
Use fill so the image takes the box's size, and object-cover to crop instead of stretching. Pass sizes so the browser downloads the right resolution.
import Image from "next/image"
<AspectRatio ratio={16 / 9} className="overflow-hidden rounded-xl bg-muted">
<Image
src="/showcase/dashboard.png"
alt="The analytics dashboard in dark mode"
fill
sizes="(min-width: 768px) 50vw, 100vw"
className="object-cover"
/>
</AspectRatio>Video
Video and embeds should reserve their frame before they load. Overlay controls and metadata with absolute positioning — the box is already relative.
import { PlayIcon } from "lucide-react"
import { AspectRatio } from "@/components/ui/aspect-ratio"
import { Button } from "@/components/ui/button"
export default function AspectRatioVideo() {
return (
<div className="flex w-full max-w-lg flex-col gap-3">
<AspectRatio
ratio={16 / 9}
// Always dark, like a video poster. The fill and the edge keep the
// frame visible on a dark page too.
className="dark overflow-hidden rounded-xl border bg-muted"
>
<div className="absolute inset-0 flex items-center justify-center">
<Button
size="icon-lg"
variant="default"
aria-label="Play video: Designing with tokens"
className="size-14 rounded-full"
>
<PlayIcon className="size-5! translate-x-px fill-current" />
</Button>
</div>
<span className="absolute right-3 bottom-3 rounded-md bg-background px-1.5 py-0.5 font-mono text-[0.6875rem] text-foreground numeric">
12:48
</span>
</AspectRatio>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-foreground">
Designing with tokens
</span>
<span className="text-body-xs text-muted-foreground">
prfct Talks · Episode 4
</span>
</div>
</div>
)
}<AspectRatio ratio={16 / 9} className="overflow-hidden rounded-xl">
<iframe
src="https://www.youtube-nocookie.com/embed/…"
title="Designing with tokens"
allow="accelerometer; encrypted-media; picture-in-picture"
allowFullScreen
className="absolute inset-0 size-full"
/>
</AspectRatio>Square grid
A 1:1 ratio keeps avatars, product shots and thumbnails uniform even when the source images aren't.
- Ana
- Jonas
- Maya
- Theo
- Iris
- Lena
- Omar
- Eli
import { AspectRatio } from "@/components/ui/aspect-ratio"
const people = [
{ initials: "AK", name: "Ana" },
{ initials: "JB", name: "Jonas" },
{ initials: "MC", name: "Maya" },
{ initials: "TP", name: "Theo" },
{ initials: "IV", name: "Iris" },
{ initials: "LR", name: "Lena" },
{ initials: "OS", name: "Omar" },
{ initials: "EW", name: "Eli" },
]
export default function AspectRatioSquareGrid() {
return (
<ul className="grid w-full max-w-md grid-cols-4 gap-3">
{people.map((person) => (
<li key={person.initials} className="flex flex-col gap-1.5">
<AspectRatio
ratio={1}
className="flex items-center justify-center rounded-xl bg-muted text-lg font-medium text-muted-foreground"
>
<span aria-hidden="true">{person.initials}</span>
</AspectRatio>
<span className="text-center text-xs text-muted-foreground">
{person.name}
</span>
</li>
))}
</ul>
)
}Card media
Put the ratio box first in a card with pt-0 on the card, and the media runs edge to edge with the card's rounded corners.
import { LampDeskIcon } from "lucide-react"
import { AspectRatio } from "@/components/ui/aspect-ratio"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export default function AspectRatioCardMedia() {
return (
<Card className="w-full max-w-xs pt-0">
<AspectRatio
ratio={4 / 3}
className="flex items-center justify-center border-b bg-muted"
>
<LampDeskIcon
aria-hidden="true"
className="size-10 text-muted-foreground"
strokeWidth={1.25}
/>
<Badge variant="outline" className="absolute top-3 left-3 bg-card">
New
</Badge>
</AspectRatio>
<CardHeader>
<CardTitle>Amber desk lamp</CardTitle>
<CardDescription>
Hand-finished brass, warm dimmable light.
</CardDescription>
</CardHeader>
<CardContent>
<span className="text-heading-sm text-foreground numeric">$189</span>
</CardContent>
<CardFooter>
<Button className="w-full">Add to cart</Button>
</CardFooter>
</Card>
)
}Guidelines
When to use
- For images, video, maps and embeds whose container width changes with the layout.
- In grids and carousels, to keep every tile the same shape.
- Wherever late-loading media would otherwise push content down.
When not to use
- For text or UI whose height depends on its content — fixing the ratio will clip or leave gaps.
- When the container already has fixed pixel dimensions; set them directly.
Crop, don't distort
Media inside a ratio box should use object-cover (crop to fill) or object-contain (letterbox), never the default stretch.
Accessibility
Aspect Ratio is purely presentational; what matters is the media inside it.
- Images need
alttext that says what the image shows, oralt=""when it's decorative. - Video needs captions, and embeds need a
titlethat names the content. - Layout stability. Reserving space prevents content from jumping as media loads, which helps everyone — and especially people using magnification or reading slowly.
API reference
AspectRatio
Renders a <div> with position: relative and aspect-ratio set from ratio. Accepts every <div> prop.