Inputs
Duration Input
A duration field that normalizes to seconds — segmented h/m/s spinbuttons that carry (type 90 minutes, get 1h 30m), or a free-text mode that parses "1h30", "90m" and "1:30".
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/duration-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DurationInput" component (lucide-react X
icon only). No Radix, no date library — the segments and the text parser are
hand-rolled.
Contract
- Export a forwardRef<HTMLDivElement, DurationInputProps>; the ref lands on the
outer wrapper and the remaining div props spread onto it.
- Controlled: value: number | null — always a count of SECONDS, never a string
and never per-unit fields — plus onValueChange(value: number | null).
- units?: ("h" | "m" | "s")[] = ["h","m"] — which units are editable. Dedupe and
re-sort them into canonical h → m → s order; an empty or bogus array falls back
to the default. The finest active unit is the grid: with ["h","m"] every emitted
value is a multiple of 60.
- min?/max?: number (seconds, inclusive). A negative min is ignored (a duration
never goes below 0) and a non-finite/negative max means "no ceiling" — a bad
bound must degrade, never lock the control.
- step?: number (seconds) — the arrow-key increment for the finest segment;
larger segments always step by one of their own unit. step <= 0 or NaN falls
back to one finest unit, so arrows can never become a no-op.
- format?: "segments" | "text" = "segments".
- placeholder?, clearable?, invalid?, disabled?, label?.
Behavior
- segments: one role="spinbutton" div per active unit, tabIndex 0, with
aria-valuenow/valuemin/valuemax/valuetext ("30 minutes") and a small
aria-hidden unit label right after the digits (2 h 30 m). Empty reads "--".
Non-leading segments zero-pad to two digits; the leading one never pads, so a
100h value coming from the parent stays "100h" instead of "0100h".
- Digit entry: a segment accumulates digits while it is the one being edited and
NOTHING IS EMITTED in that window. This is the whole trick — emitting a
half-typed digit makes a controlled parent echo the value straight back, the
render-time resync rebuilds the draft, and the second keystroke gets read as a
fresh first digit (typing "90" would land on 9). The segment settles when it is
full, and only then does it carry, clamp, emit and auto-advance focus. Arriving
by Tab or click resets to "replace" mode.
- Digit capacity: two per segment, so continuous typing of "1" "3" "0" in h+m
reads as 13h 0m instead of 130 hours. The leading segment widens to four digits
when it is the ONLY segment (units ["m"] is deliberately open-ended), and to
however many digits `max` needs when max is set (max = 100h gives it three).
- Carry, not wrap: settling sums every segment into seconds and re-splits it, so
90 in the minutes segment becomes 1h 30m whenever an hours segment exists (with
units ["m","s"] there is no bigger unit, so 90m simply stays 90m). ↑/↓ steps the
TOTAL, so 59m + 1 rolls into 1h 00m instead of wrapping back to 0.
- ←/→ move between segments (settling the one you leave), Backspace/Delete blanks
the focused segment and keeps it in edit mode, Enter settles. Blurring the whole
group settles too — a single typed digit is a legal value and must not be lost.
Blanking every segment emits null rather than 0.
- text: one input parsed on blur/Enter (Esc reverts). Accept unit tokens in any
mix ("90m", "1h30m", "2.5h", "45 sec", "1 hour 30 min"), a bare tail number that
takes the next smaller unit ("1h30" = 1h 30m), a lone bare number in the finest
active unit ("90" = 90 minutes for h+m), and colon form whose groups fill
canonical units starting at the largest active one ("1:30" = 1h 30m for h+m,
1m 30s for m+s, "1:30:00" = h:m:s). Anything else — garbage, two bare numbers in
a row — fails: roll back to the last valid value and set aria-invalid rather
than guessing. A successful parse is floored onto the unit grid and re-spelled
canonically ("1h 30m"), so the user always sees exactly what was stored.
- Bounds are enforced on every settle, and clamping is never silent: show the
bound that was hit ("Maximum is 2h 30m") in a text-destructive line under the
control. Values arriving from the parent are displayed as-is, out of range or
not — the component never re-emits behind the consumer's back.
- One aria-live="polite" region carries either that clamp/parse reason or the
settled total ("1 hour 30 minutes"), updated on settle/blur/parse only — never
per keystroke, since each spinbutton already reports its own value on ↑/↓.
- clearable renders an × that emits null and refocuses the field.
Rendering & styling
- Semantic tokens only: border-input + bg-transparent shell, focus-within:
border-ring + ring-ring/50, border-destructive + aria-invalid when invalid or
after a failed parse, text-muted-foreground for unit labels and placeholders,
text-destructive for the clamp/parse line. cn() merges className onto the outer
wrapper. tabular-nums + a min-width per segment keep the box from twitching
while typing.
- No animation at all (nothing to disable under prefers-reduced-motion) and no
Intl / Date at render — every format is manual string work, so server and
client markup always match.
Customization levers
- Granularity: units alone reshapes the control — ["h","m"] for meetings,
["m","s"] for a pomodoro, ["h","m","s"] for precise timing, ["m"] for a single
open-ended box. The value contract stays seconds, so consumers never change.
- Input style: format="text" swaps the segments for a parsed free-text field
(better on touch, where a div spinbutton raises no virtual keyboard); the
parser and the normalization are shared, so both modes emit identical values.
- Stepping: step tunes the finest segment's arrow increment (30 for half-minute
nudges, 300 for 5-minute blocks) independently of what can be typed.
- Range: min/max turn it into a policy field ("between 5m and 2h30m"); drop the
destructive notice line if your form already renders errors elsewhere.
- Footprint: the shell's h-9 / px-2.5 / gap-1.5 and the text input's w-36 are the
only layout knobs; the segment row grows naturally for large values.
- Composition: pair two instances (shortest/longest) by feeding one's value as
the other's min for a live linked window.Concepts
- Duration, not instant — the value is an elapsed amount (a count of seconds), so there is no midnight, no wrap-around and no locale: 59m + 1 becomes 1h 00m, never 00m.
- Seconds as the normalized unit —
unitsonly changes which boxes are editable; the emitted value is always seconds, so changing granularity never breaks a consumer or a stored record. - Draft vs value — segments edit a local draft and nothing is emitted while a digit is pending; that gap is exactly what keeps a controlled parent from echoing a half-typed value back and eating the second keystroke.
- Carry — settling re-splits the total into the active units, so an over-full segment (90 minutes) promotes into the next bigger unit instead of being rejected or clamped to 59.
- Unit grid — the finest active unit defines the resolution; parsed or bounded values are floored/raised onto that grid so what is displayed is exactly what was emitted.
- Clamp with a reason — hitting
min/maxwrites a visible line naming the bound instead of silently rewriting the number, and that same line is what the live region announces.
Checkbox Tree
A multi-select hierarchical picker with parent-child cascade, derived indeterminate parents, and full WAI-ARIA tree keyboard navigation.
Masked Input
A generic pattern-masked text field — 9/A/* slots with literal separators, both the masked and the bare value on every change, and a caret that survives edits in the middle of the string.