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…

"use client"

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

export type DurationUnit = "h" | "m" | "s"

type Texts = Record<DurationUnit, string>

interface DraftState {
  texts: Texts
  /** The one segment currently mid-typing. While it is set, nothing is emitted (see `typeDigit`). */
  editing: DurationUnit | null

Installation

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

Prompt

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 unitunits only 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/max writes a visible line naming the bound instead of silently rewriting the number, and that same line is what the live region announces.

On This Page