Skip to content
DocsSupportPricing
Roadmap (has updates coming soon)XFigma3.6K
Sign inGet All-Access
Overview
  • Introduction
  • Get Started
  • License Setup
  • Styling
  • Registry
  • MCP Server
  • Embed
  • Agent Skills
  • llms.txt
  • RTL
  • Changelog
  • YouTube
MCP Server
  • Claude
  • CodexCodexCodex
  • Cursor
  • Grok
  • Conductor
  • v0
  • Lovable
  • Replit
  • Bolt
  • OpenCode
  • VS Code
  • GitHub Copilot
  • Kilo Code
  • Zed
  • Antigravity
  • WSWindsurf
  • CLCline
  • Gemini CLI
  • AMAmp
  • JBJetBrains Junie
Components
  • Alert
  • Autocomplete
  • Badge
  • CascaderCascader can now search the whole tree from any level
  • Code Block
  • Data GridData Grid now scrolls in RTL without an extra provider
  • Date Selector
  • Event Calendar
  • File Upload
  • Filters
  • Frame
  • Gantt
  • Icon Stack
  • Icon Tile
  • Kanban
  • Number Field
  • Phone Input
  • Rating
  • Scrollspy
  • Signature PadNew Signature Pad docs
  • Sortable
  • Stepper
  • Time PickerNew Time Picker docs
  • Timeline
  • Tree

Shadcn Time Picker

PreviousNext

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.

Base UIRadix UI
Radix UI
"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>
  )
}

Installation

pnpm dlx shadcn@latest add @reui/time-picker

Usage

import {
  TimePicker,
  TimePickerClear,
  TimePickerColumns,
  TimePickerConfirm,
  TimePickerContent,
  TimePickerFooter,
  TimePickerNow,
  TimePickerTrigger,
} from "@/components/reui/time-picker"
<TimePicker value={time} onValueChange={setTime}>
  <TimePickerTrigger />
  <TimePickerContent>
    <TimePickerColumns />
    <TimePickerFooter>
      <TimePickerNow />
      <TimePickerClear />
      <TimePickerConfirm />
    </TimePickerFooter>
  </TimePickerContent>
</TimePicker>

Examples

Popover picker

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.

const [time, setTime] = useState<string | null>("14:30")
 
<TimePicker value={time} onValueChange={setTime} hourCycle={12} />

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>
}
LabelDefaultUsed 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.
FunctionTypeDescription
formatSegment(value: number, unit: TimePickerUnit) => stringOption text in the columns. Pads to 2 digits. In 12-hour time the hour is 1 to 12.
formatValue(time: TimePickerTime, context: TimePickerFormatContext) => stringThe 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.

const DE: TimePickerI18nOverrides = {
  labels: {
    placeholder: "Uhrzeit wählen",
    hour: "Stunde",
    minute: "Minute",
    now: "Jetzt",
    clear: "Löschen",
    panelLabel: "Uhrzeit wählen",
    openPicker: "Zeitauswahl öffnen",
  },
}
 
<TimePicker i18n={DE} />

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.

const ARABIC_DIGITS = "٠١٢٣٤٥٦٧٨٩"
 
const AR: TimePickerI18nOverrides = {
  labels: { hour: "الساعة", minute: "الدقيقة", period: "ص/م", am: "ص", pm: "م" },
  functions: {
    formatSegment: (value) =>
      String(value)
        .padStart(2, "0")
        .replace(/\d/g, (digit) => ARABIC_DIGITS[Number(digit)]),
  },
}
 
<div dir="rtl" lang="ar">
  <TimePicker hourCycle={12} i18n={AR}>
    <TimePickerTrigger />
    <TimePickerContent dir="rtl" />
  </TimePicker>
</div>

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.

