For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0
Sponsor

Aspect Ratio Picker

Aspect ratio as a grid of to-scale glyphs: every shape is drawn inside the same square window, so the grid stays aligned and the ratio reads before the label does.

Aspect ratio

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.

Canvas

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

aspect-ratio-picker.tsx
"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>
  );
};