Time Picker
Hour, minute and optional second scroll columns with 12/24h modes, step intervals and keyboard nav.
Example
Installation
npx shadcn@latest add https://hirael.com/r/time-picker.jsonUsage
import { TimePicker, TimePickerTrigger, TimePickerContent } from "@/components/time-picker"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<TimePicker />
| Prop | Type | Default |
|---|---|---|
value | TimeValue | null | none |
defaultValue | TimeValue | none |
onValueChangeReports every change, including `null` when the value is cleared. | ((v: TimeValue | null) => void) | none |
clearable | boolean | false |
format | TimeFormat | '24h' |
meridiemLabelsLabels for the 12-hour clock halves, in the trigger and the AM/PM switch. | MeridiemLabels | DEFAULT_MERIDIEM_LABELS |
showSeconds | boolean | false |
minuteStep | number | 1 |
secondStep | number | 1 |
disabled | boolean | none |
open | boolean | none |
defaultOpen | boolean | false |
onOpenChange | ((open: boolean) => void) | none |
children | React.ReactNode | none |
<TimePickerTrigger />
+ native element props| Prop | Type | Default |
|---|---|---|
placeholder | string | 'Pick a time' |
children | React.ReactNode | none |
<TimePickerContent />
+ native element props| Prop | Type | Default |
|---|---|---|
labelsOverride the column names and the clear button text. | Partial<{ hour: string; minute: string; second: string; clear: string; }> | none |
Component source
'use client';
import * as React from 'react';
import { Clock, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/registry/hirael/bases/radix/ui/popover';
import { Tabs, TabsList, TabsTrigger } from '@/registry/hirael/bases/radix/ui/tabs';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';
export interface TimeValue {
hour: number;
minute: number;
second?: number;
}
export type TimeFormat = '12h' | '24h';
export interface MeridiemLabels {
am: string;
pm: string;
}
const DEFAULT_MERIDIEM_LABELS: MeridiemLabels = { am: 'AM', pm: 'PM' };
interface TimePickerContextValue {
value: TimeValue | null;
setValue: (v: TimeValue) => void;
clearValue: () => void;
clearable: boolean;
format: TimeFormat;
meridiemLabels: MeridiemLabels;
showSeconds: boolean;
minuteStep: number;
secondStep: number;
open: boolean;
setOpen: (open: boolean) => void;
disabled?: boolean;
}
const TimePickerContext = React.createContext<TimePickerContextValue | null>(null);
const useTimePicker = () => {
const ctx = React.useContext(TimePickerContext);
if (!ctx) {
throw new Error('TimePicker compound components must be used inside <TimePicker>');
}
return ctx;
};
const pad2 = (n: number) => n.toString().padStart(2, '0');
const to12Hour = (hour: number) => ((hour + 11) % 12) + 1;
const stepValues = (step: number) => {
const size = Math.max(1, step);
return Array.from({ length: Math.ceil(60 / size) }, (_, i) => i * size);
};
export interface TimePickerProps {
value?: TimeValue | null;
defaultValue?: TimeValue;
/** Reports every change, including `null` when the value is cleared. */
onValueChange?: (v: TimeValue | null) => void;
clearable?: boolean;
format?: TimeFormat;
/** Labels for the 12-hour clock halves, in the trigger and the AM/PM switch. */
meridiemLabels?: MeridiemLabels;
showSeconds?: boolean;
minuteStep?: number;
secondStep?: number;
disabled?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
}
const TimePicker = ({
value: valueProp,
defaultValue,
onValueChange,
clearable = false,
format = '24h',
meridiemLabels = DEFAULT_MERIDIEM_LABELS,
showSeconds = false,
minuteStep = 1,
secondStep = 1,
disabled,
open: openProp,
defaultOpen = false,
onOpenChange,
children,
}: TimePickerProps) => {
const [open, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [value, setValue] = useControllableState<TimeValue | null>({
prop: valueProp,
defaultProp: defaultValue ?? null,
onChange: onValueChange,
});
const clearValue = React.useCallback(() => setValue(null), [setValue]);
const ctx = React.useMemo<TimePickerContextValue>(
() => ({
value,
setValue,
clearValue,
clearable,
format,
meridiemLabels,
showSeconds,
minuteStep,
secondStep,
open,
setOpen,
disabled,
}),
[
value,
setValue,
clearValue,
clearable,
format,
meridiemLabels,
showSeconds,
minuteStep,
secondStep,
open,
setOpen,
disabled,
],
);
return (
<TimePickerContext.Provider value={ctx}>
<Popover open={open} onOpenChange={setOpen}>
{children}
</Popover>
</TimePickerContext.Provider>
);
};
const formatTimeValue = (v: TimeValue, format: TimeFormat, showSeconds: boolean, labels: MeridiemLabels) => {
const tail = showSeconds ? `:${pad2(v.second ?? 0)}` : '';
if (format === '24h') {
return `${pad2(v.hour)}:${pad2(v.minute)}${tail}`;
}
return `${pad2(to12Hour(v.hour))}:${pad2(v.minute)}${tail} ${v.hour >= 12 ? labels.pm : labels.am}`;
};
const TimePickerTrigger = ({
placeholder = 'Pick a time',
className,
children,
...props
}: Omit<React.ComponentProps<'button'>, 'children'> & {
placeholder?: string;
children?: React.ReactNode;
}) => {
const ctx = useTimePicker();
const label = ctx.value ? formatTimeValue(ctx.value, ctx.format, ctx.showSeconds, ctx.meridiemLabels) : null;
return (
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={ctx.disabled}
data-slot="time-picker-trigger"
data-empty={!ctx.value || undefined}
className={cn('w-full justify-start font-normal tabular-nums data-empty:text-muted-foreground', className)}
{...props}
>
<Clock className="text-muted-foreground" />
<span data-slot="time-picker-trigger-label" className="flex-1 truncate text-start">
{children ?? label ?? placeholder}
</span>
</Button>
</PopoverTrigger>
);
};
interface ScrollColumnProps {
values: number[];
selected?: number;
onSelect: (n: number) => void;
ariaLabel: string;
}
const ScrollColumn = ({ values, selected, onSelect, ariaLabel }: ScrollColumnProps) => {
const listRef = React.useRef<HTMLDivElement>(null);
const displayValues = React.useMemo(() => {
if (selected === undefined || values.includes(selected)) return values;
return [...values, selected].sort((a, b) => a - b);
}, [values, selected]);
const tabbableValue = selected ?? displayValues[0];
// Scroll only this column; scrollIntoView would also scroll the page and any scrollable ancestor.
React.useEffect(() => {
const list = listRef.current;
if (selected === undefined || !list) return;
const el = list.querySelector<HTMLButtonElement>(`[data-val="${selected}"]`);
if (!el) return;
list.scrollTo({ top: el.offsetTop - (list.clientHeight - el.offsetHeight) / 2, behavior: 'instant' });
}, [selected]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
const last = displayValues.length - 1;
const nextIndex = (
{ ArrowDown: Math.min(last, index + 1), ArrowUp: Math.max(0, index - 1), Home: 0, End: last } as Record<
string,
number
>
)[e.key];
if (nextIndex === undefined) return;
e.preventDefault();
const n = displayValues[nextIndex];
onSelect(n);
listRef.current?.querySelector<HTMLButtonElement>(`[data-val="${n}"]`)?.focus();
};
return (
<div
ref={listRef}
role="listbox"
aria-label={ariaLabel}
data-slot="time-picker-column"
className="relative h-40 w-14 snap-y snap-mandatory [scrollbar-width:none] overflow-y-auto scroll-smooth rounded-sm border border-input bg-card [&::-webkit-scrollbar]:hidden"
>
<div className="flex flex-col items-stretch py-16">
{displayValues.map((n, index) => {
const active = n === selected;
return (
<button
key={n}
type="button"
role="option"
aria-selected={active}
data-val={n}
data-slot="time-picker-option"
tabIndex={n === tabbableValue ? 0 : -1}
onClick={() => onSelect(n)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={cn(
'h-8 snap-center text-center text-sm tabular-nums transition-colors outline-none',
'hover:bg-accent focus-visible:bg-accent',
active ? 'font-semibold text-foreground' : 'text-muted-foreground',
)}
>
{pad2(n)}
</button>
);
})}
</div>
<div
aria-hidden
data-slot="time-picker-column-highlight"
className="pointer-events-none absolute inset-x-0 top-1/2 h-8 -translate-y-1/2 border-y border-primary/40 bg-primary/5"
/>
</div>
);
};
const DEFAULT_LABELS = { hour: 'Hour', minute: 'Minute', second: 'Second', clear: 'Clear' };
interface TimePickerContentProps extends React.ComponentProps<typeof PopoverContent> {
/** Override the column names and the clear button text. */
labels?: Partial<typeof DEFAULT_LABELS>;
}
const TimePickerContent = ({ labels, className, ...props }: TimePickerContentProps) => {
const text = { ...DEFAULT_LABELS, ...labels };
const ctx = useTimePicker();
const isAM = (ctx.value?.hour ?? 0) < 12;
const twelve = ctx.format === '12h';
const baseValue: TimeValue = ctx.value ?? { hour: 0, minute: 0, second: ctx.showSeconds ? 0 : undefined };
const hourValues = React.useMemo(
() => Array.from({ length: twelve ? 12 : 24 }, (_, i) => (twelve ? i + 1 : i)),
[twelve],
);
const minuteValues = React.useMemo(() => stepValues(ctx.minuteStep), [ctx.minuteStep]);
const secondValues = React.useMemo(() => stepValues(ctx.secondStep), [ctx.secondStep]);
const displayHour = ctx.value ? (twelve ? to12Hour(ctx.value.hour) : ctx.value.hour) : undefined;
const setHour = (h: number) => {
const hour = twelve ? (h % 12) + (isAM ? 0 : 12) : h;
ctx.setValue({ ...baseValue, hour });
};
const setMeridiem = (next: 'AM' | 'PM') => {
if ((next === 'AM') === isAM) return;
ctx.setValue({ ...baseValue, hour: (baseValue.hour % 12) + (next === 'AM' ? 0 : 12) });
};
return (
<PopoverContent align="start" data-slot="time-picker-content" className={cn('w-auto p-3', className)} {...props}>
<div data-slot="time-picker-columns" className="flex items-stretch gap-2">
<ScrollColumn values={hourValues} selected={displayHour} onSelect={setHour} ariaLabel={text.hour} />
<span aria-hidden className="flex items-center text-sm text-muted-foreground">
:
</span>
<ScrollColumn
values={minuteValues}
selected={ctx.value?.minute}
onSelect={(minute) => ctx.setValue({ ...baseValue, minute })}
ariaLabel={text.minute}
/>
{ctx.showSeconds && (
<>
<span aria-hidden className="flex items-center text-sm text-muted-foreground">
:
</span>
<ScrollColumn
values={secondValues}
selected={ctx.value ? (ctx.value.second ?? 0) : undefined}
onSelect={(second) => ctx.setValue({ ...baseValue, second })}
ariaLabel={text.second}
/>
</>
)}
</div>
{twelve && (
<Tabs value={isAM ? 'AM' : 'PM'} onValueChange={(v) => setMeridiem(v as 'AM' | 'PM')} className="mt-3">
<TabsList className="w-full">
<TabsTrigger value="AM" className="text-xs uppercase">
{ctx.meridiemLabels.am}
</TabsTrigger>
<TabsTrigger value="PM" className="text-xs uppercase">
{ctx.meridiemLabels.pm}
</TabsTrigger>
</TabsList>
</Tabs>
)}
{ctx.clearable && ctx.value && (
<div data-slot="time-picker-content-footer" className="mt-3 flex justify-end">
<Button
type="button"
variant="ghost"
size="xs"
data-slot="time-picker-clear"
onClick={ctx.clearValue}
className="text-xs font-normal uppercase"
>
<X className="size-3" />
{text.clear}
</Button>
</div>
)}
</PopoverContent>
);
};
export { TimePicker, TimePickerTrigger, TimePickerContent };
Dependencies
shadcn registry
buttonpopovertabsuse-controllable-state
npm
lucide-react