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

API

<TimePicker />

PropTypeDefault
valueTimeValue | null
defaultValueTimeValue
onValueChange((v: TimeValue) => void)
onClear(() => void)
clearablebooleanfalse
formatTimeFormat"24h"
showSecondsbooleanfalse
minuteStepnumber1
secondStepnumber1
disabledboolean
openboolean
defaultOpenbooleanfalse
onOpenChange((open: boolean) => void)
childrenReact.ReactNode

<TimePickerTrigger />

+ native element props
PropTypeDefault
placeholderstring"Pick a time"
childrenReact.ReactNode

<TimePickerContent />

+ native element props

No props of its own — forwards everything to the underlying element.

Component source

"use client";

import * as React from "react";
import { Clock, X } from "lucide-react";

import { cn } from "@/lib/utils";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/registry/hirael/ui/popover";
import { Tabs, TabsList, TabsTrigger } from "@/registry/hirael/ui/tabs";

export type TimeValue = {
  hour: number;
  minute: number;
  second?: number;
};

export type TimeFormat = "12h" | "24h";

type TimePickerContextValue = {
  value: TimeValue | null;
  setValue: (v: TimeValue) => void;
  clearValue: () => void;
  clearable: boolean;
  format: TimeFormat;
  showSeconds: boolean;
  minuteStep: number;
  secondStep: number;
  open: boolean;
  setOpen: (open: boolean) => void;
  disabled?: boolean;
};

const TimePickerContext = React.createContext<TimePickerContextValue | null>(
  null,
);

function useTimePicker() {
  const ctx = React.useContext(TimePickerContext);
  if (!ctx) {
    throw new Error(
      "TimePicker compound components must be used inside <TimePicker>",
    );
  }
  return ctx;
}

function pad2(n: number) {
  return n.toString().padStart(2, "0");
}

function clampStep(value: number, step: number, max: number) {
  const snapped = Math.round(value / step) * step;
  return Math.max(0, Math.min(max, snapped));
}

export type TimePickerProps = {
  value?: TimeValue | null;
  defaultValue?: TimeValue;
  onValueChange?: (v: TimeValue) => void;
  onClear?: () => void;
  clearable?: boolean;
  format?: TimeFormat;
  showSeconds?: boolean;
  minuteStep?: number;
  secondStep?: number;
  disabled?: boolean;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  children?: React.ReactNode;
};

function TimePicker({
  value: valueProp,
  defaultValue,
  onValueChange,
  onClear,
  clearable = false,
  format = "24h",
  showSeconds = false,
  minuteStep = 1,
  secondStep = 1,
  disabled,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  children,
}: TimePickerProps) {
  const [openInternal, setOpenInternal] = React.useState(defaultOpen);
  const open = openProp ?? openInternal;
  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setOpenInternal(next);
      onOpenChange?.(next);
    },
    [openProp, onOpenChange],
  );

  const [internal, setInternal] = React.useState<TimeValue | null>(
    defaultValue ?? null,
  );
  const value = valueProp !== undefined ? valueProp : internal;

  const setValue = React.useCallback(
    (next: TimeValue) => {
      if (valueProp === undefined) setInternal(next);
      onValueChange?.(next);
    },
    [valueProp, onValueChange],
  );

  const clearValue = React.useCallback(() => {
    if (valueProp === undefined) setInternal(null);
    onClear?.();
  }, [valueProp, onClear]);

  const ctx = React.useMemo<TimePickerContextValue>(
    () => ({
      value,
      setValue,
      clearValue,
      clearable,
      format,
      showSeconds,
      minuteStep,
      secondStep,
      open,
      setOpen,
      disabled,
    }),
    [
      value,
      setValue,
      clearValue,
      clearable,
      format,
      showSeconds,
      minuteStep,
      secondStep,
      open,
      setOpen,
      disabled,
    ],
  );

  return (
    <TimePickerContext.Provider value={ctx}>
      <Popover open={open} onOpenChange={setOpen}>
        {children}
      </Popover>
    </TimePickerContext.Provider>
  );
}

function formatTimeValue(
  v: TimeValue,
  format: TimeFormat,
  showSeconds: boolean,
) {
  const tail = showSeconds ? `:${pad2(v.second ?? 0)}` : "";
  if (format === "24h") {
    return `${pad2(v.hour)}:${pad2(v.minute)}${tail}`;
  }
  const meridiem = v.hour >= 12 ? "PM" : "AM";
  const h12 = ((v.hour + 11) % 12) + 1;
  return `${pad2(h12)}:${pad2(v.minute)}${tail} ${meridiem}`;
}

function 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)
    : null;
  return (
    <PopoverTrigger asChild>
      <button
        type="button"
        disabled={ctx.disabled}
        data-slot="time-picker-trigger"
        data-state={ctx.open ? "open" : "closed"}
        className={cn(
          "inline-flex h-9 w-full items-center gap-2 rounded-sm border border-input bg-transparent px-3 text-start text-sm font-mono tabular-nums outline-none transition-colors",
          "hover:border-ring/60 focus-visible:border-ring data-[state=open]:border-ring",
          !ctx.value && "text-muted-foreground font-sans",
          "disabled:cursor-not-allowed disabled:opacity-50",
          className,
        )}
        {...props}
      >
        <Clock className="size-3.5 shrink-0 text-muted-foreground" />
        <span className="flex-1 truncate">
          {children ?? label ?? placeholder}
        </span>
      </button>
    </PopoverTrigger>
  );
}

