Inputs
Unit Input
A number-plus-unit field for design tokens — 12px, 1.5rem, 50% — with arrow/wheel stepping, optional conversion on unit switch, and an auto state.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/unit-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "UnitInput" component (lucide-react for
the select chevron; no other runtime deps).
Contract
- Export UnitValue = { value: number | null; unit: string } and a forwardRef
component whose ref lands on the outer wrapper div; props extend
HTMLAttributes<HTMLDivElement> minus defaultValue/onChange.
- Controlled: value (UnitValue) + onValueChange(next: UnitValue). The number and
the unit travel as one atom, so a conversion is a single update rather than
two that a parent could interleave.
- Options: units (string[], default ["px","rem","%","em","vh"]), step
(default 1), min / max (default ±Infinity), precision (default 2 decimals),
convert?(value, from, to) => number, unitControl ("select" | "radio",
default "select"), allowAuto, label, placeholder, description, invalid,
disabled, wheel (default true), className.
- value.value === null means "no number". With allowAuto it reads as `auto`;
without it, the field simply has nothing in it yet.
Behavior
- Draft vs value. A raw text draft exists only while the number has focus; the
rest of the time the display is derived from the controlled value, so the
parent stays the source of truth. Typing only sanitizes (unicode letters and
digits, ".", "%", "°", sign, spaces) — nothing is reformatted mid-word.
- Parse on commit (blur / Enter). One entry may carry both halves: "12" sets
the number, "12px" / "1.5 REM" / "50%" set number *and* unit, "" and "auto"
mean null. The unit suffix is any glyph run without digits/dots/signs (so
"°c" and "µs" parse too) and is matched case-insensitively against `units`; a
suffix that is not on the list is rejected with a message and the field rolls
back — guessing would silently store the wrong unit.
- Commit pipeline: round to `precision`, clamp into [min, max], and when the
clamp actually bit, show why ("Maximum is 100%"). Clamping only at commit is
what lets someone type 5 on the way to 50 when the floor is 10.
- Stepping: ArrowUp/ArrowDown by `step`, Shift ×10, Alt ×0.1, PageUp/PageDown
×10. Stepping reads the on-screen text first, so a half-typed "12px" steps to
13px; if that will not parse it falls back to the committed number, and from
`auto` it starts at 0. A step lands on the precision grid, so Alt-stepping
needs precision >= 1 decimal to move at all.
- Wheel steps too, but only while the number has focus, and the listener is
registered natively with { passive: false } so it can preventDefault — React
attaches wheel handlers passively, so an onWheel prop would change the value
*and* scroll the page. It is armed/disarmed by focus, and removed on unmount.
- Switching unit: with `convert`, run convert(value, from, to) and re-clamp; a
non-finite result is ignored rather than allowed to destroy the value.
Without `convert`, the number is kept as-is and only the label changes.
- Defensive props: step <= 0 or NaN falls back to 1 (a zero step makes arrows
and wheel dead), precision is trunc-clamped to 0..6, a crossed min/max pair
resolves in favour of min, and a `value.unit` that is not in `units` is
appended as an extra option so the picker never shows a different unit from
the one the value is in.
- No animation beyond colour transitions, and those are disabled under
prefers-reduced-motion.
Rendering & styling
- Wrapper: inline-flex flex-col gap-1.5 with a definite default width (w-40) so
the field never collapses; className overrides the width and the number grows
into the slack while the unit picker keeps its natural size.
- Field shell: h-9 rounded-md border border-input bg-transparent shadow-xs,
focus-within:border-ring + focus-within:ring-ring/50; invalid swaps to
border-destructive + ring-destructive/30; disabled dims with opacity-50.
- Number: role="spinbutton" on a text input (inputMode="decimal"), text-right +
tabular-nums, with aria-valuenow / aria-valuemin / aria-valuemax and an
aria-valuetext of "12px" — or "auto" when the value is null.
- Unit picker is a real control, never a div: unitControl="select" renders a
native <select> (appearance-none + a lucide ChevronDown, so the OS dropdown
and its type-ahead are kept), unitControl="radio" renders
role="radiogroup" > button role="radio" chips with roving tabindex and
arrow/Home/End movement that selects as it moves.
- One aria-live="polite" status paragraph under the field carries either the
clamp/parse message or the static description; it stays mounted (sr-only when
empty) so announcements work, and is linked with aria-describedby. A clamp is
a correction, so it stays text-muted-foreground; a parse error or `invalid`
turns it text-destructive.
- Semantic tokens only — bg-primary / text-primary-foreground for the active
chip, text-muted-foreground for the unit and hints, text-destructive for
errors, border-input / ring-ring for the frame. Dark mode comes free.
Customization levers
- Unit set: `units` is just strings — swap the CSS length set for kg/lb/oz,
s/ms, MB/GB, or °C/°F. The parser matches them case-insensitively.
- Conversion policy: omit `convert` for "the number means something different
per unit" (font-size 12px vs 12%), pass it for true unit conversion. It is a
plain function, so a per-project table (rem base, DPI, currency-free physical
units) lives in your app, not in the component.
- Picker shape: `unitControl="radio"` for 2–3 units you want visible at all
times; `select` once the list is long. To use a shadcn Select instead, swap
the <select> block and keep changeUnit() as the single entry point.
- Per-unit step / precision: derive them in the parent from `value.unit`
(px → step 1 precision 0, rem → step 0.25 precision 2) and pass them down;
the component treats both as plain props.
- Density and width: h-9 / px-2.5 / text-sm are the sizing knobs and w-40 is the
default field width; drop to h-8 / text-xs / w-28 for an inspector sidebar,
and put several side by side for an X/Y or T/R/B/L group.
- Auto/empty policy: allowAuto turns an empty field into `auto` (null);
leaving it off makes clearing a no-op that rolls back to the last number.
- Validation: keep `invalid` + `description` driven by your form library; the
component clamps and reports, it never blocks typing.Concepts
- Value and unit are one atom —
{ value, unit }is emitted together, so a conversion (16px→1rem) is a single controlled update. Two separateonChangecalls would let a parent render an impossible intermediate like16rem. - Parse on commit, sanitize while typing — keystrokes are only filtered; the split into number + unit happens on blur or Enter. That is why pasting
1.5remworks and why1.5remid-word is not yet an error. - Unknown unit rejects, it never guesses —
12foorolls the field back and names the problem, because silently keeping the old unit would store a value that does not mean what the user typed. - Modifier-scaled stepping — one
stepprop covers three speeds: Shift multiplies by 10, Alt divides by 10, PageUp/PageDown behave like Shift. Steps land on theprecisiongrid, so a 0.1× step below the precision floor is a deliberate no-op rather than an invisible float drift. - Focus-gated, non-passive wheel — the wheel listener only exists while the number has focus and is registered with
{ passive: false }; React's own wheel handling is passive, so a plainonWheelwould bump the value and scroll the page out from under the caret. autois a value, not an empty string — withallowAuto, clearing the field emitsvalue: null(an explicit "no length"), which is how CSSauto, "inherit from parent" and "unset" survive all the way to your serializer.- Every numeric prop is defended —
step <= 0falls back to 1,precisionis clamped to 0–6, and a crossedmin/maxresolves tomin. A bad prop degrades the control; it must never freeze the field or spin forever.
Slug Input
A URL field that transliterates the title as you type, detaches the moment you edit it by hand, and debounces an availability check with clickable -2 / -3 suggestions.
Repeater Field
A generic controlled array field — add, remove, move, and drag-sort rows of any shape, with min/max clamping and stable row ids that never remount a focused input.