Custom Shadcn Time Picker for React and Tailwind CSS. A shadcn time picker with hour, minute, second and AM/PM columns in a popover or inline, text entry, steps, time bounds, form fields and i18n labels.
"use client"import { useState } from "react"import { TimePicker, TimePickerPanel,} from "@/components/reui/time-picker"
import { Card } from "@/components/ui/card"
export function Pattern() {
/* `HH:mm` in 24-hour time, or `null` once Clear empties it. Preselected
so each column opens centered on its selected row. */
const [value, setValue] = useState<string | null>("14:30")
return (
<div className="flex flex-col items-center gap-3">
{/* `py-0` lets the panel sit flush, so it reads like the popover. */}
<Card className="w-fit py-0">
<TimePicker
value={value}
onValueChange={setValue}
aria-label="Meeting time"
>
<TimePickerPanel />
</TimePicker>
</Card>
<p role="status" className="text-muted-foreground text-sm">
{value ? `Meeting at ${value}` : "No meeting time set"}
</p>
</div>
)
}
import { TimePicker } from "@/components/reui/time-picker"import { Field, FieldDescription, FieldLabel,} from "@/components/ui/field"
export function Pattern() {
return (
<div className="w-full max-w-xs">
<Field>
<FieldLabel id="standup-time-label" htmlFor="standup-time">
Standup time
</FieldLabel>
{/* With no children the picker renders its own trigger and popover.
`aria-labelledby` keeps the value in the trigger's name, so it
reads "Standup time 09:30" rather than the label alone. */}
<TimePicker
id="standup-time"
aria-labelledby="standup-time-label"
aria-describedby="standup-time-description"
/>
<FieldDescription id="standup-time-description">
Reminders go out 10 minutes before.
</FieldDescription>
</Field>
</div>
)
}
Typable time input
"use client"import { useId, useState } from "react"import { TimePicker, TimePickerContent, TimePickerInput,
} from "@/components/reui/time-picker"
import {
Field,
FieldDescription,
FieldLabel,
} from "@/components/ui/field"
export function Pattern() {
const id = useId()
const [value, setValue] = useState<string | null>(null)
return (
<div className="flex w-full max-w-xs flex-col gap-3">
<Field>
{/* `htmlFor` alone is enough: the input speaks its own value. */}
<FieldLabel htmlFor={id}>Pickup time</FieldLabel>
<TimePicker
id={id}
minuteStep={5}
value={value}
onValueChange={setValue}
aria-describedby={`${id}-description`}
>
{/* Typed text commits on blur or Enter; unreadable text reverts.
The popover anchors on the clock button at the end of the
field, so `align="end"` keeps it under the field. */}
<TimePickerInput />
<TimePickerContent align="end" />
</TimePicker>
<FieldDescription id={`${id}-description`}>
Type 930, 14:30 or 2:30 pm. Times snap to 5 minutes.
</FieldDescription>
</Field>
<p role="status" className="text-muted-foreground text-sm">
{value ? `Pickup at ${value}` : "No pickup time yet"}
</p>
</div>
)
}
12-hour clock with seconds
import { TimePicker } from "@/components/reui/time-picker"import { Field, FieldDescription, FieldLabel,} from "@/components/ui/field"
export function Pattern() {
return (
<div className="w-full max-w-xs">
<Field>
<FieldLabel id="stream-live-label" htmlFor="stream-live">
Stream goes live
</FieldLabel>
{/* Only the display is 12-hour: the trigger shows 02:45:30 PM while
the value stays 24-hour `HH:mm:ss`, "14:45:30". The popover
gets 4 columns, hour, minute, second and AM/PM. */}
<TimePicker
id="stream-live"
hourCycle={12}
granularity="second"
defaultValue="14:45:30"
aria-labelledby="stream-live-label"
aria-describedby="stream-live-description"
/>
<FieldDescription id="stream-live-description">
Pick the exact second the broadcast starts.
</FieldDescription>
</Field>
</div>
)
}
Hour only
import { TimePicker, TimePickerContent, TimePickerTrigger,} from "@/components/reui/time-picker"import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import { MailIcon } from 'lucide-react'
export function Pattern() {
return (
<div className="w-full max-w-sm">
<Item variant="outline">
<ItemMedia variant="icon">
<MailIcon aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle id="digest-time-title">Daily digest</ItemTitle>
<ItemDescription id="digest-time-description">
Mentions, replies and new tasks, once a day.
</ItemDescription>
</ItemContent>
<ItemActions>
{/* Hour granularity leaves 2 columns, hour and AM/PM, and every
value lands on the hour ("09:00", "21:00"). The row title names
the trigger, which adds its value: "Daily digest 09:00 AM". */}
<TimePicker
granularity="hour"
hourCycle={12}
defaultValue="09:00"
aria-labelledby="digest-time-title"
aria-describedby="digest-time-description"
>
<TimePickerTrigger size="sm" className="w-32" />
{/* The trigger sits at the row's end, so `align="end"` keeps the
popover under the row instead of hanging past its edge. */}
<TimePickerContent align="end" />
</TimePicker>
</ItemActions>
</Item>
</div>
)
}
Steps and opening hours
import { TimePicker } from "@/components/reui/time-picker"import { Field, FieldDescription, FieldLabel,} from "@/components/ui/field"
export function Pattern() {
return (
<div className="w-full max-w-xs">
<Field>
<FieldLabel id="pickup-time-label" htmlFor="pickup-time">
Pickup time
</FieldLabel>
{/* Hours outside the window are disabled. With no value the columns
open on 08:30, the first time that can be chosen, and picking 08
lands on 08:30 because 08:00 and 08:15 are before opening. */}
<TimePicker
id="pickup-time"
minuteStep={15}
min="08:30"
max="17:30"
aria-labelledby="pickup-time-label"
aria-describedby="pickup-time-description"
/>
<FieldDescription id="pickup-time-description">
Open 08:30 to 17:30.
</FieldDescription>
</Field>
</div>
)
}
Unavailable slots
"use client"import { TimePicker } from "@/components/reui/time-picker"import { Field, FieldDescription,
FieldLabel,
} from "@/components/ui/field"
const BOOKED = new Set(["10:00", "14:30", "15:00"])
/* Values arrive as `HH:mm`, so plain string comparison orders them. Module
scope keeps the function stable, and a function prop is also why this file
is a client module although it holds no state. */
function isUnavailable(value: string) {
return (value >= "12:00" && value < "13:00") || BOOKED.has(value)
}
export function Pattern() {
return (
<div className="w-full max-w-xs">
<Field>
<FieldLabel id="appointment-time-label" htmlFor="appointment-time">
Appointment
</FieldLabel>
{/* An hour with no free slot left is disabled whole, like 12. */}
<TimePicker
id="appointment-time"
minuteStep={30}
min="09:00"
max="17:00"
isTimeDisabled={isUnavailable}
aria-labelledby="appointment-time-label"
aria-describedby="appointment-time-description"
/>
<FieldDescription id="appointment-time-description">
Lunch from 12:00 to 13:00 and times already booked are unavailable.
</FieldDescription>
</Field>
</div>
)
}
Start and end time
"use client"import { useId, useState } from "react"import { formatTimeValue, parseTimeValue, TimePicker,
} from "@/components/reui/time-picker"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
/* The range cannot cross midnight: the start stops at 23:30, so the shortest
meeting still ends by 23:45, the last 15-minute slot of the day. */
const STEP = 15
const LATEST_END = 23 * 60 + 45
/* Minutes since midnight, both ways, through the picker's own value format. */
function toMinutes(value: string) {
const time = parseTimeValue(value)
return time ? time.hour * 60 + time.minute : 0
}
function fromMinutes(minutes: number) {
return formatTimeValue({
hour: Math.floor(minutes / 60),
minute: minutes % 60,
second: 0,
})
}
/* Plain text rather than Intl, so the server and the browser agree. */
function formatDuration(minutes: number) {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (hours === 0) return `${rest} min`
return rest === 0 ? `${hours} h` : `${hours} h ${rest} min`
}
export function Pattern() {
const id = useId()
const [start, setStart] = useState<string | null>("09:00")
const [end, setEnd] = useState<string | null>("10:00")
const changeStart = (next: string | null) => {
setStart(next)
if (!next || !end) return
/* The end moves by the same amount, so the meeting keeps its length.
After a Clear there is no old start to shift from, so the end is only
pushed past the new start when it would otherwise sit before it. */
const shift = start ? toMinutes(next) - toMinutes(start) : 0
const shifted = Math.max(toMinutes(end) + shift, toMinutes(next) + STEP)
setEnd(fromMinutes(Math.min(shifted, LATEST_END)))
}
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<FieldGroup className="grid grid-cols-2">
<Field>
<FieldLabel id={`${id}-start-label`} htmlFor={`${id}-start`}>
Start
</FieldLabel>
<TimePicker
id={`${id}-start`}
minuteStep={STEP}
max="23:30"
value={start}
onValueChange={changeStart}
aria-labelledby={`${id}-start-label`}
/>
</Field>
<Field>
<FieldLabel id={`${id}-end-label`} htmlFor={`${id}-end`}>
End
</FieldLabel>
{/* A `min` that follows the start disables every earlier slot, so
the duration can never reach zero or go negative. */}
<TimePicker
id={`${id}-end`}
minuteStep={STEP}
min={start ? fromMinutes(toMinutes(start) + STEP) : undefined}
value={end}
onValueChange={setEnd}
aria-labelledby={`${id}-end-label`}
/>
</Field>
</FieldGroup>
<p role="status" className="text-muted-foreground text-sm">
{start && end
? `Duration: ${formatDuration(toMinutes(end) - toMinutes(start))}`
: "Pick a start and an end time"}
</p>
</div>
)
}
Time zone label
import { TimePicker, TimePickerClear, TimePickerColumns, TimePickerConfirm, TimePickerContent, TimePickerFooter,
TimePickerTrigger,
TimePickerValue,
} from "@/components/reui/time-picker"
import {
Field,
FieldDescription,
FieldLabel,
} from "@/components/ui/field"
import { ClockIcon } from 'lucide-react'
export function Pattern() {
return (
<div className="w-full max-w-xs">
<Field>
<FieldLabel id="doors-open-label" htmlFor="doors-open">
Doors open
</FieldLabel>
{/* The zone is a fixed label, not a conversion: the value is the
venue's wall-clock time. The label and the value name the trigger,
so the zone joins them as its description. */}
<TimePicker
id="doors-open"
defaultValue="19:30"
aria-labelledby="doors-open-label"
aria-describedby="doors-open-zone doors-open-description"
>
<TimePickerTrigger>
<TimePickerValue />
<span
id="doors-open-zone"
className="text-muted-foreground ms-auto"
>
Berlin time
</span>
{/* Children replace the default value and clock, so the clock is
added back; `data-icon` keeps the button's end padding. */}
<span
data-icon="inline-end"
aria-hidden="true"
className="text-muted-foreground flex"
>
<ClockIcon aria-hidden="true" />
</span>
</TimePickerTrigger>
<TimePickerContent>
<TimePickerColumns />
{/* No Now: it would read the viewer's clock, not the venue's. */}
<TimePickerFooter>
<TimePickerClear />
<TimePickerConfirm />
</TimePickerFooter>
</TimePickerContent>
</TimePicker>
<FieldDescription id="doors-open-description">
Shown on every ticket in venue time, wherever the buyer is.
</FieldDescription>
</Field>
</div>
)
}
Confirm before applying
"use client"import { useId, useState } from "react"import { TimePicker } from "@/components/reui/time-picker"import { Field,
FieldDescription,
FieldLabel,
} from "@/components/ui/field"
export function Pattern() {
const id = useId()
/* Only a committed value reaches this state. Column picks, Now and Clear
stay a draft in the popover until OK, and closing it discards them, so
the roster never sees a half-made change. */
const [shiftStart, setShiftStart] = useState<string | null>("08:00")
return (
<div className="mx-auto flex w-full max-w-xs flex-col gap-3">
<Field>
<FieldLabel id={`${id}-label`} htmlFor={id}>
Shift start
</FieldLabel>
<TimePicker
id={id}
aria-labelledby={`${id}-label`}
aria-describedby={`${id}-hint`}
value={shiftStart}
onValueChange={setShiftStart}
minuteStep={15}
/* Also adds OK to the footer, drawn as the primary button. */
requireConfirm
/>
<FieldDescription id={`${id}-hint`}>
Changes apply when you press OK.
</FieldDescription>
</Field>
{/* Announced once per applied change, not on every pick in the
columns. */}
<p role="status" className="text-muted-foreground text-sm">
{shiftStart ? `Shift starts at ${shiftStart}` : "No shift start set"}
</p>
</div>
)
}
Localized labels
"use client"import { useState } from "react"import { TimePicker, TimePickerPanel, type TimePickerHourCycle,
type TimePickerI18nOverrides,
type TimePickerPeriodPosition,
} from "@/components/reui/time-picker"
import { Card } from "@/components/ui/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
type PickerLocale = {
lang: string
name: string
dir: "ltr" | "rtl"
hourCycle: TimePickerHourCycle
periodPosition?: TimePickerPeriodPosition
i18n?: TimePickerI18nOverrides
}
const ARABIC_DIGITS = "٠١٢٣٤٥٦٧٨٩"
/* Module scope keeps each `i18n` object stable, so the picker merges it
with the English defaults once, not on every render. English is the
default config, so it passes none. */
const LOCALES: PickerLocale[] = [
{ lang: "en", name: "English", dir: "ltr", hourCycle: 12 },
{
lang: "de",
name: "Deutsch",
dir: "ltr",
/* A 24-hour clock has no period column, so `period`, `am` and `pm` are
left out. */
hourCycle: 24,
i18n: {
labels: {
placeholder: "Uhrzeit wählen",
hour: "Stunde",
minute: "Minute",
second: "Sekunde",
now: "Jetzt",
clear: "Löschen",
confirm: "OK",
panelLabel: "Uhrzeit wählen",
openPicker: "Zeitauswahl öffnen",
},
},
},
{
lang: "zh",
name: "中文",
dir: "ltr",
hourCycle: 12,
/* 上午 and 下午 come before the time, so the period column leads. */
periodPosition: "start",
i18n: {
labels: {
placeholder: "选择时间",
hour: "时",
minute: "分",
second: "秒",
period: "上午/下午",
am: "上午",
pm: "下午",
now: "现在",
clear: "清除",
confirm: "确定",
panelLabel: "选择时间",
openPicker: "打开时间选择器",
},
},
},
{
lang: "ar",
name: "العربية",
dir: "rtl",
hourCycle: 12,
i18n: {
labels: {
placeholder: "اختر الوقت",
hour: "الساعة",
minute: "الدقيقة",
second: "الثانية",
period: "ص/م",
am: "ص",
pm: "م",
now: "الآن",
clear: "مسح",
confirm: "تم",
panelLabel: "اختر الوقت",
openPicker: "فتح منتقي الوقت",
},
functions: {
/* A string map rather than Intl, so the server and the browser print
the same digits. Column typeahead accepts both digit sets. */
formatSegment: (value) =>
String(value)
.padStart(2, "0")
.replace(/\d/g, (digit) => ARABIC_DIGITS[Number(digit)]),
},
},
},
]
export function Pattern() {
/* One value for every tab: the language changes the labels and the
clock, never the stored `HH:mm`. */
const [value, setValue] = useState<string | null>("14:30")
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-3">
<Tabs defaultValue="en">
<TabsList>
{/* Each tab names its language in that language; `lang` lets a
screen reader pronounce it. */}
{LOCALES.map((locale) => (
<TabsTrigger
key={locale.lang}
value={locale.lang}
lang={locale.lang}
>
{locale.name}
</TabsTrigger>
))}
</TabsList>
{LOCALES.map((locale) => (
<TabsContent key={locale.lang} value={locale.lang}>
{/* Arabic mirrors the header and footer; the columns keep hour first. */}
<div dir={locale.dir} lang={locale.lang}>
<Card className="w-fit py-0">
<TimePicker
value={value}
onValueChange={setValue}
hourCycle={locale.hourCycle}
periodPosition={locale.periodPosition}
i18n={locale.i18n}
>
<TimePickerPanel />
</TimePicker>
</Card>
</div>
</TabsContent>
))}
</Tabs>
<p role="status" className="text-muted-foreground text-sm">
{value ? `Stored as ${value} in every language` : "No time stored"}
</p>
</div>
)
}
Form field with validation
"use client"import { useId, useState } from "react"import { TimePicker } from "@/components/reui/time-picker"import { Button } from "@/components/ui/button"import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
export function Pattern() {
const id = useId()
const [invalid, setInvalid] = useState(false)
const [booked, setBooked] = useState<string | null>(null)
return (
/* `noValidate`: the Field draws the error, not the browser bubble. */
<form
noValidate
className="mx-auto w-full max-w-sm"
onSubmit={(event) => {
event.preventDefault()
/* The picker submits `HH:mm` under its `name`, or "" while empty. */
const time = String(
new FormData(event.currentTarget).get("deliveryTime") ?? ""
)
setInvalid(!time)
setBooked(time || null)
}}
onReset={() => {
/* The picker returns to its default (empty) on the same reset. */
setInvalid(false)
setBooked(null)
}}
>
<FieldGroup>
<Field data-invalid={invalid || undefined}>
<FieldLabel id={`${id}-label`} htmlFor={id}>
Delivery time
</FieldLabel>
<TimePicker
id={id}
name="deliveryTime"
required
/* Sets aria-invalid on the trigger; the Field colors the label. */
invalid={invalid}
minuteStep={30}
min="08:00"
max="20:00"
aria-labelledby={`${id}-label`}
aria-describedby={`${id}-hint`}
onValueChange={(next) => {
/* A pick clears the error; emptying the field waits for Submit. */
if (next) setInvalid(false)
}}
/>
{/* One id for both, so aria-describedby follows whichever is shown. */}
{invalid ? (
<FieldError id={`${id}-hint`}>
Choose a delivery time to continue.
</FieldError>
) : (
<FieldDescription id={`${id}-hint`}>
30-minute slots between 08:00 and 20:00.
</FieldDescription>
)}
</Field>
<div className="flex items-center gap-2">
<Button type="submit">Submit</Button>
<Button type="reset" variant="outline">
Reset
</Button>
<p
role="status"
className="text-muted-foreground ms-auto min-w-0 truncate text-sm"
>
{booked && `Delivery booked for ${booked}`}
</p>
</div>
</FieldGroup>
</form>
)
}
Date and time
"use client"import { useState } from "react"import { TimePicker, TimePickerColumns, TimePickerPanel,
} from "@/components/reui/time-picker"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
/* Plain arrays rather than Intl or toLocale*, so the server and the browser
always print the same summary. */
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]
/* Fixed dates, so the server and every visitor render the same month. */
const FIRST_MONTH = new Date(2026, 9, 1)
const SUGGESTED_DAY = new Date(2026, 9, 14)
export function Pattern() {
const [date, setDate] = useState(SUGGESTED_DAY)
const [time, setTime] = useState("09:00")
const [scheduled, setScheduled] = useState<string | null>(null)
const summary = `${WEEKDAYS[date.getDay()]}, ${MONTHS[date.getMonth()]} ${date.getDate()} at ${time}`
/* Derived, so changing the day or the time after scheduling reads as a
new, unsaved choice again. */
const isScheduled = scheduled === summary
return (
<Card className="mx-auto w-fit max-w-full">
<CardHeader>
<CardTitle>Schedule a post</CardTitle>
<CardDescription>
Pick the day and the time it goes live.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
{/* `required` keeps one day selected, so the summary always has
a date to show. */}
<Calendar
mode="single"
required
selected={date}
onSelect={setDate}
defaultMonth={FIRST_MONTH}
/>
<div className="flex justify-center self-stretch max-sm:border-t max-sm:pt-4 sm:border-s sm:ps-4">
<TimePicker
aria-label="Publish time"
value={time}
onValueChange={(next) => {
/* No footer means no Clear, so `next` is never null here. */
if (next) setTime(next)
}}
minuteStep={15}
>
<TimePickerPanel className="[--time-picker-rows:7]">
{/* No footer: Now would set the time but not the date. */}
<TimePickerColumns />
</TimePickerPanel>
</TimePicker>
</div>
</div>
</CardContent>
<CardFooter>
<p role="status" className="me-3 min-w-0 flex-1 text-sm">
{isScheduled && (
<span className="text-muted-foreground">Scheduled for </span>
)}
<span className="font-medium tabular-nums">{summary}</span>
</p>
<Button onClick={() => setScheduled(summary)}>Schedule</Button>
</CardFooter>
</Card>
)
}
API Reference
Time Picker is composable. TimePicker holds the value and renders no wrapper
element. TimePickerTrigger or TimePickerInput opens TimePickerContent,
and TimePickerPanel renders the same columns and footer in place. Each column
is a listbox, and the footer holds the Now, Clear and OK buttons. It composes
the shadcn Popover, Button and InputGroup. The value is a plain string, so
there is no date library to install.
Value
The value is a string in the format <input type="time"> uses: HH:mm, or
HH:mm:ss with granularity="second", always in 24-hour time whatever
hourCycle displays. null or "" is empty. onValueChange fires with the
new value in that format, or null when cleared, and only when it differs from
the current one. With granularity="hour" the value is HH:00.
A controlled value the picker would not offer (off the steps, outside min and
max, or disabled) is shown as is and never rewritten: nothing is emitted until
someone picks. Off the steps, a column highlights the available row below it. A
value with seconds shows, submits and reads at the picker's granularity, so
14:30:15 is 14:30 at minute granularity. A string parseTimeValue cannot
read shows as empty.
That picker shows 02:30 PM, and its value stays "14:30". isTimeDisabled,
useTimePicker and the helpers hand you the same time as numbers, with hour
from 0 to 23:
type TimePickerTime = { hour: number; minute: number; second: number }
i18n
Every string the picker renders, and the way it writes a time, comes from
i18n. It is merged over the English defaults per section, so one label is
enough.
type TimePickerI18nOverrides = { labels?: Partial<TimePickerLabels> functions?: Partial<TimePickerFunctions>}
Label
Default
Used for
placeholder
"Select time"
Trigger text while empty.
hour, minute, second, period
"Hour", "Minute", "Second", "AM/PM"
Column headers, which also name the listboxes.
am, pm
"AM", "PM"
The period rows, the shown value and what the input reads.
now, clear, confirm
"Now", "Clear", "OK"
The footer buttons.
panelLabel
"Choose time"
Names the popover and an inline panel.
openPicker
"Open time picker"
Names the clock button in TimePickerInput.
Function
Type
Description
formatSegment
(value: number, unit: TimePickerUnit) => string
Option text in the columns. Pads to 2 digits. In 12-hour time the hour is 1 to 12.
The value as the trigger, TimePickerValue and TimePickerInput show it.
context carries hourCycle, granularity, periodPosition, the merged
labels and the merged formatSegment, so a formatSegment override reaches
the default formatValue too. The default writes 14:30, adds seconds with
granularity="second", keeps :00 with granularity="hour", and in 12-hour
time adds the am or pm label: 02:30 PM. periodPosition is a root prop,
not a label: "start" puts the period before the time and the AM/PM column
first, as Chinese, Japanese and Korean write it. With TimePickerInput, keep a
custom formatValue to text parseTimeInput reads: an untouched value is
never read back, but an edited one is.
Digits come from formatSegment. The column type-ahead and TimePickerInput
read Arabic-Indic, Persian and full-width digits back, so localized text
round-trips. The popover renders in a portal outside your dir wrapper, so pass
dir to TimePickerContent as well.
The defaults are plain string code with no Intl, so the server and the
browser render the same text. An override built on Intl or toLocaleString
can format differently on each, because their default locale and ICU data
differ, and the mismatch fails hydration. Pin the locale in every Intl call,
and prefer a fixed map like the one above for anything the server renders.
TimePicker
The root. Holds the value, the popover state and any pending draft, and renders
no wrapper element. Without children it renders a TimePickerTrigger and a
TimePickerContent with the default columns and footer, so <TimePicker />
alone is a complete picker; className and placeholder reach that trigger.
Prop
Type
Default
Description
value
string | null
-
Controlled value, HH:mm or HH:mm:ss in 24-hour time. null is empty.
defaultValue
string | null
null
Initial value when uncontrolled, and what a form reset restores.
onValueChange
(value: string | null) => void
-
Fires with the new value in the same format, or null when cleared.
open
boolean
-
Controlled popover state.
defaultOpen
boolean
false
Initial popover state when uncontrolled.
onOpenChange
(open: boolean) => void
-
Fires when the popover opens or closes.
hourCycle
12 | 24
24
12 shows hours 12, 01 to 11 and adds the AM/PM column.
granularity
"hour" | "minute" | "second"
"minute"
The finest column, and the precision of the value.
periodPosition
"start" | "end"
"end"
Where AM/PM goes, in the text and in the columns.
hourStep
number
1
Hours offered, counted from 0 on the 24-hour scale.
minuteStep
number
1
Minutes offered, counted from 0.
secondStep
number
1
Seconds offered, counted from 0.
min
string
-
Earliest selectable time, inclusive. After max, the window runs overnight.
max
string
-
Latest selectable time, inclusive.
isTimeDisabled
(value: string, time: TimePickerTime) => boolean
-
Marks single times unavailable. value is in the value format.
requireConfirm
boolean
false
Picks, Now and Clear stay a draft until OK. Closing discards them.
fade
boolean
true
Fades the top and bottom of a column while more rows are scrolled out of view there.
disabled
boolean
false
Disables every part and stops the popover opening. A disabled picker submits nothing.
readOnly
boolean
false
The popover opens and the columns take focus, but no pick, key, Now or Clear changes the value.
invalid
boolean
false
Sets aria-invalid on the trigger or the input, and data-invalid on the trigger.
name
string
-
Submits the value with a native form under this name.
form
string
-
Associates the field with a form elsewhere in the page.
required
boolean
false
Fails native form validation while empty.
inputRef
Ref<HTMLInputElement>
-
The hidden form field, for form libraries that focus a field on error.
id
string
generated
Lands on the trigger or the input, so a label's htmlFor reaches it.
aria-label
string
-
Names the trigger or the input, the popover and an inline panel.
aria-labelledby
string
-
The same, by id. The trigger keeps its value in the name.
aria-describedby
string
-
Describes the trigger or the input.
i18n
TimePickerI18nOverrides
English
Labels and formatting, merged over the defaults.
placeholder
string
labels.placeholder
Text of the default trigger while empty.
className
string
-
Classes for the default trigger.
Steps count from 0 in each unit, so minuteStep={15} offers 00, 15, 30 and 45,
and hourStep={5} under a 12-hour clock offers 12, 05 and 10 AM and 03 and
08 PM. min, max and isTimeDisabled narrow those steps. isTimeDisabled
runs for each step-aligned time inside the bounds, and availability is
recomputed whenever its identity changes, so define it at module scope or
memoize it.
With requireConfirm, picks, Now and Clear write a draft that the columns show
and the trigger does not. It is committed by OK, by Enter on a
column, or by Alt + ↑ in the popover. Closing the popover,
or Esc on an inline column, drops it, and every open starts without
one. Typing in TimePickerInput and useTimePicker().setValue commit
directly.
TimePickerTrigger
A shadcn Button that opens the popover, and the element that takes the root's
id and ARIA props. It shows TimePickerValue and a clock icon. children
replace both; include a TimePickerValue in them to keep the value in the
accessible name. Accepts every Button prop.
Prop
Type
Default
Description
variant
Button variant
"outline"
The look of the button.
placeholder
ReactNode
labels.placeholder
Shown while there is no value.
children
ReactNode
value and icon
Replaces the content of the button.
TimePickerValue
The committed value as text, or the placeholder, in a span. Use it in custom
trigger content: its id is what keeps the value in the trigger's name. It never
shows a draft. Accepts every span prop.
Prop
Type
Default
Description
placeholder
ReactNode
labels.placeholder
Shown while there is no value.
TimePickerInput
A shadcn InputGroup with a text field and a clock button that opens the
popover. Use it in place of TimePickerTrigger, not beside it: both take the
root's id. Pair it with <TimePickerContent align="end"> so the popover lines
up with the button.
It reads 9, 930, 14:30, 14.30, 14h30, 2:30 pm and 2p, the am and
pm labels on either side, and full-width, Arabic-Indic and Persian digits.
Text is read on blur and Enter, moved to the nearest selectable time,
and committed directly, even under requireConfirm. Text it cannot read puts
the value back, and an empty field clears it. Under a 12-hour clock, a time
typed without AM or PM takes the half of the day on screen, unless only the
other half is inside min and max.
Prop
Type
Default
Description
placeholder
string
a mask
--:--, --:--:-- with seconds, and --:-- -- or -- --:-- under a 12-hour clock.
className
string
-
Classes for the InputGroup.
Every other prop reaches the <input>. onChange, onBlur and onKeyDown run
before the built-in handlers, and a keydown that calls preventDefault skips
the built-in keys.
TimePickerContent
The popover, a shadcn PopoverContent holding TimePickerColumns and
TimePickerFooter by default. It is named by the root's aria-labelledby or
aria-label, else by labels.panelLabel, and Tab cycles inside
it. Accepts every PopoverContent prop, dir included.
Prop
Type
Default
Description
align
"start" | "center" | "end"
"start"
Alignment against the trigger.
children
ReactNode
columns and footer
Replaces the content.
TimePickerPanel
The columns and the footer rendered in place, for an always-open picker: put it
straight inside TimePicker, with no trigger or content. Inline it is a
group named like the popover; inside TimePickerContent it is a plain div.
Inline, the default footer shows OK only under requireConfirm, because every
pick already commits. Accepts every div prop.
Prop
Type
Default
Description
children
ReactNode
columns and footer
Replaces the content.
TimePickerColumns
The row of columns, one per unit the picker needs, in periodPosition order.
It is dir="ltr" in every text direction, because a time reads hour first in
right-to-left text too; pass dir to change it. Accepts every div prop.
Prop
Type
Default
Description
children
ReactNode
a column for each unit
TimePickerColumns, to reorder, relabel or style.
TimePickerColumn
One unit as a listbox under a header. Selection follows the keyboard, like a
native time wheel. A row that leads to no selectable time is disabled, and the
highlighted row scrolls toward the middle of the list whenever it changes.
Accepts every div prop.
Prop
Type
Default
Description
type
"hour" | "minute" | "second" | "period"
-
Required. Renders nothing for a unit the granularity or the hour cycle leaves out.
label
ReactNode
labels[type]
Header text, which also names the listbox.
A pick moves to the nearest selectable time with that row's value and remembers
what it aimed at: from 09:15 with min="08:30", hour 8 gives 08:30 and hour 9
goes back to 09:15. An empty picker fills the other units with 0 where it can,
so hour 14 gives 14:00. Under a 12-hour clock the hour column lists the half of
the day the shown time is in, and AM or PM keeps the hour on the other side of
noon.
Each list shows 5 rows. Set --time-picker-rows on any ancestor for more, as in
className="[--time-picker-rows:7]", and keep it odd so the selection can sit
in the middle. The selection stays centered when the list resizes, for example
when a font or a theme settles after mount.
With fade on, the default, an end of a list fades only while rows are scrolled
past it, so the first and last rows are never dimmed at rest. The fade is one
row deep; set --time-picker-fade-size on any ancestor to change it, or pass
fade={false} to turn it off.
TimePickerFooter
The action row. Without children it holds Now and Clear, plus OK in a popover or
under requireConfirm. Pass the actions you want as children to leave one out.
Accepts every div prop.
Prop
Type
Default
Description
children
ReactNode
Now, Clear and OK
Replaces the actions. OK is left out inline unless requireConfirm is set.
TimePickerNow, TimePickerClear, TimePickerConfirm
shadcn Buttons wired to the picker, labelled from i18n. An onClick that
calls preventDefault skips the action. Each accepts every Button prop.
Prop
Type
Default
Description
variant
Button variant
"ghost"; OK is "default" under requireConfirm, else "secondary"
The look of the button.
size
Button size
"sm"
The size of the button.
children
ReactNode
labels.now, labels.clear, labels.confirm
The button text.
TimePickerNow sets the current time, floored to the steps (14:38 on a 15
minute step is 14:30), or the next selectable time after it, else the last one
before it. It reads the clock only when pressed, never while rendering.
Disabled when the picker is disabled or readOnly.
TimePickerClear empties the value (the draft under requireConfirm), then
moves focus to the first column. Disabled when the picker is disabled or
readOnly.
TimePickerConfirm commits a pending draft and closes the popover. Disabled
only when the picker is disabled, so it still closes a read-only picker.
useTimePicker
Returns the picker's state and actions inside a TimePicker, for a custom
action or readout. It throws outside one.
Field
Type
Description
value
string | null
The committed value in the value format.
time
TimePickerTime | null
The same value as numbers.
open
boolean
Whether the popover is open.
setOpen
(open: boolean) => void
Opens or closes the popover.
setValue
(value: string | null) => void
Commits a value as given, with no snapping and no draft. A string parseTimeValue cannot read clears.
now
() => void
The same as TimePickerNow.
clear
() => void
Empties the value, or the draft under requireConfirm.
Render it anywhere inside the picker, such as a composed TimePickerFooter
next to TimePickerConfirm.
Helpers
Pure functions for code that never mounts a picker: a duration, a sort, a
payload built on the client. They ship in the same "use client" module as the
picker, so a Server Component cannot call them.
Helper
Returns
parseTimeValue(value)
A TimePickerTime from HH:mm or HH:mm:ss (a 1-digit hour also reads), dropping a fraction of a second. Anything else, "" and 24:00 included, is null.
formatTimeValue(time, granularity)
HH:mm, or HH:mm:ss when granularity is "second". It defaults to "minute".
parseTimeInput(text, options)
A TimePickerTime from typed text in the grammar TimePickerInput reads, or null.
mergeTimePickerI18n(overrides)
The full i18n config, the overrides merged over the defaults per section.
DEFAULT_TIME_PICKER_I18N
The English labels and functions.
parseTimeInput takes hourCycle, the am and pmlabels, and period,
the half of the day a 12-hour time typed without one takes ("am" by default).
Give the root a name and the value submits with a native form, as HH:mm or
HH:mm:ss. An empty picker submits an empty string, so required works. The
field carries the committed value, never a draft, and a disabled picker submits
nothing. form ties the field to a form elsewhere in the page, and inputRef
points at it for form libraries, such as react-hook-form, that focus a field on
error. Enter in TimePickerInput reads the typed text before the
form submits.
A form reset restores defaultValue, empty unless you passed one, as a native
field returns to its default; a controlled picker gets it through
onValueChange. A listener that cancels the reset keeps the value.
required fails validation while the picker is empty and also lands on
TimePickerInput's own field. invalid is for your own validation: it sets
aria-invalid, so pair it with an error message in aria-describedby, as the
form field example does.
Selects the next or previous available row, without wrapping. When empty, it selects the highlighted row.
HomeEnd
Selects the first or last available row.
Page DownPage Up
Moves 5 rows, stopping at the last or first available row.
←→
Moves focus to the previous or next column, left to right in every text direction.
0 to 9
Selects the row typed. 2 digits within a second read as one number, so 14 is 14.
AP
In the AM/PM column, selects AM or PM, as does the first letter of a label. Pressing it again moves to the next match.
Space
Selects the highlighted row.
Enter
Selects the highlighted row, commits a draft and closes the popover.
Alt + ↑
In the popover, commits a draft and closes it.
Esc
Closes the popover, dropping a draft. Inline, drops a draft.
A digit with no row of its own selects the first row that starts with it, so
pressing 3 on a 15 minute step selects 30. Under a 12-hour clock you
type the hour as shown. In readOnly, the lists stay focusable and focus still
moves between them with ←→, but no key or click changes
the value, and Enter closes the popover.
In TimePickerInput:
Key
Action
Enter
Reads the typed text, before a form submits.
↑↓
Commits the next later or earlier available time, wrapping at midnight. When empty, the first or last one. Not in readOnly.
Alt + ↓
Reads the typed text and opens the popover.
Esc
While editing, puts the value back.
On the trigger and in the popover:
Key
Action
EnterSpace
On the trigger, opens the popover.
TabShift + Tab
Moves between the columns and the buttons, cycling inside the popover.
Accessibility
Each column is a listbox named by its header. Focus stays on the list and
aria-activedescendant points at the highlighted row, so the rows are not
tab stops. The list is described by the whole time on screen, draft included,
so a screen reader hears the time and not only the column's part of it.
With aria-labelledby, the trigger is named by your label followed by the
value, because a label on a button would otherwise replace its text.
aria-label replaces the text entirely, so prefer a visible label with
aria-labelledby, and point its htmlFor at the root id so a click on the
label still opens the picker.
The popover and an inline panel, a group, are named by the root's label or
by labels.panelLabel. The clock button in TimePickerInput is named by
labels.openPicker, and the input announces Alt + ↓
through aria-keyshortcuts.
The columns are dir="ltr" in every text direction, since a time reads hour
first in right-to-left text too, and ←→ are never
mirrored.
In forced colors mode the selected row takes the system Highlight colors,
and the keyboard row is marked with an outline, which forced colors keep.
The hidden form field is out of the tab order and hidden from assistive tech.
A failed required check focuses it, and it hands focus on to the trigger,
the input or, for an inline panel, the first column. For an error assistive
tech announces, validate yourself and pair invalid with a described-by
message.
Opening with a mouse or the keyboard focuses the first column in both twins.
Opened by touch, the Base UI popover focuses the popup itself, while the
Radix popover focuses the first column. Closing from the keyboard returns
focus to the trigger, or to the clock button for TimePickerInput.
Shadcn Time Picker Free Components
Browse 13 production-ready Shadcn Time Picker components for dashboards, forms, and product UI. These examples use Base UI primitives from @base-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.