# Time Picker

> Segmented time field with arrow-key stepping, direct digit entry and 12/24-hour modes.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/time-picker
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/time-picker.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/time-picker-demo.tsx"
"use client"

import * as React from "react"

import { TimePicker } from "@/components/time-picker"

export default function TimePickerDemo() {
  const [time, setTime] = React.useState("09:30")
  return (
    <div className="grid gap-5">
      <div className="grid gap-2">
        <span className="text-sm font-medium">Meeting starts</span>
        <TimePicker value={time} onValueChange={setTime} minuteStep={5} aria-label="Meeting start" />
        <span className="text-xs text-muted-foreground" data-testid="time">
          24h value: {time}
        </span>
      </div>
      <div className="grid gap-2">
        <span className="text-sm font-medium">24-hour</span>
        <TimePicker hour12={false} defaultValue="18:45" aria-label="24 hour" />
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/time-picker.tsx"
"use client"

import * as React from "react"
import { Clock } from "lucide-react"

import { cn } from "@/lib/utils"

type Segment = "hour" | "minute" | "period"

export interface TimePickerProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  /** 24h "HH:mm" */
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  hour12?: boolean
  minuteStep?: number
  disabled?: boolean
}

const pad = (n: number) => String(n).padStart(2, "0")

function parse(value: string) {
  const [h, m] = value.split(":").map(Number)
  return { hours: Number.isFinite(h) ? Math.min(23, Math.max(0, h)) : 0, minutes: Number.isFinite(m) ? Math.min(59, Math.max(0, m)) : 0 }
}

export function TimePicker({
  value,
  defaultValue = "09:30",
  onValueChange,
  hour12 = true,
  minuteStep = 1,
  disabled = false,
  className,
  ...props
}: TimePickerProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const refs = React.useRef<Record<Segment, HTMLDivElement | null>>({ hour: null, minute: null, period: null })
  const buffer = React.useRef("")
  const { hours, minutes } = parse(value ?? internal)
  const isPm = hours >= 12

  const commit = (h: number, m: number) => {
    const next = `${pad(((h % 24) + 24) % 24)}:${pad(((m % 60) + 60) % 60)}`
    setInternal(next)
    onValueChange?.(next)
  }

  const display = {
    hour: pad(hour12 ? hours % 12 || 12 : hours),
    minute: pad(minutes),
    period: isPm ? "PM" : "AM",
  }

  const order: Segment[] = hour12 ? ["hour", "minute", "period"] : ["hour", "minute"]
  const focusNext = (segment: Segment) => refs.current[order[order.indexOf(segment) + 1]]?.focus()

  const step = (segment: Segment, direction: 1 | -1) => {
    if (segment === "hour") {
      commit(hour12 ? ((hours % 12) + direction + 12) % 12 + (isPm ? 12 : 0) : hours + direction, minutes)
    } else if (segment === "minute") {
      commit(hours, Math.round((minutes + direction * minuteStep) / minuteStep) * minuteStep)
    } else {
      commit(hours + 12, minutes)
    }
  }

  const type = (segment: Segment, digit: number) => {
    const typed = buffer.current + digit
    if (segment === "hour") {
      const limit = hour12 ? 12 : 23
      const first = Number(typed[0])
      if (typed.length === 1 && first * 10 <= limit) {
        buffer.current = typed
        if (hour12) commit(first === 12 ? 12 * Number(isPm) : first + (isPm ? 12 : 0), minutes)
        else commit(first, minutes)
        return
      }
      const n = Math.min(limit, Number(typed.length === 1 ? typed : typed.slice(-2)))
      buffer.current = ""
      commit(hour12 ? (n % 12) + (isPm ? 12 : 0) : n, minutes)
      focusNext(segment)
    } else {
      if (typed.length === 1 && digit <= 5) {
        buffer.current = typed
        commit(hours, digit)
        return
      }
      const n = Math.min(59, Number(typed.length === 1 ? typed : typed.slice(-2)))
      buffer.current = ""
      commit(hours, n)
      focusNext(segment)
    }
  }

  const onKeyDown = (segment: Segment) => (event: React.KeyboardEvent) => {
    if (disabled) return
    if (event.key === "ArrowUp" || event.key === "ArrowDown") {
      event.preventDefault()
      buffer.current = ""
      step(segment, event.key === "ArrowUp" ? 1 : -1)
    } else if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
      const target = refs.current[order[order.indexOf(segment) + (event.key === "ArrowRight" ? 1 : -1)]]
      if (target) {
        event.preventDefault()
        target.focus()
      }
    } else if (segment === "period" && /^[ap]$/i.test(event.key)) {
      event.preventDefault()
      const wantPm = event.key.toLowerCase() === "p"
      if (wantPm !== isPm) commit(hours + 12, minutes)
    } else if (segment !== "period" && /^\d$/.test(event.key)) {
      event.preventDefault()
      type(segment, Number(event.key))
    }
  }

  const segmentClass =
    "rounded px-1 py-0.5 tabular-nums outline-none transition-colors focus:bg-primary focus:text-primary-foreground"

  const label = { hour: "Hours", minute: "Minutes", period: "AM or PM" } as const
  const range = {
    hour: hour12 ? { min: 1, max: 12, now: hours % 12 || 12 } : { min: 0, max: 23, now: hours },
    minute: { min: 0, max: 59, now: minutes },
    period: null,
  }

  return (
    <div
      {...props}
      role="group"
      aria-label={props["aria-label"] ?? "Time"}
      className={cn(
        "inline-flex h-10 items-center gap-1 rounded-lg border border-input bg-background px-3 text-sm transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/30",
        disabled && "pointer-events-none opacity-50",
        className,
      )}
    >
      <Clock aria-hidden className="mr-1 size-4 text-muted-foreground" />
      {order.map((segment, index) => (
        <React.Fragment key={segment}>
          {index === 1 && <span aria-hidden className="text-muted-foreground">:</span>}
          <div
            ref={(el) => {
              refs.current[segment] = el
            }}
            role="spinbutton"
            tabIndex={disabled ? -1 : 0}
            aria-label={label[segment]}
            aria-valuemin={range[segment]?.min}
            aria-valuemax={range[segment]?.max}
            aria-valuenow={range[segment]?.now}
            aria-valuetext={display[segment]}
            onKeyDown={onKeyDown(segment)}
            onBlur={() => {
              buffer.current = ""
            }}
            className={cn(segmentClass, segment === "period" && "ml-1 font-medium")}
          >
            {display[segment]}
          </div>
        </React.Fragment>
      ))}
    </div>
  )
}
```
