Inputs
Time Picker
A segmented hour:minute input — type digits or arrow-step, plus a clock-button dropdown of minuteStep candidates — always normalized to 24-hour "HH:mm" regardless of display format.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/time-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TimePicker" component (lucide-react
Clock + X icons). No Radix, no date library — segments and the dropdown are
hand-rolled.
Contract
- Export a forwardRef<HTMLDivElement, TimePickerProps> whose ref points at
the outer wrapper; the root spreads the remaining div props.
- Controlled: value: string | null (always normalized 24h "HH:mm", never
locale- or display-dependent) and onValueChange(value: string | null).
- hour12?: boolean = false — swaps in an AM/PM segment and 12-hour display;
the emitted value is still always 24h.
- minuteStep?: number = 5 — arrow-step increment and dropdown candidate
Clamp it into 1..60 on the way in: the dropdown builds its options with
`m += minuteStep`, so a 0 or negative step would loop forever and hang the
tab (and 60/step would be Infinity in the arrow-step math). A bad prop must
degrade, never freeze.
spacing (typed minutes still accept any 00–59, not just step-aligned ones).
- min?/max?: string ("HH:mm", 24h) — inclusive bounds.
- clearable?, disabled?, invalid?, placeholder?, label?.
Behavior
- Three segments — hour, minute, and (hour12 only) AM/PM — each a
role="spinbutton" div with aria-valuenow/min/max/valuetext, tabIndex 0,
showing "--" when empty.
- Digit entry is a small per-segment state machine. The first keystroke
after arriving (via Tab, click, or explicit ←/→ nav) always REPLACES the
segment. A second keystroke typed without leaving EXTENDS the pending
digit into a two-digit number when that combination is valid and
in-bounds, then auto-advances focus to the next segment. When it isn't
valid — e.g. hour "9" then "3" (93 is not an hour) — the pending digit
finalizes alone ("09"), focus auto-advances, and the new keystroke feeds
into the next segment as ITS fresh first digit ("09" then minute starts
at "3", i.e. reads as "09:3…"). A leading digit that can never resolve to
an in-range, in-bounds value (e.g. "2" when max="08:00") is rejected
outright — never accepted and fixed up after the fact.
- ↑/↓ steps the focused segment (hour ±1, minute ±minuteStep, AM/PM
toggles), wrapping within its own range and skipping any value outside
min/max instead of getting stuck at the boundary. ←/→ moves between
segments. Backspace clears the focused segment (on minute, when already
empty, it moves back to hour instead).
- onValueChange only ever fires with a complete, in-range, in-bounds
"HH:mm" — never a half-typed draft. Losing focus on the whole control
with an incomplete or invalid draft snaps it back to the last valid value
(or empty), so a bad edit never leaks out silently.
- The clock button opens a listbox of every minuteStep-aligned time in the
day (288 rows at the 5-minute default), scrolled so the current value is
visible on open; ↑/↓ moves (skipping disabled rows), Enter selects,
Escape closes and returns focus to the button, click-outside closes.
Candidates outside min/max render aria-disabled and are not selectable,
never hidden.
- clearable renders an × that emits null and refocuses the hour segment.
Rendering & styling
- Semantic tokens only: border-input/bg-transparent/text-sm shell,
focus-within:border-ring + ring-ring/50, border-destructive +
aria-invalid when invalid, bg-popover/text-popover-foreground for the
dropdown, bg-accent/text-accent-foreground for the active row,
text-muted-foreground for placeholders/disabled rows. cn() merges
className on the outer wrapper.
- No Intl.*, no `new Date()` at render — every format is manual
zero-padding, so server and client markup always match.
Customization levers
- Display format: hour12 alone flips 24h ↔ 12h + AM/PM; the value contract
never changes, so existing onValueChange consumers don't need updates.
- Density / step: minuteStep controls both arrow granularity and dropdown
size (1 for every minute, 30 for a coarse picker) independent of what
users can type.
- Range: min/max turn this into a business-hours or shift-window picker;
omit either for an unrestricted clock.
- Dropdown footprint: the w-36 max-h-60 popup classes are the only layout
knobs — widen for longer 12-hour labels, grow the height for denser
steps.
- Composition: pair two instances (start/end) by feeding one instance's
value as the other's min for a live linked range.Concepts
- Draft vs value — the segments edit a local draft;
onValueChangeonly fires once the draft is a complete, in-bounds time, so the parent'svalueis never desynced by a half-typed segment. - Digit cascade — a second keystroke that would overflow the current segment (hour "9" then "3") finalizes the first digit alone and feeds the new one into the next segment, so "93" reads as "09:3…" instead of being silently clamped.
- Absolute range vs min/max bounds — typing rejects digits that can never form a valid segment value at all, and separately rejects ones that would leave
min/max, so business-hours-style constraints are enforced live, not just on blur. - Blur rollback — leaving the control with an incomplete or out-of-bounds draft snaps back to the last emitted value; a half-finished edit never leaks out as a bad value.
- Dropdown as a second input path — the clock button opens a
minuteStep-spaced candidate list sharing the same bounds and emit gate as typing, just pre-computed instead of validated per keystroke.
Date Range Picker
A two-month range calendar with preset shortcuts (Today / Last 7 days / Last 30 days / This month / Last month) — the standard filter for SaaS reports and dashboards.
Color Picker
A hex-driven color picker — saturation/value area, hue and alpha sliders, a format-aware text field, quick swatches, and an optional native eyedropper.