PropTypeDefaultDescription
valuestring | null-Controlled value, HH:mm or HH:mm:ss in 24-hour time. null is empty.
defaultValuestring | nullnullInitial 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.
openboolean-Controlled popover state.
defaultOpenbooleanfalseInitial popover state when uncontrolled.
onOpenChange(open: boolean) => void-Fires when the popover opens or closes.
hourCycle12 | 242412 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.
hourStepnumber1Hours offered, counted from 0 on the 24-hour scale.
minuteStepnumber1Minutes offered, counted from 0.
secondStepnumber1Seconds offered, counted from 0.
minstring-Earliest selectable time, inclusive. After max, the window runs overnight.
maxstring-Latest selectable time, inclusive.
isTimeDisabled(value: string, time: TimePickerTime) => boolean-Marks single times unavailable. value is in the value format.
requireConfirmbooleanfalsePicks, Now and Clear stay a draft until OK. Closing discards them.
fadebooleantrueFades the top and bottom of a column while more rows are scrolled out of view there.
disabledbooleanfalseDisables every part and stops the popover opening. A disabled picker submits nothing.
readOnlybooleanfalseThe popover opens and the columns take focus, but no pick, key, Now or Clear changes the value.
invalidbooleanfalseSets aria-invalid on the trigger or the input, and data-invalid on the trigger.
namestring-Submits the value with a native form under this name.
formstring-Associates the field with a form elsewhere in the page.
requiredbooleanfalseFails native form validation while empty.
inputRefRef<HTMLInputElement>-The hidden form field, for form libraries that focus a field on error.
idstringgeneratedLands on the trigger or the input, so a label's htmlFor reaches it.
aria-labelstring-Names the trigger or the input, the popover and an inline panel.
aria-labelledbystring-The same, by id. The trigger keeps its value in the name.
aria-describedbystring-Describes the trigger or the input.
i18nTimePickerI18nOverridesEnglishLabels and formatting, merged over the defaults.
placeholderstringlabels.placeholderText of the default trigger while empty.
classNamestring-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.

const BOOKED = new Set(["10:00", "14:30"])
 
const isTimeDisabled = (value: string) =>
  (value >= "12:00" && value < "13:00") || BOOKED.has(value)
 
<TimePicker
  minuteStep={30}
  min="09:00"
  max="17:00"
  isTimeDisabled={isTimeDisabled}
/>

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.

PropTypeDefaultDescription
variantButton variant"outline"The look of the button.
placeholderReactNodelabels.placeholderShown while there is no value.
childrenReactNodevalue and iconReplaces 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.

PropTypeDefaultDescription
placeholderReactNodelabels.placeholderShown 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.

PropTypeDefaultDescription
placeholderstringa mask--:--, --:--:-- with seconds, and --:-- -- or -- --:-- under a 12-hour clock.
classNamestring-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.

PropTypeDefaultDescription
align"start" | "center" | "end""start"Alignment against the trigger.
childrenReactNodecolumns and footerReplaces 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.

PropTypeDefaultDescription
childrenReactNodecolumns and footerReplaces 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.

PropTypeDefaultDescription
childrenReactNodea column for each unitTimePickerColumns, 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.

