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…

"use client"

import * as React from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"

/** The value of the field: a number that may be absent, plus the unit it is expressed in. */
export interface UnitValue {
  /** `null` = no number at all. Rendered as `auto` when `allowAuto` is on. */
  value: number | null
  unit: string
}

type Notice = { kind: "clamp" | "error"; text: string }

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/unit-input.json

Prompt

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 (16px1rem) is a single controlled update. Two separate onChange calls would let a parent render an impossible intermediate like 16rem.
  • 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.5rem works and why 1.5re mid-word is not yet an error.
  • Unknown unit rejects, it never guesses12foo rolls 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 step prop covers three speeds: Shift multiplies by 10, Alt divides by 10, PageUp/PageDown behave like Shift. Steps land on the precision grid, 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 plain onWheel would bump the value and scroll the page out from under the caret.
  • auto is a value, not an empty string — with allowAuto, clearing the field emits value: null (an explicit "no length"), which is how CSS auto, "inherit from parent" and "unset" survive all the way to your serializer.
  • Every numeric prop is defendedstep <= 0 falls back to 1, precision is clamped to 0–6, and a crossed min/max resolves to min. A bad prop degrades the control; it must never freeze the field or spin forever.

On This Page