Dimension Input
A paired width × height field with a real aspect-ratio lock — the ratio is frozen when you lock it, and min/max rolls the edited side back instead of letting its partner escape.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/dimension-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DimensionInput" component: a width × height
pair with an aspect-ratio lock (lucide-react for the lock / swap icons; no other
runtime deps).
Contract
- Export DimensionValue = { width: number; height: number } and
DimensionPreset = { label, width, height } | { label, ratio }, plus a
forwardRef component whose ref lands on the outer div; props extend
HTMLAttributes<HTMLDivElement> minus defaultValue/onChange.
- Value is controlled (value + onValueChange) or uncontrolled (defaultValue,
default 1920 × 1080). The lock is independently controlled (locked +
onLockedChange) or uncontrolled (defaultLocked). Both halves of the pair are
emitted in one onValueChange call, never as two updates.
- Options: lockable (default true), unit (display-only suffix), min (default 0),
max (default Infinity), step (default 1), precision (default 0 decimals),
presets, swappable (default true), labels {width, height}, description,
disabled, className.
Behavior
- Freeze the ratio at the moment the lock goes on, keeping the *pair* it was
frozen from, and never re-derive it afterwards. Re-deriving width/height after
every edit feeds each rounding error into the next one: with precision 0 a
16:9 lock walks to 1.60 within twenty edits, and typing 1920 back gives 1200
instead of 1080. Frozen, the same twenty edits return to 1080 exactly and
100 → 33 → 100 comes back to the height it started from.
- One solver decides every pair. With ratio r the partner is `edited * k`
(k = 1/r when editing width, r when editing height), so the partner's bounds
fold back into a feasible interval for the edited side: [lo/k, hi/k] ∩
[lo, hi], snapped inward onto the precision grid. Clamp the request into that
interval, then compute the partner. This is what makes an out-of-range partner
roll *the edited side* back — clamping the two sides separately would let the
partner escape the bounds, which is the classic bug here. Example: min 64 /
max 1024 with a 4:1 lock — typing 100 into width holds it at 256, because
height would otherwise fall under 64.
- If no pair can satisfy the ratio and the bounds at once (say 4:1 inside
100–150), degrade to two independent clamps rather than freezing the field.
- Rounding: the linked value is rounded to `precision`, but the ratio itself
stays unrounded, so round trips restore the original numbers.
- Zero sides: locking while width or height is 0 has no ratio to hold. Say so
instead of dividing by zero, keep both fields free, and arm the lock the first
time a complete pair exists. Once frozen, going back through 0 and out again
restores the same partner value.
- Typing: a controlled <input type="number"> eats keystrokes (mid-entry "-" and
"1." read back as ""), so use type="text" + inputMode="decimal", keep a raw
draft while the field has focus, and parse on commit (blur / Enter). Empty
rolls back — an empty field is a slip, not an intent to erase a dimension.
Unparseable text is rejected with a message and aria-invalid on that field.
Escape drops the draft.
- ArrowUp/ArrowDown step by `step` (Shift ×10) and commit immediately, stepping
off whatever is on screen. Swap exchanges the two sides *and inverts the
frozen ratio* — without that, the next keystroke would undo the swap.
- Preset chips: a size chip sets both sides at once (and re-freezes the lock
while locked); a ratio chip keeps the width, re-derives the height and
re-freezes the ratio. A chip shows aria-pressed when the current pair matches.
- Defensive props: step <= 0 or NaN falls back to 1, precision is trunc-clamped
to 0..6, non-finite min/max fall back to 0/Infinity, min/max are snapped
inward onto the precision grid so every emitted number is on the grid, and a
non-finite incoming value reads as 0.
Rendering & styling
- One row: width field, lock button, height field, swap button — flex-wrap with
a min-w on each field, so a container too narrow for both stacks them instead
of clipping a digit off the value. Optional preset chips below, then one
status line.
- Fields are h-9 rounded-md border-input shells with focus-within:border-ring +
ring-ring/50 (border-destructive + ring-destructive/30 when invalid); the
number is right-aligned tabular-nums, the unit is a bordered suffix span.
- Each side has a real <label htmlFor> and role="spinbutton" with
aria-valuenow / aria-valuemin / aria-valuemax and an aria-valuetext like
"1920 px".
- The lock is a real button with aria-pressed and a descriptive aria-label
("Aspect ratio locked at 16:9"); pressed state is bg-primary /
text-primary-foreground.
- One aria-live="polite" role="status" line carries, in priority order: the
clamp/link/parse message, the lock readout ("Locked at 16:9 — editing one side
updates the other."), then `description`. Its text changes only on a commit, a
step, a preset, a swap or a lock toggle — never on a keystroke — so it
announces the linked side once instead of on every key. It stays mounted
(sr-only when empty) and is wired with aria-describedby.
- Semantic tokens only: bg-primary / text-primary-foreground for pressed
states, text-muted-foreground for labels, units and hints, text-destructive
for parse errors, border-input / ring-ring for the frame. Colour transitions
only, disabled under prefers-reduced-motion. Dark mode comes free.
Customization levers
- Ratio naming: ratioLabel() searches denominators 1..64 for an exact-enough
rational ("16:9") and falls back to a decimal ("1:1.41" for A4). Widen the
search or hand it a table of named ratios if your domain has its own.
- Preset sets: swap the chips for the surface — 1080p / 4K / OG image for
export, A4 / Letter / A5 for print, 1:1 / 4:5 / 9:16 for social. Ratio chips
and size chips can be mixed in one array.
- Units: `unit` is display-only. For real unit conversion (px ↔ mm ↔ in), keep
the conversion in the parent and pass a converted min/max/step/precision down;
pair it with unit-input if the unit itself must be selectable.
- Density and layout: h-9 / text-sm / gap-2 are the sizing knobs; drop to h-8 /
text-xs for an inspector sidebar, raise the field min-w if your numbers are
longer than six glyphs. Set swappable={false} or lockable={false} to strip the
buttons; lockable={false} renders a plain "W × H" pair.
- Bounds policy: min/max are shared by both sides. For per-side bounds, replace
the two constants in the solver with a per-side pair and intersect the
feasible interval the same way.
- Labels: `labels` renames the two sides (Columns / Rows, Rows / Seats) — the
messages and aria-labels follow automatically.Concepts
- Freeze the ratio at lock time — the ratio is captured once, when the lock goes on, and kept as the pair it came from. Recomputing it from the on-screen numbers after every edit feeds each rounding error into the next: with integer pixels a 16:9 lock walks to 1.60 within twenty edits, and
100 → 33 → 100comes back as 55 instead of 56. Frozen, both round trips are exact. - The pair is clamped, not the side — with the lock on,
min/maxapply to both numbers, so the constraint on the partner folds back into a feasible interval for the side you are typing into. Overshooting holds the edited side (Width held at 256 px — height would fall under the 64 px minimum) instead of letting the partner slip out of range. - Grid on the outside, exact on the inside — every emitted number is rounded to
precisionand the bounds themselves are snapped inward onto that grid, while the ratio stays unrounded. That is what keeps display tidy without letting the display define the geometry. - A zero side degrades, it does not divide — locking with a 0 has no ratio to hold, so the field says so and stays free, then arms itself the first time both sides are non-zero. Passing back through 0 later does not disturb the already-frozen ratio.
- Swap inverts the lock — exchanging width and height also flips the frozen pair, otherwise the very next keystroke would pull the pair back to the old orientation.
- Parse on commit, never on keystroke — a controlled
numberinput silently eats-and1., so the field istext+inputMode="decimal"with a raw draft; the value, the clamp message and the single live region all update on blur / Enter / arrow, which is also why the linked side is announced once instead of on every key.
Language Picker
An interface-language field that lists every option under its own name — 简体中文 next to Chinese (Simplified) — with endonym, English name and BCP 47 tag all searchable, RTL rows rendered in their own direction, optional translation completeness and a browser-language matching ladder.
Gradient Picker
A CSS gradient editor — draggable stop rail, angle dial, per-stop colour editing, and a live read-only linear/radial/conic CSS string.