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…

"use client"

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

type Period = "AM" | "PM"
type Segment = "hour" | "minute" | "period"

interface DraftState {
  hourText: string
  hourEditing: boolean
  minuteText: string
  minuteEditing: boolean

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/time-picker.json

Prompt

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; onValueChange only fires once the draft is a complete, in-bounds time, so the parent's value is 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.

On This Page