Skip to content
38

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.json

Usage

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 />

PropTypeDefault
valueTimeValue | nullnone
defaultValueTimeValuenone
onValueChange

Reports every change, including `null` when the value is cleared.

((v: TimeValue | null) => void)none
clearablebooleanfalse
formatTimeFormat'24h'
meridiemLabels

Labels for the 12-hour clock halves, in the trigger and the AM/PM switch.

MeridiemLabelsDEFAULT_MERIDIEM_LABELS
showSecondsbooleanfalse
minuteStepnumber1
secondStepnumber1
disabledbooleannone
openbooleannone
defaultOpenbooleanfalse
onOpenChange((open: boolean) => void)none
childrenReact.ReactNodenone

<TimePickerTrigger />

+ native element props
PropTypeDefault
placeholderstring'Pick a time'
childrenReact.ReactNodenone

<TimePickerContent />

+ native element props
PropTypeDefault
labels

Override 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