Time Picker

Popover time selector built from hour and minute columns.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/time-picker.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client"; import * as React from "react"; import { Clock } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "../button/button"; import { Popover, PopoverContent, PopoverTrigger } from "../popover"; function pad(value: number): string { return value.toString().padStart(2, "0"); } function buildOptions(count: number, step: number): string[] { return Array.from({ length: Math.ceil(count / step) }, (_unused, index) => pad(index * step), ); } function splitTime(value: string) { const [hour = "", minute = ""] = value.split(":"); return { hour, minute }; } type TimeColumnProps = { label: string; onSelect: (value: string) => void; options: string[]; selected: string; }; function TimeColumn({ label, onSelect, options, selected }: TimeColumnProps) { return ( <div aria-label={label} className="flex max-h-56 flex-col gap-1 overflow-y-auto px-1" role="listbox" > {options.map((option) => ( <button aria-selected={option === selected} className={cn( "rounded-sm px-3 py-1.5 text-sm tabular-nums outline-none transition-colors hover:bg-accent focus-visible:bg-accent", option === selected && "bg-primary text-primary-foreground hover:bg-primary", )} key={option} onClick={() => { onSelect(option); }} role="option" type="button" > {option} </button> ))} </div> ); } /** Popover-based time selector built from hour and minute columns. */ export type TimePickerProps = { className?: string; defaultValue?: string; minuteStep?: number; onValueChange?: (value: string) => void; placeholder?: string; value?: string; }; const TimePicker = ({ className, defaultValue = "", minuteStep = 5, onValueChange, placeholder = "Select time", ref, value, }: TimePickerProps & { ref?: React.Ref<HTMLButtonElement> }) => { const [internalValue, setInternalValue] = React.useState(defaultValue); const currentValue = value ?? internalValue; const { hour, minute } = splitTime(currentValue); const commit = (nextHour: string, nextMinute: string) => { const next = `${nextHour || "00"}:${nextMinute || "00"}`; if (value === undefined) { setInternalValue(next); } onValueChange?.(next); }; return ( <Popover> <PopoverTrigger asChild> <Button className={cn( "w-full justify-start text-left font-normal", !currentValue && "text-muted-foreground", className, )} ref={ref} variant="outline" > <Clock className="mr-2 size-4" /> {currentValue || placeholder} </Button> </PopoverTrigger> <PopoverContent align="start" className="w-auto p-2"> <div className="flex gap-2"> <TimeColumn label="Hour" onSelect={(nextHour) => { commit(nextHour, minute); }} options={buildOptions(24, 1)} selected={hour} /> <TimeColumn label="Minute" onSelect={(nextMinute) => { commit(hour, nextMinute); }} options={buildOptions(60, minuteStep)} selected={minute} /> </div> </PopoverContent> </Popover> ); }; TimePicker.displayName = "TimePicker"; export { TimePicker };

Dependencies

  • @vllnt/ui@^0.2.1