function ScrollColumn({
  values,
  selected,
  onSelect,
  ariaLabel,
}: {
  values: number[];
  selected?: number;
  onSelect: (n: number) => void;
  ariaLabel: string;
}) {
  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];

  React.useEffect(() => {
    if (selected === undefined) return;
    const el = listRef.current?.querySelector<HTMLButtonElement>(
      `[data-val="${selected}"]`,
    );
    el?.scrollIntoView({ block: "center", behavior: "instant" });
  }, [selected]);

  const handleKeyDown = (
    e: React.KeyboardEvent<HTMLButtonElement>,
    index: number,
  ) => {
    let nextIndex: number;
    switch (e.key) {
      case "ArrowDown":
        nextIndex = Math.min(displayValues.length - 1, index + 1);
        break;
      case "ArrowUp":
        nextIndex = Math.max(0, index - 1);
        break;
      case "Home":
        nextIndex = 0;
        break;
      case "End":
        nextIndex = displayValues.length - 1;
        break;
      default:
        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 overflow-y-auto scroll-smooth rounded-sm border border-input bg-card snap-y snap-mandatory [scrollbar-width:none] [&::-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 font-mono text-sm tabular-nums outline-none transition-colors",
                "hover:bg-accent",
                "focus-visible:bg-accent",
                active
                  ? "text-foreground font-semibold"
                  : "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>
  );
}

function TimePickerContent({
  className,
  ...props
}: React.ComponentProps<typeof PopoverContent>) {
  const ctx = useTimePicker();
  const isAM = (ctx.value?.hour ?? 0) < 12;
  const baseValue: TimeValue = ctx.value ?? {
    hour: 0,
    minute: 0,
    second: ctx.showSeconds ? 0 : undefined,
  };

  const hourValues = React.useMemo(() => {
    if (ctx.format === "24h") {
      return Array.from({ length: 24 }, (_, i) => i);
    }
    return Array.from({ length: 12 }, (_, i) => i + 1);
  }, [ctx.format]);

  const minuteValues = React.useMemo(() => {
    const step = Math.max(1, ctx.minuteStep);
    const count = Math.ceil(60 / step);
    return Array.from({ length: count }, (_, i) =>
      clampStep(i * step, step, 59),
    );
  }, [ctx.minuteStep]);

  const secondValues = React.useMemo(() => {
    const step = Math.max(1, ctx.secondStep);
    const count = Math.ceil(60 / step);
    return Array.from({ length: count }, (_, i) =>
      clampStep(i * step, step, 59),
    );
  }, [ctx.secondStep]);

  const displayHour = ctx.value
    ? ctx.format === "24h"
      ? ctx.value.hour
      : ((ctx.value.hour + 11) % 12) + 1
    : undefined;

  const setHour = (h: number) => {
    let nextHour: number;
    if (ctx.format === "24h") {
      nextHour = h;
    } else {
      const base = h === 12 ? 0 : h;
      nextHour = isAM ? base : base + 12;
    }
    ctx.setValue({ ...baseValue, hour: nextHour });
  };

  const setMinute = (m: number) => {
    ctx.setValue({ ...baseValue, minute: m });
  };

  const setSecond = (s: number) => {
    ctx.setValue({ ...baseValue, second: s });
  };

  const setMeridiem = (next: "AM" | "PM") => {
    if ((next === "AM" && isAM) || (next === "PM" && !isAM)) return;
    const base = baseValue.hour % 12;
    ctx.setValue({ ...baseValue, hour: next === "AM" ? base : base + 12 });
  };

  return (
    <PopoverContent
      align="start"
      data-slot="time-picker-content"
      className={cn("w-auto p-3", className)}
      {...props}
    >
      <div className="flex items-stretch gap-2">
        <ScrollColumn
          values={hourValues}
          selected={displayHour}
          onSelect={setHour}
          ariaLabel="Hour"
        />
        <span
          aria-hidden
          className="flex items-center font-mono text-sm text-muted-foreground"
        >
          :
        </span>
        <ScrollColumn
          values={minuteValues}
          selected={ctx.value?.minute}
          onSelect={setMinute}
          ariaLabel="Minute"
        />
        {ctx.showSeconds && (
          <>
            <span
              aria-hidden
              className="flex items-center font-mono text-sm text-muted-foreground"
            >
              :
            </span>
            <ScrollColumn
              values={secondValues}
              selected={ctx.value ? (ctx.value.second ?? 0) : undefined}
              onSelect={setSecond}
              ariaLabel="Second"
            />
          </>
        )}
      </div>
      {ctx.format === "12h" && (
        <Tabs
          value={isAM ? "AM" : "PM"}
          onValueChange={(v) => setMeridiem(v as "AM" | "PM")}
          className="mt-3"
        >
          <TabsList className="w-full">
            <TabsTrigger
              value="AM"
              className="font-mono text-xs uppercase tracking-[0.1em]"
            >
              AM
            </TabsTrigger>
            <TabsTrigger
              value="PM"
              className="font-mono text-xs uppercase tracking-[0.1em]"
            >
              PM
            </TabsTrigger>
          </TabsList>
        </Tabs>
      )}
      {ctx.clearable && ctx.value && (
        <div className="mt-3 flex justify-end">
          <button
            type="button"
            data-slot="time-picker-clear"
            onClick={() => ctx.clearValue()}
            className="inline-flex h-7 items-center gap-1 rounded-sm px-2 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
          >
            <X className="size-3" />
            Clear
          </button>
        </div>
      )}
    </PopoverContent>
  );
}

export { TimePicker, TimePickerTrigger, TimePickerContent };

Dependencies

shadcn registry

popovertabs

npm

lucide-react