Components
Aspect Ratio
A ratio-box wrapper that locks a child — image, video, <iframe>, embed, or chart slot — to a fixed width-to-height ratio while it resizes fluidly, killing layout shift. One CSS declaration (native aspect-ratio), zero JavaScript.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/aspect-ratio.json2. Use it
import { AspectRatio } from "@/components/ui/aspect-ratio"
// 16:9 photo, cropped to fill (default fit="cover")
<AspectRatio ratio="16/9">
<img src="/photo.jpg" alt="Mountain lake at dawn" />
</AspectRatio>
// Square, letterboxed so nothing is cropped
<AspectRatio ratio="1/1" fit="contain">
<img src="/logo.png" alt="Brand logo" />
</AspectRatio>
// Responsive video embed
<AspectRatio ratio="16/9">
<iframe src="https://www.youtube-nocookie.com/embed/ID" title="Talk" allowfullscreen />
</AspectRatio>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { Child } from "hono/jsx"
import { cloneElement, isValidElement } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Aspect Ratio — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A ratio-box wrapper that locks a child (image / video / iframe / embed /
// chart slot) to a fixed width-to-height ratio while it resizes fluidly,
// eliminating layout shift. One CSS declaration does all the work — there
// is no JavaScript here.
//
// shadcn/ui's upstream AspectRatio wraps Radix's primitive, which predates
// native browser support and emulates the ratio with a padding-bottom hack
// + absolute positioning. We do NOT copy that: the platform now ships the
// real thing, so we use the native CSS `aspect-ratio` property instead. No
// hacks (see AGENTS.md rule 4).
// Upstream (anatomy only):
// repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/aspect-ratio.tsx
//
// Built on:
// - CSS `aspect-ratio` — defines the desired width-to-height ratio of the
// box; the browser keeps it as the box resizes. At least one of the
// box's sizes must be automatic for it to take effect (we leave height
// auto, width fluid).
// repos/mdn/files/en-us/web/css/reference/properties/aspect-ratio/index.md
// - CSS `object-fit` — how a replaced element (img/video) fills the box:
// `cover` crops to fill, `contain` letterboxes to fit. Note object-fit
// has no effect on <iframe>/<embed>, which already stretch to the box.
// repos/mdn/files/en-us/web/css/reference/properties/object-fit/index.md
// - web.dev "Aspect ratio image card" pattern (`aspect-ratio: 16 / 9`,
// no padding-top hack):
// repos/web.dev/src/site/content/en/patterns/layout/aspect-ratio-image-card/index.md
//
// Tailwind v4: `aspect-video` = 16/9, `aspect-square` = 1/1; any other
// ratio is the arbitrary `aspect-[w/h]` utility. `object-cover` /
// `object-contain` map to the object-fit keywords.
export type AspectRatioFit = "cover" | "contain"
// Named ratios mapped to Tailwind's stock aspect utilities; everything else
// falls through to the arbitrary `aspect-[w/h]` form below.
const NAMED_RATIO: Record<string, string> = {
"1/1": "aspect-square",
"16/9": "aspect-video",
}
const fitClasses: Record<AspectRatioFit, string> = {
cover: "object-cover",
contain: "object-contain",
}
// Turn a ratio prop into a Tailwind class. Accepts:
// - a number → 1.78 → aspect-[1.78]
// - "16/9" → aspect-video (or aspect-[w/h] for unmapped ratios)
function ratioClass(ratio: number | string): string {
if (typeof ratio === "number") return `aspect-[${ratio}]`
const key = ratio.replace(/\s+/g, "")
return NAMED_RATIO[key] ?? `aspect-[${key}]`
}
type AspectRatioProps = {
// Width-to-height ratio. A number (e.g. 1.778) or a "w/h" string
// (e.g. "16/9", "4/3"). Defaults to a 16:9 video frame.
ratio?: number | string
// How a replaced child (img/video) fills the box. `cover` crops, `contain`
// letterboxes. Ignored for non-replaced children (iframe/embed/div).
fit?: AspectRatioFit
class?: ClassValue
id?: string
// The locked element: an <img>, <video>, <iframe>, <embed>, or any block.
// A single valid element child is cloned so the sizing + object-fit
// classes land directly on it (the wrapper only carries the ratio).
children?: Child
// Forward hx-*, data-*, aria-*, and standard attributes onto the root.
[key: string]: unknown
}
const root = "relative block w-full overflow-hidden"
export function AspectRatio(props: AspectRatioProps) {
const {
ratio = "16/9",
fit = "cover",
class: className,
id,
children,
...rest
} = props
const rootClasses = cn(root, ratioClass(ratio), className)
// Annotate the single child so it stretches to fill the ratio box and
// applies object-fit (mirrors button/tooltip cloneElement convention).
let child: Child = children
if (isValidElement(children)) {
const el = children as any
child = cloneElement(el, {
"data-slot": "aspect-ratio-content",
class: cn("size-full", fitClasses[fit], el?.props?.class),
})
}
return (
<div
id={id}
data-slot="aspect-ratio"
data-ratio={typeof ratio === "number" ? String(ratio) : ratio}
class={rootClasses}
{...rest}
>
{child}
</div>
)
}
1. Save the file
Copy aspect-ratio.html into templates/components/.
2. Use it
{% from "components/aspect-ratio.html" import aspect_ratio %}
{% call aspect_ratio(ratio="16/9") %}
<img src="/photo.jpg" alt="Mountain lake at dawn" class="size-full object-cover">
{% endcall %}View source
{# Aspect Ratio macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/aspect-ratio.tsx.
Locks the slotted child to a fixed width-to-height ratio with the native
CSS `aspect-ratio` property (no padding-top hack) and `object-fit` for
replaced elements. Zero JS.
MDN aspect-ratio: repos/mdn/files/en-us/web/css/reference/properties/aspect-ratio/index.md
MDN object-fit: repos/mdn/files/en-us/web/css/reference/properties/object-fit/index.md
web.dev pattern: repos/web.dev/src/site/content/en/patterns/layout/aspect-ratio-image-card/index.md
The slotted child (passed via {{ caller() }}) should carry
`size-full object-cover` / `object-contain` itself so the fit lands on
the replaced element — mirroring how the .tsx clones the child.
Usage:
{% from "components/aspect-ratio.html" import aspect_ratio %}
{% call aspect_ratio(ratio="16/9") %}
<img src="/photo.jpg" alt="…" class="size-full object-cover">
{% endcall %} #}
{% macro aspect_ratio(ratio="16/9", id=none, extra_class="", **attrs) %}
{%- set ratio_class -%}
{%- if ratio == "1/1" -%}aspect-square{%- elif ratio == "16/9" -%}aspect-video{%- else -%}aspect-[{{ ratio | replace(' ', '') }}]{%- endif -%}
{%- endset -%}
<div
{%- if id %} id="{{ id }}"{% endif %}
data-slot="aspect-ratio"
data-ratio="{{ ratio }}"
class="relative block w-full overflow-hidden {{ ratio_class }} {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>{{ caller() }}</div>
{% endmacro %}
1. Save the file
Add aspect-ratio.tmpl alongside your templates.
2. Use it
{{template "aspect-ratio" (dict
"Ratio" "16/9"
"Body" "<img src=\"/photo.jpg\" alt=\"Mountain lake\" class=\"size-full object-cover\">")}}View source
{{/*
Aspect Ratio template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/aspect-ratio.tsx.
Locks the slotted child to a fixed width-to-height ratio with the native
CSS `aspect-ratio` property (no padding-top hack) and `object-fit` for
replaced elements. Zero JS.
MDN aspect-ratio: repos/mdn/files/en-us/web/css/reference/properties/aspect-ratio/index.md
MDN object-fit: repos/mdn/files/en-us/web/css/reference/properties/object-fit/index.md
web.dev pattern: repos/web.dev/src/site/content/en/patterns/layout/aspect-ratio-image-card/index.md
type AspectRatioArgs struct {
Ratio string // "16/9" (default) | "1/1" | "4/3" | …
ID string
Class string
Body template.HTML // the slotted child, already carrying
// size-full object-cover / object-contain
}
*/}}
{{define "aspect-ratio"}}
{{- $ratio := or .Ratio "16/9" -}}
{{- $ratioClass := printf "aspect-[%s]" $ratio -}}
{{- if eq $ratio "1/1" -}}{{- $ratioClass = "aspect-square" -}}{{- else if eq $ratio "16/9" -}}{{- $ratioClass = "aspect-video" -}}{{- end -}}
<div {{if .ID}}id="{{.ID}}" {{end}}data-slot="aspect-ratio" data-ratio="{{$ratio}}" class="relative block w-full overflow-hidden {{$ratioClass}} {{.Class}}">{{htmlSafe .Body}}</div>
{{end}}
1. Save the file
Drop aspect_ratio.ex into lib/my_app_web/components/.
2. Use it
<.aspect_ratio ratio="16/9">
<img src="/photo.jpg" alt="Mountain lake at dawn" class="size-full object-cover" />
</.aspect_ratio>View source
defmodule ShadcnHtmx.Components.AspectRatio do
@moduledoc """
Aspect Ratio — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/aspect-ratio.tsx.
Locks the slotted child (image / video / iframe / embed / chart) to a
fixed width-to-height ratio with the native CSS `aspect-ratio` property
(no padding-top hack) and `object-fit` for replaced elements. Zero JS.
* MDN aspect-ratio:
repos/mdn/files/en-us/web/css/reference/properties/aspect-ratio/index.md
* MDN object-fit:
repos/mdn/files/en-us/web/css/reference/properties/object-fit/index.md
* web.dev pattern:
repos/web.dev/src/site/content/en/patterns/layout/aspect-ratio-image-card/index.md
The slotted child should carry `size-full object-cover` / `object-contain`
so the fit lands on the replaced element — mirroring how the .tsx clones
the child.
## Examples
<.aspect_ratio ratio="16/9">
<img src="/photo.jpg" alt="…" class="size-full object-cover" />
</.aspect_ratio>
<.aspect_ratio ratio="1/1">
<img src="/avatar.jpg" alt="…" class="size-full object-cover" />
</.aspect_ratio>
"""
use Phoenix.Component
@root "relative block w-full overflow-hidden"
attr :ratio, :string, default: "16/9"
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def aspect_ratio(assigns) do
assigns =
assigns
|> assign(:root, @root)
|> assign(:ratio_class, ratio_class(assigns.ratio))
~H"""
<div
data-slot="aspect-ratio"
data-ratio={@ratio}
class={[@root, @ratio_class, @class]}
{@rest}
>
{render_slot(@inner_block)}
</div>
"""
end
defp ratio_class("1/1"), do: "aspect-square"
defp ratio_class("16/9"), do: "aspect-video"
defp ratio_class(ratio), do: "aspect-[#{String.replace(ratio, " ", "")}]"
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<div data-slot="aspect-ratio" data-ratio="16/9"
class="relative block w-full overflow-hidden aspect-video">
<img src="/photo.jpg" alt="Mountain lake at dawn"
data-slot="aspect-ratio-content" class="size-full object-cover">
</div>View source
<!--
shadcn-htmx — raw HTML aspect-ratio snippets.
Locks the slotted child to a fixed width-to-height ratio with the native
CSS `aspect-ratio` property (no padding-top hack). Replaced elements
(img/video) get `object-fit` via object-cover / object-contain; iframe /
embed already stretch to the box (object-fit has no effect on them).
Zero JavaScript — Tailwind utilities only.
MDN aspect-ratio: repos/mdn/files/en-us/web/css/reference/properties/aspect-ratio/index.md
MDN object-fit: repos/mdn/files/en-us/web/css/reference/properties/object-fit/index.md
web.dev pattern: repos/web.dev/src/site/content/en/patterns/layout/aspect-ratio-image-card/index.md
ROOT:
relative block w-full overflow-hidden + the ratio utility
RATIO utility:
1/1 → aspect-square
16/9 → aspect-video
any → aspect-[w/h] e.g. aspect-[4/3]
-->
<!-- 16:9 image, cropped to fill (object-cover) -->
<div data-slot="aspect-ratio" data-ratio="16/9"
class="relative block w-full overflow-hidden aspect-video">
<img src="/photo.jpg" alt="Mountain lake at dawn"
data-slot="aspect-ratio-content"
class="size-full object-cover">
</div>
<!-- 1:1 square, letterboxed to fit (object-contain) -->
<div data-slot="aspect-ratio" data-ratio="1/1"
class="relative block w-full overflow-hidden aspect-square">
<img src="/logo.png" alt="Brand logo"
data-slot="aspect-ratio-content"
class="size-full object-contain">
</div>
<!-- 16:9 responsive iframe (video embed) — object-fit has no effect here;
the iframe simply stretches to the ratio box. -->
<div data-slot="aspect-ratio" data-ratio="16/9"
class="relative block w-full overflow-hidden aspect-video">
<iframe src="https://www.youtube-nocookie.com/embed/VIDEO_ID"
title="Embedded video"
data-slot="aspect-ratio-content"
class="size-full" allowfullscreen></iframe>
</div>
<!-- Arbitrary 4:3 ratio with a plain content slot (e.g. a chart canvas) -->
<div data-slot="aspect-ratio" data-ratio="4/3"
class="relative block w-full overflow-hidden aspect-[4/3]">
<div data-slot="aspect-ratio-content"
class="flex size-full items-center justify-center bg-muted text-muted-foreground">
4 / 3 slot
</div>
</div>
Examples
16:9 — fluid image with no layout shift
The wrapper carries the ratio; the <img> stretches to fill it and is cropped with object-cover.
The wrapper reserves the right amount of space before the image loads, so there is no reflow when it arrives — the classic cause of Cumulative Layout Shift. The ratio is the native CSS aspect-ratio property (Tailwind's aspect-video = 16/9); no padding-top hack, no JavaScript.
<AspectRatio ratio="16/9" class="rounded-lg border">
<img src="/photo.jpg" alt="Mountain lake at dawn" />
</AspectRatio>{% call aspect_ratio(ratio="16/9", extra_class="rounded-lg border") %}
<img src="/photo.jpg" alt="Mountain lake at dawn" class="size-full object-cover">
{% endcall %}{{template "aspect-ratio" (dict "Ratio" "16/9" "Class" "rounded-lg border"
"Body" "<img src=\"/photo.jpg\" alt=\"Mountain lake\" class=\"size-full object-cover\">")}}<.aspect_ratio ratio="16/9" class="rounded-lg border">
<img src="/photo.jpg" alt="Mountain lake at dawn" class="size-full object-cover" />
</.aspect_ratio><div class="w-full max-w-md">
<div data-slot="aspect-ratio" data-ratio="16/9" class="relative block w-full overflow-hidden aspect-video rounded-lg border">
<img src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'640'%20height%3D'360'%3E%3Cdefs%3E%3ClinearGradient%20id%3D'g'%20x1%3D'0'%20y1%3D'0'%20x2%3D'1'%20y2%3D'1'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%252306b6d4'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%25237c3aed'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D'640'%20height%3D'360'%20fill%3D'url(%2523g)'%2F%3E%3Ctext%20x%3D'50%25'%20y%3D'50%25'%20fill%3D'white'%20font-family%3D'sans-serif'%20font-size%3D'28'%20text-anchor%3D'middle'%20dominant-baseline%3D'middle'%3E640%20%C3%97%20360%3C%2Ftext%3E%3C%2Fsvg%3E" alt="Gradient placeholder, 640 by 360" data-slot="aspect-ratio-content" class="size-full object-cover">
</img>
</div>
</div>Further reading
Cover vs contain — how the child fills the box
fit="cover" (default) crops to fill; fit="contain" letterboxes so nothing is cut off.
Both boxes are the same 1:1 square. The left uses object-cover — the image is scaled up and cropped to fill. The right uses object-contain — the whole image is shown, with empty space around it. Use cover for photos, contain for logos or diagrams you must not crop. (object-fit has no effect on <iframe> or <embed>.)
<AspectRatio ratio="1/1" fit="cover">
<img src="/photo.jpg" alt="…" />
</AspectRatio>
<AspectRatio ratio="1/1" fit="contain">
<img src="/logo.png" alt="…" />
</AspectRatio>{% call aspect_ratio(ratio="1/1") %}
<img src="/photo.jpg" alt="…" class="size-full object-cover">
{% endcall %}
{% call aspect_ratio(ratio="1/1") %}
<img src="/logo.png" alt="…" class="size-full object-contain">
{% endcall %}{{template "aspect-ratio" (dict "Ratio" "1/1"
"Body" "<img src=\"/photo.jpg\" class=\"size-full object-cover\">")}}
{{template "aspect-ratio" (dict "Ratio" "1/1"
"Body" "<img src=\"/logo.png\" class=\"size-full object-contain\">")}}<.aspect_ratio ratio="1/1">
<img src="/photo.jpg" alt="…" class="size-full object-cover" />
</.aspect_ratio>
<.aspect_ratio ratio="1/1">
<img src="/logo.png" alt="…" class="size-full object-contain" />
</.aspect_ratio><div class="grid w-full max-w-md grid-cols-2 gap-4">
<div data-slot="aspect-ratio" data-ratio="1/1" class="relative block w-full overflow-hidden aspect-square rounded-lg border bg-muted">
<img src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'640'%20height%3D'360'%3E%3Cdefs%3E%3ClinearGradient%20id%3D'g'%20x1%3D'0'%20y1%3D'0'%20x2%3D'1'%20y2%3D'1'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%252306b6d4'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%25237c3aed'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D'640'%20height%3D'360'%20fill%3D'url(%2523g)'%2F%3E%3Ctext%20x%3D'50%25'%20y%3D'50%25'%20fill%3D'white'%20font-family%3D'sans-serif'%20font-size%3D'28'%20text-anchor%3D'middle'%20dominant-baseline%3D'middle'%3E640%20%C3%97%20360%3C%2Ftext%3E%3C%2Fsvg%3E" alt="Cover fit, cropped to fill" data-slot="aspect-ratio-content" class="size-full object-cover">
</img>
</div>
<div data-slot="aspect-ratio" data-ratio="1/1" class="relative block w-full overflow-hidden aspect-square rounded-lg border bg-muted">
<img src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'640'%20height%3D'360'%3E%3Cdefs%3E%3ClinearGradient%20id%3D'g'%20x1%3D'0'%20y1%3D'0'%20x2%3D'1'%20y2%3D'1'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%252306b6d4'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%25237c3aed'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D'640'%20height%3D'360'%20fill%3D'url(%2523g)'%2F%3E%3Ctext%20x%3D'50%25'%20y%3D'50%25'%20fill%3D'white'%20font-family%3D'sans-serif'%20font-size%3D'28'%20text-anchor%3D'middle'%20dominant-baseline%3D'middle'%3E640%20%C3%97%20360%3C%2Ftext%3E%3C%2Fsvg%3E" alt="Contain fit, letterboxed" data-slot="aspect-ratio-content" class="size-full object-contain">
</img>
</div>
</div>Further reading
Responsive video embed
Wrap an <iframe> so a video embed stays 16:9 at every width — the most common reason to reach for this component.
Drop an <iframe> inside and it stretches to the ratio box at every viewport width. Always give the iframe a title so its content has an accessible name. The placeholder below stands in for the embed.
<AspectRatio ratio="16/9" class="rounded-lg border">
<iframe
src="https://www.youtube-nocookie.com/embed/ID"
title="Conference talk"
allowfullscreen
/>
</AspectRatio>{% call aspect_ratio(ratio="16/9", extra_class="rounded-lg border") %}
<iframe src="https://www.youtube-nocookie.com/embed/ID"
title="Conference talk" class="size-full" allowfullscreen></iframe>
{% endcall %}{{template "aspect-ratio" (dict "Ratio" "16/9" "Class" "rounded-lg border"
"Body" "<iframe src=\"…/embed/ID\" title=\"Conference talk\" class=\"size-full\" allowfullscreen></iframe>")}}<.aspect_ratio ratio="16/9" class="rounded-lg border">
<iframe src="https://www.youtube-nocookie.com/embed/ID"
title="Conference talk" class="size-full" allowfullscreen></iframe>
</.aspect_ratio><div class="w-full max-w-md">
<div data-slot="aspect-ratio" data-ratio="16/9" class="relative block w-full overflow-hidden aspect-video rounded-lg border">
<div class="size-full object-cover flex size-full items-center justify-center bg-muted text-sm text-muted-foreground" data-slot="aspect-ratio-content">iframe embed (16 : 9)</div>
</div>
</div>Further reading
API Reference
<AspectRatio>
| Prop | Type | Default | Description |
|---|---|---|---|
ratio | number|string | "16/9" | Width-to-height ratio. A number (1.778) or a "w/h" string ("16/9", "4/3", "1/1"). Maps to Tailwind's aspect-* utility; "1/1" -> aspect-square, "16/9" -> aspect-video, anything else -> aspect-[w/h].MDNaspect-ratio |
fit | "cover"|"contain" | "cover" | How a replaced child (img/video) fills the box. cover scales up and crops; contain letterboxes so nothing is cut off. Has no effect on iframe/embed children.MDNobject-fit |
children* | Child | — | The locked element: an <img>, <video>, <iframe>, <embed>, or any block. A single valid element child is cloned so size-full + object-fit classes land directly on it. |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required