PropTypeDefaultDescription
type"hour" | "minute" | "second" | "period"-Required. Renders nothing for a unit the granularity or the hour cycle leaves out.
labelReactNodelabels[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.

PropTypeDefaultDescription
childrenReactNodeNow, Clear and OKReplaces 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.

PropTypeDefaultDescription
variantButton variant"ghost"; OK is "default" under requireConfirm, else "secondary"The look of the button.
sizeButton size"sm"The size of the button.
childrenReactNodelabels.now, labels.clear, labels.confirmThe 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.

FieldTypeDescription
valuestring | nullThe committed value in the value format.
timeTimePickerTime | nullThe same value as numbers.
openbooleanWhether the popover is open.
setOpen(open: boolean) => voidOpens or closes the popover.
setValue(value: string | null) => voidCommits a value as given, with no snapping and no draft. A string parseTimeValue cannot read clears.
now() => voidThe same as TimePickerNow.
clear() => voidEmpties the value, or the draft under requireConfirm.
confirm() => voidCommits a pending draft and closes the popover.
function NineAm() {
  const { setValue } = useTimePicker()
  return (
    <Button variant="ghost" size="sm" onClick={() => setValue("09:00")}>
      9:00
    </Button>
  )
}

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.

HelperReturns
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_I18NThe English labels and functions.

parseTimeInput takes hourCycle, the am and pm labels, and period, the half of the day a 12-hour time typed without one takes ("am" by default).

parseTimeValue("09:30") // { hour: 9, minute: 30, second: 0 }
formatTimeValue({ hour: 14, minute: 5, second: 0 }) // "14:05"
parseTimeInput("2:30 pm") // { hour: 14, minute: 30, second: 0 }

Forms

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.

<form onSubmit={handleSubmit}>
  <FieldLabel id="delivery-label" htmlFor="delivery">
    Delivery time
  </FieldLabel>
  <TimePicker
    id="delivery"
    aria-labelledby="delivery-label"
    name="deliveryTime"
    required
  />
</form>

Data Attributes

AttributeOnValues
data-placeholdertrigger, valuepresent while there is no value
data-invalidtriggerpresent when invalid
data-disabledTimePickerInput, panelpresent when disabled
data-typecolumnhour, minute, second, period
data-emptycolumn listpresent when the list has no selectable row
data-fadecolumn listpresent while fade is on
data-overflow-start, data-overflow-endcolumn listpresent while rows are scrolled past the top or the bottom
data-highlightedoptionpresent on the row the keyboard is on
aria-selectedoptiontrue on the row of the shown value, else false
aria-disabledoptiontrue on a row that leads to no selectable time
Partdata-slot
TimePickerTriggertime-picker-trigger
TimePickerValuetime-picker-value
TimePickerInputtime-picker-input, and time-picker-input-trigger on the clock button
TimePickerContentpopover-content, the shadcn popover's own
TimePickerPaneltime-picker-panel
TimePickerColumnstime-picker-columns
TimePickerColumntime-picker-column, then time-picker-column-label, time-picker-column-list and time-picker-option inside
TimePickerFootertime-picker-footer
Now, Clear, Confirmtime-picker-now, time-picker-clear, time-picker-confirm
The hidden form fieldtime-picker-field

Keyboard

On a column:

KeyAction
↓ ↑Selects the next or previous available row, without wrapping. When empty, it selects the highlighted row.
Home EndSelects the first or last available row.
Page Down Page UpMoves 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 9Selects the row typed. 2 digits within a second read as one number, so 1 4 is 14.
A PIn the AM/PM column, selects AM or PM, as does the first letter of a label. Pressing it again moves to the next match.
SpaceSelects the highlighted row.
EnterSelects the highlighted row, commits a draft and closes the popover.
Alt + ↑In the popover, commits a draft and closes it.
EscCloses 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:

KeyAction
EnterReads 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.
EscWhile editing, puts the value back.

On the trigger and in the popover:

KeyAction
Enter SpaceOn the trigger, opens the popover.
Tab Shift + TabMoves 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 follow the Radix UI implementation with accessible primitives from the Radix stack and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.

Browse all 13 Shadcn Time Picker components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.

StepperTimeline

On This Page

InstallationUsageExamplesPopover pickerTypable time input12-hour clock with secondsHour onlySteps and opening hoursUnavailable slotsStart and end timeTime zone labelConfirm before applyingLocalized labelsForm field with validationDate and timeAPI ReferenceValuei18nTimePickerTimePickerTriggerTimePickerValueTimePickerInputTimePickerContentTimePickerPanelTimePickerColumnsTimePickerColumnTimePickerFooterTimePickerNow, TimePickerClear, TimePickerConfirmuseTimePickerHelpersFormsData AttributesKeyboardAccessibility

Application

  • App Shell
  • Auth
  • Card
  • ChartAdded 17 Chart blocks for cron jobs, deploys, firewall traffic and SLOs
  • Dashboard
  • Dialog
  • Empty State
  • Event Calendar
  • Flow
  • Form
  • Gantt
  • Kanban Board
  • List
  • Navbar
  • Onboarding
  • Profile
  • Rich Text EditorNew Rich Text Editor category with 5 Tiptap editor blocks
  • Schedule
  • Settings
  • SheetAdded 13 Sheet blocks for cron jobs, deploys, firewall rules, SLOs and data
  • Stats
  • Timeline
  • WhiteboardNew Whiteboard category with 2 Excalidraw board blocks
  • Wizard

Solutions

  • Dev OpsNew Dev Ops category with 6 platform console blocks
  • AI Ops
  • CRMAdded 2 CRM blocks: a forecast call workspace and a deal desk
  • Agents
  • Analytics
  • Billing
  • Bookings
  • Files
  • Inventory
  • Users

AI & Agents

  • AI ChatAdded a corner chat popup block
  • Agent Activity

Templates

  • E-commerce
  • SaaS
  • Dashboard
  • Landing
  • All templates

eCommerce

  • Category Card
  • Checkout
  • Comparison
  • Coupon
  • Filter Sidebar
  • Product Card
  • Product Detail
  • Product Grid
  • Receipt
  • Review
  • Shopping Cart
  • Wishlist
  • Shop Hero

Data Grid

  • Base
  • Columns
  • Drag & Drop
  • EditingAdded a spreadsheet reorder plan Data Grid block
  • Expansion
  • Filtering
  • Grouping
  • Virtualization

Marketing

  • Blog
  • Compare
  • Contact
  • CTA
  • FAQ
  • Hero
  • How It Works
  • PricingAdded 7 Pricing blocks: plan cards, comparison tables and upgrade dialogs

Resources

  • Components
  • Blocks
  • Icons
  • MCP for Agents
  • Docs
  • Support
  • Pricing
  • Tailwind Plus Alternative
  • Roadmap(has updates coming soon)
  • AffiliateSoon

Legal

  • Privacy Policy
  • Terms & Conditions
  • License
  • Refunds
  • Cookies

© 2026 ReUI. All rights reserved.

3.6K