value: 16:9
"use client";
import { useState } from "react";Installation
$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/aspect-ratio-picker.json
Usage
import { AspectRatioPicker } from "@/components/ui/aspect-ratio-picker";
export function Frame() {
const [ratio, setRatio] = useState("16:9");
return (
<div className="space-y-2">
<div className="font-medium text-muted-foreground text-xs">
Aspect ratio
</div>
<AspectRatioPicker
aria-label="Aspect ratio"
onChange={setRatio}
value={ratio}
/>
</div>
);
}The heading is yours
The control draws no label. A field's label belongs to the form around it — above,
beside, in a <legend> — and that is layout the caller owns, not something a select
should decide for the page it lands in. So the component renders the grid and nothing
else.
That leaves one line of wiring: a heading you write is plain text, so pass aria-label
with the same words and the name reaches a screen reader too.
Pass your own list when the model offers a different set. Five fit a row before the grid
wraps, and "adaptive" / "auto" swap the glyph for a scan mark:
<AspectRatioPicker
aria-label="Canvas"
options={["1:1", "16:9", "9:16", "adaptive"]}
value={ratio}
onChange={setRatio}
/>The fixed window
The thing that makes a row of ratios readable is that nothing in it moves except the
shape. Every glyph is drawn inside a square of the same size — 18px here — and the
rectangle inside is scaled to fit that square. A 1:1 and a 16:9 therefore occupy the
same footprint; the eye compares the two rectangles directly instead of comparing two
boxes of different widths.
showReferenceBox draws the square itself as a dashed outline. It is on for the selected
cell only, which is what makes the selection read as "this shape, in this frame" rather
than just a highlighted tile.
AspectRatioIcon is exported on its own — it is useful anywhere a ratio needs a 16px shape
rather than a string of digits, a trigger button being the obvious one.
Accessibility
Each cell is a real <button> carrying aria-pressed, so the group is reachable and
operable by keyboard and the current choice is announced rather than only coloured in.
The grid is a role="group" named by aria-label, so the row is announced as one control
rather than as five unrelated buttons. The glyph is aria-hidden throughout: the visible
value is the label, and a screen reader should hear 16:9, not a description of a
rectangle.
Component source
"use client";
import { Scan } from "lucide-react";
import { cn } from "@/lib/utils";
/* -- The glyph -----------------------------------------------------------------
* A ratio is a shape, and a shape is easier to compare than a string of digits.
* Every glyph is drawn inside the same square window, so `1:1` and `16:9` line up
* on the grid and the eye reads the difference in geometry, not in label width.
* --------------------------------------------------------------------------- */
/** Ratios offered when the caller does not pass their own. */
const DEFAULT_OPTIONS = ["1:1", "16:9", "9:16", "4:3", "3:4"] as const;
/** The values that mean "let the model decide", in the spellings models use. */
const ADAPTIVE = new Set(["adaptive", "auto", "自适应"]);
/** Reads `"16:9"` into `[16, 9]`, falling back to a square for anything unparseable. */
const parseRatio = (ratio: string): [number, number] => {
const [w, h] = ratio.split(":").map(Number);
return Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0
? [w, h]
: [1, 1];
};
export interface AspectRatioIconProps {
className?: string;
/** A ratio like `"1:1"`, `"16:9"`, `"3:4"` — or `"adaptive"` / `"auto"`. */
ratio: string;
/** Side of the square window the glyph is drawn in, in px. Defaults to 16. */
size?: number;
/** Draws a dashed square around the glyph, so the chosen frame has a reference. */
showReferenceBox?: boolean;
}
/**
* The ratio drawn to scale inside a fixed square window. The window never changes
* size, so a row of ratios stays aligned instead of stepping with their shapes — the
* outer square is the layout, the inner rectangle is the information.
*/
export const AspectRatioIcon = ({
className,
ratio,
showReferenceBox = false,
size = 16,
}: AspectRatioIconProps) => {
if (ADAPTIVE.has(ratio.toLowerCase())) {
return (
<span
aria-hidden="true"
className={cn(
"inline-flex shrink-0 items-center justify-center",
className
)}
style={{ height: size, width: size }}
>
<Scan className="size-3.5 text-current opacity-80" />
</span>
);
}
const [w, h] = parseRatio(ratio);
const max = Math.max(w, h);
// Two px of slack for the 1px stroke on either side.
const bound = Math.max(8, size - 2);
const width = Math.max(4, Math.round((w / max) * bound));
const height = Math.max(4, Math.round((h / max) * bound));
return (
<span
aria-hidden="true"
className={cn(
"relative inline-flex shrink-0 items-center justify-center",
className
)}
style={{ height: size, width: size }}
>
{showReferenceBox ? (
<span
className="pointer-events-none absolute rounded-[2px] border border-current/20 border-dashed"
style={{ height: bound, width: bound }}
/>
) : null}
<span
className="shrink-0 rounded-[2px] border border-current transition-all"
style={{ height, width }}
/>
</span>
);
};
export interface AspectRatioPickerProps {
/**
* Names the grid for assistive tech, e.g. `"Aspect ratio"` or `"Canvas"`. The
* heading above the control is the caller's to draw — the component ships no label,
* because a field's label belongs to the form around it.
*/
"aria-label"?: string;
className?: string;
/** Called with the chosen ratio, e.g. a `"16:9"` string. */
onChange: (value: string) => void;
/** Choices to offer, in display order. Five fit a row before wrapping. */
options?: readonly string[];
/** The ratio currently in effect. */
value: string;
}
/**
* The aspect ratio as a grid of to-scale glyphs. The frame answers "what shape am I
* asking for" at a glance — the ratio printed under it is only there to confirm the
* number.
*/
export const AspectRatioPicker = ({
"aria-label": ariaLabel,
className,
onChange,
options = DEFAULT_OPTIONS,
value,
}: AspectRatioPickerProps) => {
// Five per row is the most that keeps the labels legible at the shipped width;
// anything beyond that wraps instead of shrinking.
const columns = Math.max(1, Math.min(options.length, 5));
return (
<div
aria-label={ariaLabel}
className={cn("grid gap-1.5", className)}
role="group"
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
>
{options.map((ratio) => {
const isSelected = value === ratio;
return (
<button
aria-pressed={isSelected}
className={cn(
"flex h-13 flex-col items-center justify-center gap-1 rounded-lg border p-1 text-xs outline-none transition-all focus-visible:ring-2 focus-visible:ring-ring/50",
isSelected
? "border-foreground/80 bg-accent font-semibold text-accent-foreground shadow-xs"
: "border-border/40 bg-muted/20 text-muted-foreground hover:border-border hover:bg-muted/50 hover:text-foreground"
)}
key={ratio}
onClick={() => onChange(ratio)}
type="button"
>
<AspectRatioIcon
ratio={ratio}
showReferenceBox={isSelected}
size={18}
/>
<span className="font-mono text-xs leading-none tracking-tight">
{ratio}
</span>
</button>
);
})}
</div>
);
};