Dice UI
Components

Time Picker

An accessible time picker component with inline editing and dropdown selection. Automatically adapts to 12-hour or 24-hour format based on user locale.

API
import {
  TimePicker,
  TimePickerContent,
  TimePickerHour,
  TimePickerInput,
  TimePickerInputGroup,
  TimePickerLabel,
  TimePickerMinute,
  TimePickerPeriod,
  TimePickerSeparator,
  TimePickerTrigger,
} from "@/components/ui/time-picker";
 
export function TimePickerDemo() {
  return (
    <TimePicker className="w-[280px]">
      <TimePickerLabel>Select Time</TimePickerLabel>
      <TimePickerInputGroup>
        <TimePickerInput segment="hour" />
        <TimePickerSeparator />
        <TimePickerInput segment="minute" />
        <TimePickerInput segment="period" />
        <TimePickerTrigger />
      </TimePickerInputGroup>
      <TimePickerContent>
        <TimePickerHour />
        <TimePickerMinute />
        <TimePickerPeriod />
      </TimePickerContent>
    </TimePicker>
  );
}

Installation

CLI

npx shadcn@latest add "https://diceui.com/r/time-picker"

Manual

Install the following dependencies:

npm install @radix-ui/react-slot

Copy the refs composition utilities into your lib/compose-refs.ts file.

/**
 * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
 */
 
import * as React from "react";
 
type PossibleRef<T> = React.Ref<T> | undefined;
 
/**
 * Set a given ref to a given value
 * This utility takes care of different types of refs: callback refs and RefObject(s)
 */
function setRef<T>(ref: PossibleRef<T>, value: T) {
  if (typeof ref === "function") {
    return ref(value);
  }
 
  if (ref !== null && ref !== undefined) {
    ref.current = value;
  }
}
 
/**
 * A utility to compose multiple refs together
 * Accepts callback refs and RefObject(s)
 */
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  return (node) => {
    let hasCleanup = false;
    const cleanups = refs.map((ref) => {
      const cleanup = setRef(ref, node);
      if (!hasCleanup && typeof cleanup === "function") {
        hasCleanup = true;
      }
      return cleanup;
    });
 
    // React <19 will log an error to the console if a callback ref returns a
    // value. We don't use ref cleanups internally so this will only happen if a
    // user's ref callback returns a value, which we only expect if they are
    // using the cleanup functionality added in React 19.
    if (hasCleanup) {
      return () => {
        for (let i = 0; i < cleanups.length; i++) {
          const cleanup = cleanups[i];
          if (typeof cleanup === "function") {
            cleanup();
          } else {
            setRef(refs[i], null);
          }
        }
      };
    }
  };
}
 
/**
 * A custom hook that composes multiple refs
 * Accepts callback refs and RefObject(s)
 */
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
  return React.useCallback(composeRefs(...refs), refs);
}
 
export { composeRefs, useComposedRefs };

Copy the visually hidden input component into your components/visually-hidden-input.tsx file.

"use client";
 
import * as React from "react";
 
type InputValue = string[] | string;
 
interface VisuallyHiddenInputProps<T = InputValue>
  extends Omit<
    React.InputHTMLAttributes<HTMLInputElement>,
    "value" | "checked" | "onReset"
  > {
  value?: T;
  checked?: boolean;
  control: HTMLElement | null;
  bubbles?: boolean;
}
 
function VisuallyHiddenInput<T = InputValue>(
  props: VisuallyHiddenInputProps<T>,
) {
  const {
    control,
    value,
    checked,
    bubbles = true,
    type = "hidden",
    style,
    ...inputProps
  } = props;
 
  const isCheckInput = React.useMemo(
    () => type === "checkbox" || type === "radio" || type === "switch",
    [type],
  );
  const inputRef = React.useRef<HTMLInputElement>(null);
 
  const prevValueRef = React.useRef<{
    value: T | boolean | undefined;
    previous: T | boolean | undefined;
  }>({
    value: isCheckInput ? checked : value,
    previous: isCheckInput ? checked : value,
  });
 
  const prevValue = React.useMemo(() => {
    const currentValue = isCheckInput ? checked : value;
    if (prevValueRef.current.value !== currentValue) {
      prevValueRef.current.previous = prevValueRef.current.value;
      prevValueRef.current.value = currentValue;
    }
    return prevValueRef.current.previous;
  }, [isCheckInput, value, checked]);
 
  const [controlSize, setControlSize] = React.useState<{
    width?: number;
    height?: number;
  }>({});
 
  React.useLayoutEffect(() => {
    if (!control) {
      setControlSize({});
      return;
    }
 
    setControlSize({
      width: control.offsetWidth,
      height: control.offsetHeight,
    });
 
    if (typeof window === "undefined") return;
 
    const resizeObserver = new ResizeObserver((entries) => {
      if (!Array.isArray(entries) || !entries.length) return;
 
      const entry = entries[0];
      if (!entry) return;
 
      let width: number;
      let height: number;
 
      if ("borderBoxSize" in entry) {
        const borderSizeEntry = entry.borderBoxSize;
        const borderSize = Array.isArray(borderSizeEntry)
          ? borderSizeEntry[0]
          : borderSizeEntry;
        width = borderSize.inlineSize;
        height = borderSize.blockSize;
      } else {
        width = control.offsetWidth;
        height = control.offsetHeight;
      }
 
      setControlSize({ width, height });
    });
 
    resizeObserver.observe(control, { box: "border-box" });
    return () => {
      resizeObserver.disconnect();
    };
  }, [control]);
 
  React.useEffect(() => {
    const input = inputRef.current;
    if (!input) return;
 
    const inputProto = window.HTMLInputElement.prototype;
    const propertyKey = isCheckInput ? "checked" : "value";
    const eventType = isCheckInput ? "click" : "input";
    const currentValue = isCheckInput ? checked : value;
 
    const serializedCurrentValue = isCheckInput
      ? checked
      : typeof value === "object" && value !== null
        ? JSON.stringify(value)
        : value;
 
    const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
 
    const setter = descriptor?.set;
 
    if (prevValue !== currentValue && setter) {
      const event = new Event(eventType, { bubbles });
      setter.call(input, serializedCurrentValue);
      input.dispatchEvent(event);
    }
  }, [prevValue, value, checked, bubbles, isCheckInput]);
 
  const composedStyle = React.useMemo<React.CSSProperties>(() => {
    return {
      ...style,
      ...(controlSize.width !== undefined && controlSize.height !== undefined
        ? controlSize
        : {}),
      border: 0,
      clip: "rect(0 0 0 0)",
      clipPath: "inset(50%)",
      height: "1px",
      margin: "-1px",
      overflow: "hidden",
      padding: 0,
      position: "absolute",
      whiteSpace: "nowrap",
      width: "1px",
    };
  }, [style, controlSize]);
 
  return (
    <input
      type={type}
      {...inputProps}
      ref={inputRef}
      aria-hidden={isCheckInput}
      tabIndex={-1}
      defaultChecked={isCheckInput ? checked : undefined}
      style={composedStyle}
    />
  );
}
 
export { VisuallyHiddenInput };

Copy and paste the following code into your project.

"use client";
 
import { Slot } from "@radix-ui/react-slot";
import { Clock } from "lucide-react";
import * as React from "react";
import {
  Popover,
  PopoverAnchor,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
 
const ROOT_NAME = "TimePicker";
const LABEL_NAME = "TimePickerLabel";
const INPUT_GROUP_NAME = "TimePickerInputGroup";
const INPUT_NAME = "TimePickerInput";
const TRIGGER_NAME = "TimePickerTrigger";
const COLUMN_NAME = "TimePickerColumn";
const COLUMN_ITEM_NAME = "TimePickerColumnItem";
const HOUR_NAME = "TimePickerHour";
const MINUTE_NAME = "TimePickerMinute";
const SECOND_NAME = "TimePickerSecond";
const PERIOD_NAME = "TimePickerPeriod";
const CLEAR_NAME = "TimePickerClear";
 
const DEFAULT_STEP = 1;
const DEFAULT_SEGMENT_PLACEHOLDER = "--";
const DEFAULT_LOCALE = undefined;
const PERIODS = ["AM", "PM"] as const;
 
type Segment = "hour" | "minute" | "second" | "period";
type SegmentFormat = "numeric" | "2-digit";
type Period = (typeof PERIODS)[number];
 
interface DivProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
interface ButtonProps extends React.ComponentProps<"button"> {
  asChild?: boolean;
}
 
type RootElement = React.ComponentRef<typeof TimePickerRoot>;
type InputElement = React.ComponentRef<typeof TimePickerInput>;
type ColumnElement = React.ComponentRef<typeof TimePickerColumn>;
type ColumnItemElement = React.ComponentRef<typeof TimePickerColumnItem>;
 
interface TimeValue {
  hour?: number;
  minute?: number;
  second?: number;
  period?: Period;
}
 
interface ItemData {
  value: number | string;
  ref: React.RefObject<ColumnItemElement | null>;
  selected: boolean;
}
 
interface ColumnData {
  id: string;
  ref: React.RefObject<ColumnElement | null>;
  getSelectedItemRef: () => React.RefObject<ColumnItemElement | null> | null;
  getItems: () => ItemData[];
}
 
const useIsomorphicLayoutEffect =
  typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
 
function useAsRef<T>(props: T) {
  const ref = React.useRef<T>(props);
 
  useIsomorphicLayoutEffect(() => {
    ref.current = props;
  });
 
  return ref;
}
 
function useLazyRef<T>(fn: () => T) {
  const ref = React.useRef<T | null>(null);
 
  if (ref.current === null) {
    ref.current = fn();
  }
 
  return ref as React.RefObject<T>;
}
 
function focusFirst(
  candidates: React.RefObject<ColumnItemElement | null>[],
  preventScroll = false,
) {
  const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;
  for (const candidateRef of candidates) {
    const candidate = candidateRef.current;
    if (!candidate) continue;
    if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;
    candidate.focus({ preventScroll });
    if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;
  }
}
 
function sortNodes<T extends { ref: React.RefObject<Element | null> }>(
  items: T[],
): T[] {
  return items.sort((a, b) => {
    const elementA = a.ref.current;
    const elementB = b.ref.current;
    if (!elementA || !elementB) return 0;
    const position = elementA.compareDocumentPosition(elementB);
    if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
      return -1;
    }
    if (position & Node.DOCUMENT_POSITION_PRECEDING) {
      return 1;
    }
    return 0;
  });
}
 
function getIs12Hour(locale?: string): boolean {
  const testDate = new Date(2000, 0, 1, 13, 0, 0);
  const formatted = new Intl.DateTimeFormat(locale, {
    hour: "numeric",
  }).format(testDate);
 
  return /am|pm/i.test(formatted) || !formatted.includes("13");
}
 
function parseTimeString(timeString: string | undefined): TimeValue | null {
  if (!timeString) return null;
 
  const parts = timeString.split(":");
  if (parts.length < 2) return null;
 
  const result: TimeValue = {};
 
  if (parts[0] && parts[0] !== DEFAULT_SEGMENT_PLACEHOLDER) {
    const hour = Number.parseInt(parts[0], 10);
    if (!Number.isNaN(hour) && hour >= 0 && hour <= 23) {
      result.hour = hour;
    }
  }
 
  if (parts[1] && parts[1] !== DEFAULT_SEGMENT_PLACEHOLDER) {
    const minute = Number.parseInt(parts[1], 10);
    if (!Number.isNaN(minute) && minute >= 0 && minute <= 59) {
      result.minute = minute;
    }
  }
 
  if (parts[2] && parts[2] !== DEFAULT_SEGMENT_PLACEHOLDER) {
    const second = Number.parseInt(parts[2], 10);
    if (!Number.isNaN(second) && second >= 0 && second <= 59) {
      result.second = second;
    }
  }
  if (
    result.hour === undefined &&
    result.minute === undefined &&
    result.second === undefined
  ) {
    return null;
  }
 
  return result;
}
 
function formatTimeValue(value: TimeValue, showSeconds: boolean): string {
  const hourStr =
    value.hour !== undefined
      ? value.hour.toString().padStart(2, "0")
      : DEFAULT_SEGMENT_PLACEHOLDER;
  const minuteStr =
    value.minute !== undefined
      ? value.minute.toString().padStart(2, "0")
      : DEFAULT_SEGMENT_PLACEHOLDER;
  const secondStr =
    value.second !== undefined
      ? value.second.toString().padStart(2, "0")
      : DEFAULT_SEGMENT_PLACEHOLDER;
 
  if (showSeconds) {
    return `${hourStr}:${minuteStr}:${secondStr}`;
  }
  return `${hourStr}:${minuteStr}`;
}
 
function to12Hour(hour24: number): { hour: number; period: Period } {
  const period: Period = hour24 >= 12 ? "PM" : "AM";
  const hour = hour24 % 12 || 12;
  return { hour, period };
}
 
function to24Hour(hour12: number, period: Period): number {
  if (hour12 === 12) {
    return period === "PM" ? 12 : 0;
  }
  return period === "PM" ? hour12 + 12 : hour12;
}
 
function clamp(value: number, min: number, max: number) {
  return Math.min(Math.max(value, min), max);
}
 
interface StoreState {
  value: string;
  open: boolean;
}
 
interface Store {
  subscribe: (callback: () => void) => () => void;
  getState: () => StoreState;
  setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
  notify: () => void;
}
 
const StoreContext = React.createContext<Store | null>(null);
 
function useStoreContext(consumerName: string) {
  const context = React.useContext(StoreContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
function useStore<T>(selector: (state: StoreState) => T): T {
  const store = useStoreContext("useStore");
 
  const getSnapshot = React.useCallback(
    () => selector(store.getState()),
    [store, selector],
  );
 
  return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
 
type SegmentPlaceholder =
  | string
  | {
      hour?: string;
      minute?: string;
      second?: string;
      period?: string;
    };
 
interface TimePickerContextValue {
  id: string;
  inputGroupId: string;
  labelId: string;
  triggerId: string;
  disabled: boolean;
  readOnly: boolean;
  required: boolean;
  invalid: boolean;
  showSeconds: boolean;
  is12Hour: boolean;
  minuteStep: number;
  secondStep: number;
  hourStep: number;
  segmentPlaceholder: {
    hour: string;
    minute: string;
    second: string;
    period: string;
  };
  min?: string;
  max?: string;
}
 
const TimePickerContext = React.createContext<TimePickerContextValue | null>(
  null,
);
 
function useTimePickerContext(consumerName: string) {
  const context = React.useContext(TimePickerContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
interface TimePickerRootProps extends DivProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  min?: string;
  max?: string;
  hourStep?: number;
  minuteStep?: number;
  secondStep?: number;
  segmentPlaceholder?: SegmentPlaceholder;
  locale?: string;
  name?: string;
  disabled?: boolean;
  invalid?: boolean;
  readOnly?: boolean;
  required?: boolean;
  showSeconds?: boolean;
}
 
function TimePickerRoot(props: TimePickerRootProps) {
  const {
    value,
    defaultValue,
    onValueChange,
    open,
    defaultOpen,
    onOpenChange,
    ...rootProps
  } = props;
 
  const listenersRef = useLazyRef(() => new Set<() => void>());
  const stateRef = useLazyRef<StoreState>(() => ({
    value: value ?? defaultValue ?? "",
    open: open ?? defaultOpen ?? false,
  }));
  const propsRef = useAsRef({ onValueChange, onOpenChange });
 
  const store: Store = React.useMemo(() => {
    return {
      subscribe: (cb) => {
        listenersRef.current.add(cb);
        return () => listenersRef.current.delete(cb);
      },
      getState: () => stateRef.current,
      setState: (key, value) => {
        if (Object.is(stateRef.current[key], value)) return;
 
        if (key === "value" && typeof value === "string") {
          stateRef.current.value = value;
          propsRef.current.onValueChange?.(value);
        } else if (key === "open" && typeof value === "boolean") {
          stateRef.current.open = value;
          propsRef.current.onOpenChange?.(value);
        } else {
          stateRef.current[key] = value;
        }
 
        store.notify();
      },
      notify: () => {
        for (const cb of listenersRef.current) {
          cb();
        }
      },
    };
  }, [listenersRef, stateRef, propsRef]);
 
  return (
    <StoreContext.Provider value={store}>
      <TimePickerRootImpl {...rootProps} value={value} open={open} />
    </StoreContext.Provider>
  );
}
 
interface TimePickerRootImplProps
  extends Omit<
    TimePickerRootProps,
    "defaultValue" | "defaultOpen" | "onValueChange" | "onOpenChange"
  > {}
 
function TimePickerRootImpl(props: TimePickerRootImplProps) {
  const {
    value,
    open: openProp,
    min,
    max,
    hourStep = DEFAULT_STEP,
    minuteStep = DEFAULT_STEP,
    secondStep = DEFAULT_STEP,
    segmentPlaceholder = DEFAULT_SEGMENT_PLACEHOLDER,
    locale = DEFAULT_LOCALE,
    name,
    asChild,
    disabled = false,
    invalid = false,
    readOnly = false,
    required = false,
    showSeconds = false,
    className,
    children,
    id,
    ref,
    ...rootProps
  } = props;
 
  const store = useStoreContext("TimePickerRootImpl");
 
  useIsomorphicLayoutEffect(() => {
    if (value !== undefined) {
      store.setState("value", value);
    }
  }, [value]);
 
  useIsomorphicLayoutEffect(() => {
    if (openProp !== undefined) {
      store.setState("open", openProp);
    }
  }, [openProp]);
 
  const instanceId = React.useId();
  const rootId = id ?? instanceId;
  const inputGroupId = React.useId();
  const labelId = React.useId();
  const triggerId = React.useId();
 
  const [formTrigger, setFormTrigger] = React.useState<RootElement | null>(
    null,
  );
  const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
 
  const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
 
  const open = useStore((state) => state.open);
 
  const onPopoverOpenChange = React.useCallback(
    (newOpen: boolean) => store.setState("open", newOpen),
    [store],
  );
 
  const is12Hour = React.useMemo(() => getIs12Hour(locale), [locale]);
 
  const normalizedPlaceholder = React.useMemo(() => {
    if (typeof segmentPlaceholder === "string") {
      return {
        hour: segmentPlaceholder,
        minute: segmentPlaceholder,
        second: segmentPlaceholder,
        period: segmentPlaceholder,
      };
    }
    return {
      hour: segmentPlaceholder.hour ?? DEFAULT_SEGMENT_PLACEHOLDER,
      minute: segmentPlaceholder.minute ?? DEFAULT_SEGMENT_PLACEHOLDER,
      second: segmentPlaceholder.second ?? DEFAULT_SEGMENT_PLACEHOLDER,
      period: segmentPlaceholder.period ?? DEFAULT_SEGMENT_PLACEHOLDER,
    };
  }, [segmentPlaceholder]);
 
  const rootContext = React.useMemo<TimePickerContextValue>(
    () => ({
      id: rootId,
      inputGroupId,
      labelId,
      triggerId,
      disabled,
      readOnly,
      required,
      invalid,
      showSeconds,
      is12Hour,
      minuteStep,
      secondStep,
      hourStep,
      segmentPlaceholder: normalizedPlaceholder,
      min,
      max,
    }),
    [
      rootId,
      inputGroupId,
      labelId,
      triggerId,
      disabled,
      readOnly,
      required,
      invalid,
      showSeconds,
      is12Hour,
      minuteStep,
      secondStep,
      hourStep,
      normalizedPlaceholder,
      min,
      max,
    ],
  );
 
  const RootPrimitive = asChild ? Slot : "div";
 
  return (
    <>
      <TimePickerContext.Provider value={rootContext}>
        <Popover open={open} onOpenChange={onPopoverOpenChange}>
          <RootPrimitive
            data-slot="time-picker"
            data-disabled={disabled ? "" : undefined}
            data-invalid={invalid ? "" : undefined}
            ref={composedRef}
            {...rootProps}
            className={cn("relative", className)}
          >
            {children}
          </RootPrimitive>
        </Popover>
      </TimePickerContext.Provider>
      {isFormControl && (
        <VisuallyHiddenInput
          type="hidden"
          control={formTrigger}
          name={name}
          value={value}
          disabled={disabled}
          readOnly={readOnly}
          required={required}
        />
      )}
    </>
  );
}
 
interface TimePickerLabelProps extends React.ComponentProps<"label"> {
  asChild?: boolean;
}
 
function TimePickerLabel(props: TimePickerLabelProps) {
  const { asChild, className, ...labelProps } = props;
 
  const { labelId } = useTimePickerContext(LABEL_NAME);
 
  const LabelPrimitive = asChild ? Slot : "label";
 
  return (
    <LabelPrimitive
      data-slot="time-picker-label"
      {...labelProps}
      htmlFor={labelId}
      className={cn(
        "font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
        className,
      )}
    />
  );
}
 
interface TimePickerInputGroupContextValue {
  onInputRegister: (
    segment: Segment,
    ref: React.RefObject<InputElement | null>,
  ) => void;
  onInputUnregister: (segment: Segment) => void;
  getNextInput: (
    currentSegment: Segment,
  ) => React.RefObject<InputElement | null> | null;
}
 
const TimePickerInputGroupContext =
  React.createContext<TimePickerInputGroupContextValue | null>(null);
 
function useTimePickerInputGroupContext(consumerName: string) {
  const context = React.useContext(TimePickerInputGroupContext);
  if (!context) {
    throw new Error(
      `\`${consumerName}\` must be used within \`${INPUT_GROUP_NAME}\``,
    );
  }
  return context;
}
 
function TimePickerInputGroup(props: DivProps) {
  const { asChild, className, style, ...inputGroupProps } = props;
 
  const { inputGroupId, labelId, disabled, invalid, segmentPlaceholder } =
    useTimePickerContext(INPUT_GROUP_NAME);
 
  const inputRefsMap = React.useRef<
    Map<Segment, React.RefObject<InputElement | null>>
  >(new Map());
 
  const onInputRegister = React.useCallback(
    (segment: Segment, ref: React.RefObject<InputElement | null>) => {
      inputRefsMap.current.set(segment, ref);
    },
    [],
  );
 
  const onInputUnregister = React.useCallback((segment: Segment) => {
    inputRefsMap.current.delete(segment);
  }, []);
 
  const getNextInput = React.useCallback(
    (currentSegment: Segment): React.RefObject<InputElement | null> | null => {
      const segmentOrder: Segment[] = ["hour", "minute", "second", "period"];
      const currentIndex = segmentOrder.indexOf(currentSegment);
 
      if (currentIndex === -1 || currentIndex === segmentOrder.length - 1) {
        return null;
      }
 
      for (let i = currentIndex + 1; i < segmentOrder.length; i++) {
        const nextSegment = segmentOrder[i];
        if (nextSegment) {
          const nextRef = inputRefsMap.current.get(nextSegment);
          if (nextRef?.current) {
            return nextRef;
          }
        }
      }
 
      return null;
    },
    [],
  );
 
  const inputGroupContextValue =
    React.useMemo<TimePickerInputGroupContextValue>(
      () => ({
        onInputRegister,
        onInputUnregister,
        getNextInput,
      }),
      [onInputRegister, onInputUnregister, getNextInput],
    );
 
  const InputGroupPrimitive = asChild ? Slot : "div";
 
  return (
    <TimePickerInputGroupContext.Provider value={inputGroupContextValue}>
      <PopoverAnchor asChild>
        <InputGroupPrimitive
          role="group"
          id={inputGroupId}
          aria-labelledby={labelId}
          data-slot="time-picker-input-group"
          data-disabled={disabled ? "" : undefined}
          data-invalid={invalid ? "" : undefined}
          {...inputGroupProps}
          className={cn(
            "flex h-10 w-full items-center gap-0.5 rounded-md border border-input bg-background px-3 py-2 shadow-xs outline-none transition-shadow",
            "has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50",
            invalid && "border-destructive ring-destructive/20",
            disabled && "cursor-not-allowed opacity-50",
            className,
          )}
          style={
            {
              "--time-picker-hour-input-width": `${segmentPlaceholder.hour.length}ch`,
              "--time-picker-minute-input-width": `${segmentPlaceholder.minute.length}ch`,
              "--time-picker-second-input-width": `${segmentPlaceholder.second.length}ch`,
              "--time-picker-period-input-width": `${Math.max(segmentPlaceholder.period.length, 2) + 0.5}ch`,
              ...style,
            } as React.CSSProperties
          }
        />
      </PopoverAnchor>
    </TimePickerInputGroupContext.Provider>
  );
}
 
interface TimePickerInputProps
  extends Omit<React.ComponentProps<"input">, "type" | "value"> {
  segment?: Segment;
}
 
function TimePickerInput(props: TimePickerInputProps) {
  const {
    segment,
    disabled: disabledProp,
    readOnly: readOnlyProp,
    className,
    style,
    ref,
    onBlur: onBlurProp,
    onChange: onChangeProp,
    onClick: onClickProp,
    onFocus: onFocusProp,
    onKeyDown: onKeyDownProp,
    ...inputProps
  } = props;
 
  const { is12Hour, showSeconds, disabled, readOnly, segmentPlaceholder } =
    useTimePickerContext(INPUT_NAME);
  const store = useStoreContext(INPUT_NAME);
  const inputGroupContext = useTimePickerInputGroupContext(INPUT_NAME);
 
  const isDisabled = disabledProp || disabled;
  const isReadOnly = readOnlyProp || readOnly;
 
  const value = useStore((state) => state.value);
  const timeValue = parseTimeString(value);
 
  const inputRef = React.useRef<HTMLInputElement>(null);
  const composedRef = useComposedRefs(ref, inputRef);
 
  useIsomorphicLayoutEffect(() => {
    if (segment) {
      inputGroupContext.onInputRegister(segment as Segment, inputRef);
      return () => inputGroupContext.onInputUnregister(segment as Segment);
    }
  }, [inputGroupContext, segment]);
 
  const getSegmentValue = React.useCallback(() => {
    if (!timeValue) {
      if (!segment) return "";
      return segmentPlaceholder[segment];
    }
    switch (segment) {
      case "hour": {
        if (timeValue.hour === undefined) return segmentPlaceholder.hour;
        if (is12Hour) {
          return to12Hour(timeValue.hour).hour.toString().padStart(2, "0");
        }
        return timeValue.hour.toString().padStart(2, "0");
      }
      case "minute":
        if (timeValue.minute === undefined) return segmentPlaceholder.minute;
        return timeValue.minute.toString().padStart(2, "0");
      case "second":
        if (timeValue.second === undefined) return segmentPlaceholder.second;
        return timeValue.second.toString().padStart(2, "0");
      case "period": {
        if (!timeValue || timeValue.hour === undefined)
          return segmentPlaceholder.period;
        return to12Hour(timeValue.hour).period;
      }
      default:
        return "";
    }
  }, [timeValue, segment, is12Hour, segmentPlaceholder]);
 
  const [editValue, setEditValue] = React.useState(getSegmentValue());
  const [isEditing, setIsEditing] = React.useState(false);
  const [pendingDigit, setPendingDigit] = React.useState<string | null>(null);
 
  React.useEffect(() => {
    if (!isEditing) {
      setEditValue(getSegmentValue());
      setPendingDigit(null);
    }
  }, [getSegmentValue, isEditing]);
 
  const updateTimeValue = React.useCallback(
    (newSegmentValue: string | undefined, shouldCreateIfEmpty = false) => {
      const placeholder = segment
        ? segmentPlaceholder[segment]
        : DEFAULT_SEGMENT_PLACEHOLDER;
      if (!newSegmentValue || newSegmentValue === placeholder) return;
      if (!timeValue && !shouldCreateIfEmpty) return;
 
      const currentTime = timeValue ?? {};
      const newTime = { ...currentTime };
 
      switch (segment) {
        case "hour": {
          const displayHour = Number.parseInt(newSegmentValue, 10);
          if (!Number.isNaN(displayHour)) {
            if (is12Hour) {
              const clampedHour = clamp(displayHour, 1, 12);
              let currentPeriod: Period;
              if (timeValue?.period !== undefined) {
                currentPeriod = timeValue.period;
              } else if (timeValue?.hour !== undefined) {
                currentPeriod = to12Hour(timeValue.hour).period;
              } else {
                const now = new Date();
                currentPeriod = to12Hour(now.getHours()).period;
              }
              const hour24 = to24Hour(clampedHour, currentPeriod);
              newTime.hour = hour24;
              if (timeValue?.period !== undefined) {
                newTime.period = timeValue.period;
              }
            } else {
              newTime.hour = clamp(displayHour, 0, 23);
            }
          }
          break;
        }
        case "minute": {
          const minute = Number.parseInt(newSegmentValue, 10);
          if (!Number.isNaN(minute)) {
            newTime.minute = clamp(minute, 0, 59);
          }
          break;
        }
        case "second": {
          const second = Number.parseInt(newSegmentValue, 10);
          if (!Number.isNaN(second)) {
            newTime.second = clamp(second, 0, 59);
          }
          break;
        }
        case "period": {
          if (newSegmentValue === "AM" || newSegmentValue === "PM") {
            newTime.period = newSegmentValue;
            if (timeValue && timeValue.hour !== undefined) {
              const currentDisplay = to12Hour(timeValue.hour);
              newTime.hour = to24Hour(currentDisplay.hour, newSegmentValue);
            }
          }
          break;
        }
      }
 
      const newValue = formatTimeValue(newTime, showSeconds);
      store.setState("value", newValue);
    },
    [timeValue, segment, is12Hour, showSeconds, store, segmentPlaceholder],
  );
 
  const onBlur = React.useCallback(
    (event: React.FocusEvent<InputElement>) => {
      onBlurProp?.(event);
      if (event.defaultPrevented) return;
 
      setIsEditing(false);
 
      const placeholder = segment
        ? segmentPlaceholder[segment]
        : DEFAULT_SEGMENT_PLACEHOLDER;
      if (editValue && editValue !== placeholder && editValue.length > 0) {
        let valueToUpdate = editValue;
 
        if (segment !== "period") {
          if (editValue.length === 2) {
            valueToUpdate = editValue;
          } else if (editValue.length === 1) {
            const numValue = Number.parseInt(editValue, 10);
            if (!Number.isNaN(numValue)) {
              valueToUpdate = numValue.toString().padStart(2, "0");
            }
          }
        }
 
        updateTimeValue(valueToUpdate, true);
 
        queueMicrotask(() => {
          const currentTimeValue = parseTimeString(store.getState().value);
          if (currentTimeValue) {
            const now = new Date();
            const newTime = { ...currentTimeValue };
            let needsUpdate = false;
 
            if (newTime.hour === undefined) {
              newTime.hour = now.getHours();
              needsUpdate = true;
            }
 
            if (newTime.minute === undefined) {
              newTime.minute = now.getMinutes();
              needsUpdate = true;
            }
 
            if (showSeconds && newTime.second === undefined) {
              newTime.second = now.getSeconds();
              needsUpdate = true;
            }
 
            if (needsUpdate) {
              const newValue = formatTimeValue(newTime, showSeconds);
              store.setState("value", newValue);
            }
          }
        });
      }
 
      setEditValue(getSegmentValue());
      setPendingDigit(null);
    },
    [
      onBlurProp,
      editValue,
      updateTimeValue,
      getSegmentValue,
      segment,
      segmentPlaceholder,
      showSeconds,
      store,
    ],
  );
 
  const onChange = React.useCallback(
    (event: React.ChangeEvent<InputElement>) => {
      onChangeProp?.(event);
      if (event.defaultPrevented) return;
 
      let newValue = event.target.value;
 
      const placeholder = segment
        ? segmentPlaceholder[segment]
        : DEFAULT_SEGMENT_PLACEHOLDER;
      if (
        editValue === placeholder &&
        newValue.length > 0 &&
        newValue !== placeholder
      ) {
        newValue = newValue.replace(new RegExp(`^${placeholder}`), "");
      }
 
      if (segment === "period") {
        const firstChar = newValue.charAt(0).toUpperCase();
        let newPeriod: Period | null = null;
 
        if (firstChar === "A" || firstChar === "1") {
          newPeriod = "AM";
        } else if (firstChar === "P" || firstChar === "2") {
          newPeriod = "PM";
        }
 
        if (newPeriod) {
          setEditValue(newPeriod);
          updateTimeValue(newPeriod, true);
          queueMicrotask(() => {
            inputRef.current?.select();
          });
        }
        return;
      }
 
      if (segment === "hour" || segment === "minute" || segment === "second") {
        newValue = newValue.replace(/\D/g, "");
      }
 
      if (newValue.length > 2) {
        newValue = newValue.slice(0, 2);
      }
      if (segment === "hour" || segment === "minute" || segment === "second") {
        const numValue = Number.parseInt(newValue, 10);
 
        if (!Number.isNaN(numValue) && newValue.length > 0) {
          if (pendingDigit !== null && newValue.length === 1) {
            const twoDigitValue = pendingDigit + newValue;
            const combinedNum = Number.parseInt(twoDigitValue, 10);
 
            if (!Number.isNaN(combinedNum)) {
              const paddedValue = combinedNum.toString().padStart(2, "0");
              setEditValue(paddedValue);
              updateTimeValue(paddedValue, true);
              setPendingDigit(null);
 
              queueMicrotask(() => {
                if (segment) {
                  const nextInputRef = inputGroupContext.getNextInput(segment);
                  if (nextInputRef?.current) {
                    nextInputRef.current.focus();
                    nextInputRef.current.select();
                  }
                }
              });
              return;
            }
          }
 
          const maxFirstDigit = segment === "hour" ? (is12Hour ? 1 : 2) : 5;
 
          const firstDigit = Number.parseInt(newValue[0] ?? "0", 10);
          const shouldAutoAdvance = firstDigit > maxFirstDigit;
 
          if (newValue.length === 1) {
            if (shouldAutoAdvance) {
              const paddedValue = numValue.toString().padStart(2, "0");
              setEditValue(paddedValue);
              updateTimeValue(paddedValue, true);
              setPendingDigit(null);
 
              queueMicrotask(() => {
                if (segment) {
                  const nextInputRef = inputGroupContext.getNextInput(segment);
                  if (nextInputRef?.current) {
                    nextInputRef.current.focus();
                    nextInputRef.current.select();
                  }
                }
              });
            } else {
              const paddedValue = numValue.toString().padStart(2, "0");
              setEditValue(paddedValue);
              setPendingDigit(newValue);
              queueMicrotask(() => {
                inputRef.current?.select();
              });
            }
          } else if (newValue.length === 2) {
            const paddedValue = numValue.toString().padStart(2, "0");
            setEditValue(paddedValue);
            updateTimeValue(paddedValue, true);
            setPendingDigit(null);
 
            queueMicrotask(() => {
              if (segment) {
                const nextInputRef = inputGroupContext.getNextInput(segment);
                if (nextInputRef?.current) {
                  nextInputRef.current.focus();
                  nextInputRef.current.select();
                }
              }
            });
          }
        } else if (newValue.length === 0) {
          setEditValue("");
          setPendingDigit(null);
        }
      }
    },
    [
      segment,
      updateTimeValue,
      onChangeProp,
      editValue,
      is12Hour,
      inputGroupContext,
      pendingDigit,
      segmentPlaceholder,
    ],
  );
 
  const onClick = React.useCallback(
    (event: React.MouseEvent<InputElement>) => {
      onClickProp?.(event);
      if (event.defaultPrevented) return;
 
      event.currentTarget.select();
    },
    [onClickProp],
  );
 
  const onFocus = React.useCallback(
    (event: React.FocusEvent<InputElement>) => {
      onFocusProp?.(event);
      if (event.defaultPrevented) return;
 
      setIsEditing(true);
      setPendingDigit(null);
      queueMicrotask(() => event.target.select());
    },
    [onFocusProp],
  );
 
  const onKeyDown = React.useCallback(
    (event: React.KeyboardEvent<InputElement>) => {
      onKeyDownProp?.(event);
      if (event.defaultPrevented) return;
 
      if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
        event.preventDefault();
 
        const goToPrevious = event.key === "ArrowLeft";
        const inputGroup = inputRef.current?.closest(
          '[data-slot="time-picker-input-group"]',
        );
 
        if (inputGroup && inputRef.current) {
          const allInputs = Array.from(
            inputGroup.querySelectorAll('input[type="text"]'),
          ) as HTMLInputElement[];
          const currentIdx = allInputs.indexOf(inputRef.current);
 
          if (currentIdx !== -1) {
            const targetIdx = goToPrevious
              ? Math.max(0, currentIdx - 1)
              : Math.min(allInputs.length - 1, currentIdx + 1);
 
            const targetInput = allInputs[targetIdx];
            if (targetInput && targetInput !== inputRef.current) {
              targetInput.focus();
              targetInput.select();
            }
          }
        }
        return;
      }
 
      if (event.key === "Backspace" || event.key === "Delete") {
        const input = inputRef.current;
        if (
          input &&
          input.selectionStart === 0 &&
          input.selectionEnd === input.value.length
        ) {
          event.preventDefault();
          const placeholder = segment
            ? segmentPlaceholder[segment]
            : DEFAULT_SEGMENT_PLACEHOLDER;
          setEditValue(placeholder);
          setPendingDigit(null);
 
          if (timeValue) {
            const newTime = { ...timeValue };
            switch (segment) {
              case "hour":
                delete newTime.hour;
                break;
              case "minute":
                delete newTime.minute;
                break;
              case "second":
                delete newTime.second;
                break;
              case "period":
                delete newTime.period;
                break;
            }
 
            if (
              newTime.hour !== undefined ||
              newTime.minute !== undefined ||
              newTime.second !== undefined ||
              newTime.period !== undefined
            ) {
              const newValue = formatTimeValue(newTime, showSeconds);
              store.setState("value", newValue);
            } else {
              store.setState("value", "");
            }
          } else {
            store.setState("value", "");
          }
 
          queueMicrotask(() => {
            inputRef.current?.select();
          });
          return;
        }
      }
 
      if (segment === "period") {
        const key = event.key.toLowerCase();
        if (key === "a" || key === "p" || key === "1" || key === "2") {
          event.preventDefault();
          let newPeriod: Period;
          if (key === "a" || key === "1") {
            newPeriod = "AM";
          } else {
            newPeriod = "PM";
          }
          setEditValue(newPeriod);
          updateTimeValue(newPeriod, true);
          queueMicrotask(() => {
            inputRef.current?.select();
          });
        } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
          event.preventDefault();
          const placeholder = segmentPlaceholder.period;
          const currentPeriod =
            editValue === placeholder || editValue === "" ? "AM" : editValue;
          const newPeriod =
            currentPeriod === "AM" || currentPeriod === "A" ? "PM" : "AM";
          setEditValue(newPeriod);
          updateTimeValue(newPeriod, true);
          queueMicrotask(() => {
            inputRef.current?.select();
          });
        }
        return;
      }
 
      if (event.key === "Tab") {
        const placeholder = segment
          ? segmentPlaceholder[segment]
          : DEFAULT_SEGMENT_PLACEHOLDER;
        if (editValue && editValue.length > 0 && editValue !== placeholder) {
          if (editValue.length === 2) {
            updateTimeValue(editValue, true);
          } else if (editValue.length === 1) {
            const numValue = Number.parseInt(editValue, 10);
            if (!Number.isNaN(numValue)) {
              const paddedValue = numValue.toString().padStart(2, "0");
              updateTimeValue(paddedValue, true);
            }
          }
        }
        return;
      }
 
      if (event.key === "Enter") {
        event.preventDefault();
        inputRef.current?.blur();
      }
 
      if (event.key === "Escape") {
        event.preventDefault();
        setEditValue(getSegmentValue());
        inputRef.current?.blur();
      }
 
      if (event.key === "ArrowUp") {
        event.preventDefault();
        const placeholder = segment
          ? segmentPlaceholder[segment]
          : DEFAULT_SEGMENT_PLACEHOLDER;
        if (editValue === placeholder || editValue === "") {
          const defaultValue = segment === "hour" ? (is12Hour ? 12 : 0) : 0;
          const formattedValue = defaultValue.toString().padStart(2, "0");
          setEditValue(formattedValue);
          updateTimeValue(formattedValue, true);
          return;
        }
        const currentValue = Number.parseInt(editValue, 10);
        if (!Number.isNaN(currentValue)) {
          let newValue: number;
          switch (segment) {
            case "hour":
              if (is12Hour) {
                newValue = currentValue === 12 ? 1 : currentValue + 1;
              } else {
                newValue = currentValue === 23 ? 0 : currentValue + 1;
              }
              break;
            case "minute":
            case "second":
              newValue = currentValue === 59 ? 0 : currentValue + 1;
              break;
            default:
              return;
          }
          const formattedValue = newValue.toString().padStart(2, "0");
          setEditValue(formattedValue);
          updateTimeValue(formattedValue, true);
        }
      }
 
      if (event.key === "ArrowDown") {
        event.preventDefault();
        const placeholder = segment
          ? segmentPlaceholder[segment]
          : DEFAULT_SEGMENT_PLACEHOLDER;
        if (editValue === placeholder || editValue === "") {
          const defaultValue = segment === "hour" ? (is12Hour ? 12 : 23) : 59;
          const formattedValue = defaultValue.toString().padStart(2, "0");
          setEditValue(formattedValue);
          updateTimeValue(formattedValue, true);
          return;
        }
        const currentValue = Number.parseInt(editValue, 10);
        if (!Number.isNaN(currentValue)) {
          let newValue: number;
          switch (segment) {
            case "hour":
              if (is12Hour) {
                newValue = currentValue === 1 ? 12 : currentValue - 1;
              } else {
                newValue = currentValue === 0 ? 23 : currentValue - 1;
              }
              break;
            case "minute":
            case "second":
              newValue = currentValue === 0 ? 59 : currentValue - 1;
              break;
            default:
              return;
          }
          const formattedValue = newValue.toString().padStart(2, "0");
          setEditValue(formattedValue);
          updateTimeValue(formattedValue, true);
        }
      }
    },
    [
      onKeyDownProp,
      editValue,
      segment,
      is12Hour,
      getSegmentValue,
      updateTimeValue,
      showSeconds,
      timeValue,
      store,
      segmentPlaceholder,
    ],
  );
 
  const displayValue = isEditing ? editValue : getSegmentValue();
 
  const segmentWidth = segment
    ? `var(--time-picker-${segment}-input-width)`
    : "2ch";
 
  return (
    <input
      type="text"
      inputMode={segment === "period" ? "text" : "numeric"}
      autoComplete="off"
      autoCorrect="off"
      autoCapitalize="off"
      spellCheck={false}
      translate="no"
      {...inputProps}
      disabled={isDisabled}
      readOnly={isReadOnly}
      className={cn(
        "inline-flex h-full items-center justify-center border-0 bg-transparent text-center text-sm tabular-nums outline-none transition-colors focus:bg-transparent disabled:cursor-not-allowed disabled:opacity-50",
        className,
      )}
      style={{ width: segmentWidth, ...style }}
      ref={composedRef}
      value={displayValue}
      onBlur={onBlur}
      onChange={onChange}
      onClick={onClick}
      onFocus={onFocus}
      onKeyDown={onKeyDown}
    />
  );
}
 
function TimePickerTrigger(props: ButtonProps) {
  const {
    className,
    children,
    disabled: disabledProp,
    ...triggerProps
  } = props;
 
  const { triggerId, disabled } = useTimePickerContext(TRIGGER_NAME);
 
  const isDisabled = disabledProp || disabled;
 
  return (
    <PopoverTrigger
      type="button"
      id={triggerId}
      data-slot="time-picker-trigger"
      disabled={isDisabled}
      {...triggerProps}
      className={cn(
        "ml-auto flex items-center text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none [&>svg:not([class*='size-'])]:size-4",
        className,
      )}
    >
      {children ?? <Clock />}
    </PopoverTrigger>
  );
}
 
interface TimePickerGroupContextValue {
  getColumns: () => ColumnData[];
  onColumnRegister: (column: ColumnData) => void;
  onColumnUnregister: (id: string) => void;
}
 
const TimePickerGroupContext =
  React.createContext<TimePickerGroupContextValue | null>(null);
 
function useTimePickerGroupContext(consumerName: string) {
  const context = React.useContext(TimePickerGroupContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
interface TimePickerContentProps
  extends DivProps,
    React.ComponentProps<typeof PopoverContent> {}
 
function TimePickerContent(props: TimePickerContentProps) {
  const {
    side = "bottom",
    align = "start",
    sideOffset = 4,
    className,
    onOpenAutoFocus: onOpenAutoFocusProp,
    children,
    ...contentProps
  } = props;
 
  const columnsRef = React.useRef<Map<string, Omit<ColumnData, "id">>>(
    new Map(),
  );
 
  const onColumnRegister = React.useCallback((column: ColumnData) => {
    columnsRef.current.set(column.id, column);
  }, []);
 
  const onColumnUnregister = React.useCallback((id: string) => {
    columnsRef.current.delete(id);
  }, []);
 
  const getColumns = React.useCallback(() => {
    const columns = Array.from(columnsRef.current.entries())
      .map(([id, { ref, getSelectedItemRef, getItems }]) => ({
        id,
        ref,
        getSelectedItemRef,
        getItems,
      }))
      .filter((c) => c.ref.current !== null);
    return sortNodes(columns);
  }, []);
 
  const groupContextValue = React.useMemo<TimePickerGroupContextValue>(
    () => ({
      getColumns,
      onColumnRegister,
      onColumnUnregister,
    }),
    [getColumns, onColumnRegister, onColumnUnregister],
  );
 
  const onOpenAutoFocus: NonNullable<
    React.ComponentProps<typeof PopoverContent>["onOpenAutoFocus"]
  > = React.useCallback(
    (event) => {
      onOpenAutoFocusProp?.(event);
      if (event.defaultPrevented) return;
 
      event.preventDefault();
      const columns = getColumns();
      const firstColumn = columns[0];
 
      if (!firstColumn) return;
 
      const items = firstColumn.getItems();
      const selectedItem = items.find((item) => item.selected);
 
      const candidateRefs = selectedItem
        ? [selectedItem.ref, ...items.map((item) => item.ref)]
        : items.map((item) => item.ref);
 
      focusFirst(candidateRefs, false);
    },
    [onOpenAutoFocusProp, getColumns],
  );
 
  return (
    <TimePickerGroupContext.Provider value={groupContextValue}>
      <PopoverContent
        data-slot="time-picker-content"
        side={side}
        align={align}
        sideOffset={sideOffset}
        onOpenAutoFocus={onOpenAutoFocus}
        {...contentProps}
        className={cn(
          "flex w-auto max-w-(--radix-popover-trigger-width) p-0",
          className,
        )}
      >
        {children}
      </PopoverContent>
    </TimePickerGroupContext.Provider>
  );
}
 
interface TimePickerColumnContextValue {
  getItems: () => ItemData[];
  onItemRegister: (
    value: number | string,
    ref: React.RefObject<ColumnItemElement | null>,
    selected: boolean,
  ) => void;
  onItemUnregister: (value: number | string) => void;
}
 
const TimePickerColumnContext =
  React.createContext<TimePickerColumnContextValue | null>(null);
 
function useTimePickerColumnContext(consumerName: string) {
  const context = React.useContext(TimePickerColumnContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within a column`);
  }
  return context;
}
 
interface TimePickerColumnProps extends DivProps {}
 
function TimePickerColumn(props: TimePickerColumnProps) {
  const { children, className, ref, ...columnProps } = props;
 
  const columnId = React.useId();
  const columnRef = React.useRef<ColumnElement | null>(null);
  const composedRef = useComposedRefs(ref, columnRef);
 
  const itemsRef = React.useRef<
    Map<
      number | string,
      {
        ref: React.RefObject<ColumnItemElement | null>;
        selected: boolean;
      }
    >
  >(new Map());
 
  const groupContext = useTimePickerGroupContext(COLUMN_NAME);
 
  const onItemRegister = React.useCallback(
    (
      value: number | string,
      ref: React.RefObject<HTMLButtonElement | null>,
      selected: boolean,
    ) => {
      itemsRef.current.set(value, { ref, selected });
    },
    [],
  );
 
  const onItemUnregister = React.useCallback((value: number | string) => {
    itemsRef.current.delete(value);
  }, []);
 
  const getItems = React.useCallback(() => {
    const items = Array.from(itemsRef.current.entries())
      .map(([value, { ref, selected }]) => ({
        value,
        ref,
        selected,
      }))
      .filter((item) => item.ref.current);
    return sortNodes(items);
  }, []);
 
  const getSelectedItemRef = React.useCallback(() => {
    const items = getItems();
    return items.find((item) => item.selected)?.ref ?? null;
  }, [getItems]);
 
  useIsomorphicLayoutEffect(() => {
    groupContext.onColumnRegister({
      id: columnId,
      ref: columnRef,
      getSelectedItemRef,
      getItems,
    });
    return () => groupContext.onColumnUnregister(columnId);
  }, [groupContext, columnId, getSelectedItemRef, getItems]);
 
  const columnContextValue = React.useMemo<TimePickerColumnContextValue>(
    () => ({
      getItems,
      onItemRegister,
      onItemUnregister,
    }),
    [getItems, onItemRegister, onItemUnregister],
  );
 
  return (
    <TimePickerColumnContext.Provider value={columnContextValue}>
      <div
        ref={composedRef}
        data-slot="time-picker-column"
        {...columnProps}
        className={cn("flex flex-col gap-1 not-last:border-r p-1", className)}
      >
        {children}
      </div>
    </TimePickerColumnContext.Provider>
  );
}
 
interface TimePickerColumnItemProps extends ButtonProps {
  value: number | string;
  selected?: boolean;
  format?: SegmentFormat;
}
 
function TimePickerColumnItem(props: TimePickerColumnItemProps) {
  const {
    value,
    selected = false,
    format = "numeric",
    className,
    ref,
    ...itemProps
  } = props;
 
  const itemRef = React.useRef<ColumnItemElement | null>(null);
  const composedRef = useComposedRefs(ref, itemRef);
  const columnContext = useTimePickerColumnContext(COLUMN_ITEM_NAME);
  const groupContext = useTimePickerGroupContext(COLUMN_ITEM_NAME);
 
  useIsomorphicLayoutEffect(() => {
    columnContext.onItemRegister(value, itemRef, selected);
    return () => columnContext.onItemUnregister(value);
  }, [columnContext, value, selected]);
 
  useIsomorphicLayoutEffect(() => {
    if (selected && itemRef.current) {
      itemRef.current.scrollIntoView({ block: "nearest" });
    }
  }, [selected]);
 
  const onClick = React.useCallback(
    (event: React.MouseEvent<ColumnItemElement>) => {
      itemProps.onClick?.(event);
      if (event.defaultPrevented) return;
 
      itemRef.current?.focus();
    },
    [itemProps.onClick],
  );
 
  const onKeyDown = React.useCallback(
    (event: React.KeyboardEvent<ColumnItemElement>) => {
      itemProps.onKeyDown?.(event);
      if (event.defaultPrevented) return;
 
      if (event.key === "ArrowUp" || event.key === "ArrowDown") {
        event.preventDefault();
        const items = columnContext.getItems().sort((a, b) => {
          if (typeof a.value === "number" && typeof b.value === "number") {
            return a.value - b.value;
          }
          return 0;
        });
        const currentIndex = items.findIndex((item) => item.value === value);
 
        let nextIndex: number;
        if (event.key === "ArrowUp") {
          nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
        } else {
          nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
        }
 
        const nextItem = items[nextIndex];
        nextItem?.ref.current?.focus();
        nextItem?.ref.current?.click();
      } else if (
        (event.key === "Tab" ||
          event.key === "ArrowLeft" ||
          event.key === "ArrowRight") &&
        groupContext
      ) {
        event.preventDefault();
 
        queueMicrotask(() => {
          const columns = groupContext.getColumns();
 
          if (columns.length === 0) return;
 
          const currentColumnIndex = columns.findIndex(
            (c) => c.ref.current?.contains(itemRef.current) ?? false,
          );
 
          if (currentColumnIndex === -1) return;
 
          const goToPrevious =
            event.key === "ArrowLeft" ||
            (event.key === "Tab" && event.shiftKey);
 
          const nextColumnIndex = goToPrevious
            ? currentColumnIndex > 0
              ? currentColumnIndex - 1
              : columns.length - 1
            : currentColumnIndex < columns.length - 1
              ? currentColumnIndex + 1
              : 0;
 
          const nextColumn = columns[nextColumnIndex];
          if (nextColumn?.ref.current) {
            const items = nextColumn.getItems();
            const selectedItem = items.find((item) => item.selected);
 
            const candidateRefs = selectedItem
              ? [selectedItem.ref, ...items.map((item) => item.ref)]
              : items.map((item) => item.ref);
 
            focusFirst(candidateRefs, false);
          }
        });
      }
    },
    [itemProps.onKeyDown, columnContext, groupContext, value],
  );
 
  const formattedValue =
    typeof value === "number" && format === "2-digit"
      ? value.toString().padStart(2, "0")
      : value.toString();
 
  return (
    <button
      type="button"
      {...itemProps}
      ref={composedRef}
      data-selected={selected ? "" : undefined}
      className={cn(
        "w-full rounded px-3 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus:border-ring focus:outline-none focus:ring-[3px] focus:ring-ring/50",
        selected &&
          "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
        className,
      )}
      onClick={onClick}
      onKeyDown={onKeyDown}
    >
      {formattedValue}
    </button>
  );
}
 
interface TimePickerHourProps extends DivProps {
  format?: SegmentFormat;
}
 
function TimePickerHour(props: TimePickerHourProps) {
  const { asChild, format = "numeric", className, ...hourProps } = props;
 
  const { is12Hour, hourStep, showSeconds } = useTimePickerContext(HOUR_NAME);
  const store = useStoreContext(HOUR_NAME);
 
  const value = useStore((state) => state.value);
  const timeValue = parseTimeString(value);
 
  const hours = Array.from(
    {
      length: is12Hour ? Math.ceil(12 / hourStep) : Math.ceil(24 / hourStep),
    },
    (_, i) => {
      if (is12Hour) {
        const hour = (i * hourStep) % 12;
        return hour === 0 ? 12 : hour;
      }
      return i * hourStep;
    },
  );
 
  const onHourSelect = React.useCallback(
    (displayHour: number) => {
      const now = new Date();
      const currentTime = timeValue ?? {};
 
      let hour24 = displayHour;
      if (is12Hour) {
        let currentPeriod: Period;
        if (timeValue?.period !== undefined) {
          currentPeriod = timeValue.period;
        } else if (timeValue?.hour !== undefined) {
          currentPeriod = to12Hour(timeValue.hour).period;
        } else {
          currentPeriod = to12Hour(now.getHours()).period;
        }
        hour24 = to24Hour(displayHour, currentPeriod);
      }
 
      const newTime = { ...currentTime, hour: hour24 };
      if (timeValue && timeValue.period !== undefined) {
        newTime.period = timeValue.period;
      }
 
      if (newTime.minute === undefined) {
        newTime.minute = now.getMinutes();
      }
 
      if (showSeconds && newTime.second === undefined) {
        newTime.second = now.getSeconds();
      }
 
      const newValue = formatTimeValue(newTime, showSeconds);
      store.setState("value", newValue);
    },
    [timeValue, showSeconds, is12Hour, store],
  );
 
  const now = new Date();
  const referenceHour = timeValue?.hour ?? now.getHours();
  const displayHour = is12Hour ? to12Hour(referenceHour).hour : referenceHour;
 
  const HourPrimitive = asChild ? Slot : TimePickerColumn;
 
  return (
    <HourPrimitive
      data-slot="time-picker-hour"
      {...hourProps}
      className={cn(
        "scrollbar-none flex max-h-[200px] flex-col gap-1 overflow-y-auto p-1",
        className,
      )}
    >
      {hours.map((hour) => (
        <TimePickerColumnItem
          key={hour}
          value={hour}
          selected={displayHour === hour}
          format={format}
          onClick={() => onHourSelect(hour)}
        />
      ))}
    </HourPrimitive>
  );
}
 
interface TimePickerMinuteProps extends DivProps {
  format?: SegmentFormat;
}
 
function TimePickerMinute(props: TimePickerMinuteProps) {
  const { asChild, format = "2-digit", className, ...minuteProps } = props;
 
  const { minuteStep, showSeconds } = useTimePickerContext(MINUTE_NAME);
  const store = useStoreContext(MINUTE_NAME);
 
  const value = useStore((state) => state.value);
  const timeValue = parseTimeString(value);
 
  const minutes = Array.from(
    { length: Math.ceil(60 / minuteStep) },
    (_, i) => i * minuteStep,
  );
 
  const onMinuteSelect = React.useCallback(
    (minute: number) => {
      const now = new Date();
      const currentTime = timeValue ?? {};
      const newTime = { ...currentTime, minute };
 
      if (newTime.hour === undefined) {
        newTime.hour = now.getHours();
      }
 
      if (showSeconds && newTime.second === undefined) {
        newTime.second = now.getSeconds();
      }
 
      const newValue = formatTimeValue(newTime, showSeconds);
      store.setState("value", newValue);
    },
    [timeValue, showSeconds, store],
  );
 
  const MinutePrimitive = asChild ? Slot : TimePickerColumn;
 
  const now = new Date();
  const referenceMinute = timeValue?.minute ?? now.getMinutes();
 
  return (
    <MinutePrimitive
      data-slot="time-picker-minute"
      {...minuteProps}
      className={cn(
        "scrollbar-none flex max-h-[200px] flex-col gap-1 overflow-y-auto p-1",
        className,
      )}
    >
      {minutes.map((minute) => (
        <TimePickerColumnItem
          key={minute}
          value={minute}
          selected={referenceMinute === minute}
          format={format}
          onClick={() => onMinuteSelect(minute)}
        />
      ))}
    </MinutePrimitive>
  );
}
 
interface TimePickerSecondProps extends DivProps {
  format?: SegmentFormat;
}
 
function TimePickerSecond(props: TimePickerSecondProps) {
  const { asChild, format = "2-digit", className, ...secondProps } = props;
 
  const { secondStep } = useTimePickerContext(SECOND_NAME);
  const store = useStoreContext(SECOND_NAME);
 
  const value = useStore((state) => state.value);
  const timeValue = parseTimeString(value);
 
  const seconds = Array.from(
    { length: Math.ceil(60 / secondStep) },
    (_, i) => i * secondStep,
  );
 
  const onSecondSelect = React.useCallback(
    (second: number) => {
      const now = new Date();
      const currentTime = timeValue ?? {};
      const newTime = { ...currentTime, second };
 
      if (newTime.hour === undefined) {
        newTime.hour = now.getHours();
      }
 
      if (newTime.minute === undefined) {
        newTime.minute = now.getMinutes();
      }
 
      const newValue = formatTimeValue(newTime, true);
      store.setState("value", newValue);
    },
    [timeValue, store],
  );
 
  const SecondPrimitive = asChild ? Slot : TimePickerColumn;
 
  const now = new Date();
  const referenceSecond = timeValue?.second ?? now.getSeconds();
 
  return (
    <SecondPrimitive
      data-slot="time-picker-second"
      {...secondProps}
      className={cn(
        "scrollbar-none flex max-h-[200px] flex-col gap-1 overflow-y-auto p-1",
        className,
      )}
    >
      {seconds.map((second) => (
        <TimePickerColumnItem
          key={second}
          value={second}
          selected={referenceSecond === second}
          format={format}
          onClick={() => onSecondSelect(second)}
        />
      ))}
    </SecondPrimitive>
  );
}
 
function TimePickerPeriod(props: DivProps) {
  const { asChild, className, ...periodProps } = props;
 
  const { is12Hour, showSeconds } = useTimePickerContext(PERIOD_NAME);
  const store = useStoreContext(PERIOD_NAME);
 
  const value = useStore((state) => state.value);
  const timeValue = parseTimeString(value);
 
  const onPeriodToggle = React.useCallback(
    (period: Period) => {
      const now = new Date();
      const currentTime = timeValue ?? {};
 
      const currentHour =
        currentTime.hour !== undefined ? currentTime.hour : now.getHours();
      const currentDisplay = to12Hour(currentHour);
      const new24Hour = to24Hour(currentDisplay.hour, period);
 
      const newTime = { ...currentTime, hour: new24Hour };
 
      if (newTime.minute === undefined) {
        newTime.minute = now.getMinutes();
      }
 
      if (showSeconds && newTime.second === undefined) {
        newTime.second = now.getSeconds();
      }
 
      const newValue = formatTimeValue(newTime, showSeconds);
      store.setState("value", newValue);
    },
    [timeValue, showSeconds, store],
  );
 
  if (!is12Hour) return null;
 
  const now = new Date();
  const referenceHour = timeValue?.hour ?? now.getHours();
  const currentPeriod = to12Hour(referenceHour).period;
 
  const PeriodPrimitive = asChild ? Slot : TimePickerColumn;
 
  return (
    <PeriodPrimitive
      data-slot="time-picker-period"
      {...periodProps}
      className={cn("flex flex-col gap-1 p-1", className)}
    >
      {PERIODS.map((period) => (
        <TimePickerColumnItem
          key={period}
          value={period}
          selected={currentPeriod === period}
          onClick={() => onPeriodToggle(period)}
        />
      ))}
    </PeriodPrimitive>
  );
}
 
interface TimePickerSeparatorProps extends React.ComponentProps<"span"> {
  asChild?: boolean;
}
 
function TimePickerSeparator(props: TimePickerSeparatorProps) {
  const { asChild, children, ...separatorProps } = props;
 
  const SeparatorPrimitive = asChild ? Slot : "span";
 
  return (
    <SeparatorPrimitive
      aria-hidden="true"
      data-slot="time-picker-separator"
      {...separatorProps}
    >
      {children ?? ":"}
    </SeparatorPrimitive>
  );
}
 
interface TimePickerClearProps extends ButtonProps {}
 
function TimePickerClear(props: TimePickerClearProps) {
  const {
    asChild,
    className,
    children,
    disabled: disabledProp,
    ...clearProps
  } = props;
 
  const { disabled, readOnly } = useTimePickerContext(CLEAR_NAME);
  const store = useStoreContext(CLEAR_NAME);
 
  const isDisabled = disabledProp || disabled;
 
  const onClick = React.useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      clearProps.onClick?.(event);
      if (event.defaultPrevented) return;
 
      event.preventDefault();
      if (disabled || readOnly) return;
      store.setState("value", "");
    },
    [clearProps.onClick, disabled, readOnly, store],
  );
 
  const ClearPrimitive = asChild ? Slot : "button";
 
  return (
    <ClearPrimitive
      type="button"
      data-slot="time-picker-clear"
      disabled={isDisabled}
      {...clearProps}
      className={cn(
        "inline-flex items-center justify-center rounded-sm font-medium text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50",
        className,
      )}
      onClick={onClick}
    >
      {children ?? "Clear"}
    </ClearPrimitive>
  );
}
 
export {
  TimePickerRoot as Root,
  TimePickerLabel as Label,
  TimePickerInputGroup as InputGroup,
  TimePickerInput as Input,
  TimePickerTrigger as Trigger,
  TimePickerContent as Content,
  TimePickerHour as Hour,
  TimePickerMinute as Minute,
  TimePickerSecond as Second,
  TimePickerPeriod as Period,
  TimePickerSeparator as Separator,
  TimePickerClear as Clear,
  //
  TimePickerRoot as TimePicker,
  TimePickerRoot,
  TimePickerLabel,
  TimePickerInputGroup,
  TimePickerInput,
  TimePickerTrigger,
  TimePickerContent,
  TimePickerHour,
  TimePickerMinute,
  TimePickerSecond,
  TimePickerPeriod,
  TimePickerSeparator,
  TimePickerClear,
  //
  type TimePickerRootProps as TimePickerProps,
};

Layout

Import the parts, and compose them together. Like the native HTML time picker, the inputs are always visible and the trigger opens a dropdown for easier selection.

import { Clock } from "lucide-react";
import * as TimePicker from "@/components/ui/time-picker";

return (
  <TimePicker.Root>
    <TimePicker.Label />
    {/* Inline inputs - always visible, like native time picker */}
    <div className="relative flex items-center">
      <div className="flex h-10 w-full items-center gap-1 rounded-md border border-input bg-background px-3 py-2 pr-10">
        <TimePicker.Input segment="hour" />
        <TimePicker.Separator />
        <TimePicker.Input segment="minute" />
      </div>
      {/* Trigger opens the dropdown */}
      <TimePicker.Trigger className="absolute right-0 flex h-full items-center px-3 hover:bg-accent">
        <Clock className="size-4" />
      </TimePicker.Trigger>
    </div>
    {/* Dropdown content */}
    <TimePicker.Content>
      <TimePicker.Hour />
      <TimePicker.Minute />
      <TimePicker.Clear />
    </TimePicker.Content>
  </TimePicker.Root>
)

Examples

With Step

Use the hourStep, minuteStep, and secondStep props to set custom intervals for hour, minute, and second selection respectively.

import {
  TimePicker,
  TimePickerContent,
  TimePickerHour,
  TimePickerInput,
  TimePickerInputGroup,
  TimePickerLabel,
  TimePickerMinute,
  TimePickerSecond,
  TimePickerSeparator,
  TimePickerTrigger,
} from "@/components/ui/time-picker";
 
export function TimePickerStepDemo() {
  return (
    <TimePicker
      className="w-[280px]"
      defaultValue="10:00"
      minuteStep={15}
      secondStep={10}
    >
      <TimePickerLabel>Meeting Time (15 min intervals)</TimePickerLabel>
      <TimePickerInputGroup>
        <TimePickerInput segment="hour" />
        <TimePickerSeparator />
        <TimePickerInput segment="minute" />
        <TimePickerTrigger />
      </TimePickerInputGroup>
      <TimePickerContent>
        <TimePickerHour />
        <TimePickerMinute />
        <TimePickerSecond />
      </TimePickerContent>
    </TimePicker>
  );
}

With Seconds

Include seconds in time selection.

"use client";
 
import {
  TimePicker,
  TimePickerContent,
  TimePickerHour,
  TimePickerInput,
  TimePickerInputGroup,
  TimePickerLabel,
  TimePickerMinute,
  TimePickerSecond,
  TimePickerSeparator,
  TimePickerTrigger,
} from "@/components/ui/time-picker";
 
export function TimePickerSecondsDemo() {
  return (
    <TimePicker defaultValue="14:30:45" className="w-[280px]" showSeconds>
      <TimePickerLabel>Select Time with Seconds</TimePickerLabel>
      <TimePickerInputGroup>
        <TimePickerInput segment="hour" />
        <TimePickerSeparator />
        <TimePickerInput segment="minute" />
        <TimePickerSeparator />
        <TimePickerInput segment="second" />
        <TimePickerTrigger />
      </TimePickerInputGroup>
      <TimePickerContent>
        <TimePickerHour />
        <TimePickerMinute />
        <TimePickerSecond />
      </TimePickerContent>
    </TimePicker>
  );
}

Custom Placeholders

Customize empty segment placeholders for different display formats.

import {
  TimePicker,
  TimePickerContent,
  TimePickerHour,
  TimePickerInput,
  TimePickerInputGroup,
  TimePickerLabel,
  TimePickerMinute,
  TimePickerPeriod,
  TimePickerSeparator,
  TimePickerTrigger,
} from "@/components/ui/time-picker";
 
export function TimePickerPlaceholderDemo() {
  return (
    <div className="flex flex-col gap-6">
      <TimePicker className="w-[280px]" segmentPlaceholder="--">
        <TimePickerLabel>Default (--)</TimePickerLabel>
        <TimePickerInputGroup>
          <TimePickerInput segment="hour" />
          <TimePickerSeparator />
          <TimePickerInput segment="minute" />
          <TimePickerInput segment="period" />
          <TimePickerTrigger />
        </TimePickerInputGroup>
        <TimePickerContent>
          <TimePickerHour />
          <TimePickerMinute />
          <TimePickerPeriod />
        </TimePickerContent>
      </TimePicker>
 
      <TimePicker
        className="w-[280px]"
        segmentPlaceholder={{ hour: "hh", minute: "mm", period: "aa" }}
      >
        <TimePickerLabel>Custom (hh:mm aa)</TimePickerLabel>
        <TimePickerInputGroup>
          <TimePickerInput segment="hour" />
          <TimePickerSeparator />
          <TimePickerInput segment="minute" />
          <TimePickerInput segment="period" />
          <TimePickerTrigger />
        </TimePickerInputGroup>
        <TimePickerContent>
          <TimePickerHour />
          <TimePickerMinute />
          <TimePickerPeriod />
        </TimePickerContent>
      </TimePicker>
    </div>
  );
}

Controlled State

Control the time picker value programmatically.

"use client";
 
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
  TimePicker,
  TimePickerContent,
  TimePickerHour,
  TimePickerInput,
  TimePickerInputGroup,
  TimePickerLabel,
  TimePickerMinute,
  TimePickerSeparator,
  TimePickerTrigger,
} from "@/components/ui/time-picker";
 
export function TimePickerControlledDemo() {
  const [value, setValue] = React.useState("14:30");
 
  return (
    <div className="flex flex-col gap-4">
      <TimePicker className="w-[280px]" value={value} onValueChange={setValue}>
        <TimePickerLabel>Controlled Time Picker</TimePickerLabel>
        <TimePickerInputGroup>
          <TimePickerInput segment="hour" />
          <TimePickerSeparator />
          <TimePickerInput segment="minute" />
          <TimePickerTrigger />
        </TimePickerInputGroup>
        <TimePickerContent>
          <TimePickerHour />
          <TimePickerMinute />
        </TimePickerContent>
      </TimePicker>
      <div className="flex gap-2">
        <Button variant="outline" size="sm" onClick={() => setValue("09:00")}>
          Set 9:00 AM
        </Button>
        <Button variant="outline" size="sm" onClick={() => setValue("14:30")}>
          Set 2:30 PM
        </Button>
        <Button variant="outline" size="sm" onClick={() => setValue("")}>
          Clear
        </Button>
      </div>
      <div className="text-muted-foreground text-sm">
        Selected time:{" "}
        <span className="font-mono font-semibold">{value || "None"}</span>
      </div>
    </div>
  );
}

With Form

Use the time picker in a form with validation.

"use client";
 
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
 
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import {
  TimePicker,
  TimePickerContent,
  TimePickerHour,
  TimePickerInput,
  TimePickerInputGroup,
  TimePickerMinute,
  TimePickerPeriod,
  TimePickerSeparator,
  TimePickerTrigger,
} from "@/components/ui/time-picker";
 
const formSchema = z.object({
  appointmentTime: z.string().min(1, {
    message: "Please select an appointment time.",
  }),
});
 
type FormSchema = z.infer<typeof formSchema>;
 
export function TimePickerFormDemo() {
  const form = useForm<FormSchema>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      appointmentTime: "09:00",
    },
  });
 
  const onSubmit = React.useCallback((data: FormSchema) => {
    toast.success(`Appointment scheduled for: ${data.appointmentTime}`);
  }, []);
 
  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className="flex w-[280px] flex-col gap-4"
      >
        <FormField
          control={form.control}
          name="appointmentTime"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Appointment time</FormLabel>
              <FormControl>
                <TimePicker
                  value={field.value}
                  onValueChange={field.onChange}
                  className="w-[280px]"
                >
                  <TimePickerInputGroup>
                    <TimePickerInput segment="hour" />
                    <TimePickerSeparator />
                    <TimePickerInput segment="minute" />
                    <TimePickerInput segment="period" />
                    <TimePickerTrigger />
                  </TimePickerInputGroup>
                  <TimePickerContent>
                    <TimePickerHour />
                    <TimePickerMinute />
                    <TimePickerPeriod />
                  </TimePickerContent>
                </TimePicker>
              </FormControl>
              <FormDescription>
                Select your preferred appointment time.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Schedule appointment</Button>
      </form>
    </Form>
  );
}

API Reference

Root

The main container component for time picker functionality.

Prop

Type

Data AttributeValue
[data-disabled]Present when the time picker is disabled
[data-invalid]Present when the time picker is invalid
[data-readonly]Present when the time picker is read-only

Label

The label component for the time picker field.

Prop

Type

InputGroup

The container for input segments that sets up CSS variables for dynamic segment widths.

Prop

Type

Data AttributeValue
[data-disabled]Present when the time picker is disabled
[data-invalid]Present when the time picker is invalid
CSS VariableDescriptionDefault
--time-picker-hour-input-widthThe width of the hour input segment, dynamically calculated based on the hour placeholder length.2ch (for '--' placeholder)
--time-picker-minute-input-widthThe width of the minute input segment, dynamically calculated based on the minute placeholder length.2ch (for '--' placeholder)
--time-picker-second-input-widthThe width of the second input segment, dynamically calculated based on the second placeholder length.2ch (for '--' placeholder)
--time-picker-period-input-widthThe width of the period (AM/PM) input segment, dynamically calculated based on the period placeholder length.2ch (for '--' placeholder)

Must use style prop to override the css variables of the input segment, because the width is dynamically calculated based on the placeholder length.

Trigger

Button to open the time picker dropdown.

Prop

Type

Data AttributeValue
[data-state]'open' | 'closed'
[data-disabled]Present when the time picker is disabled
[data-readonly]Present when the time picker is read-only
[data-invalid]Present when the time picker is invalid

Content

Container for the time selection interface.

Prop

Type

Data AttributeValue
[data-state]'open' | 'closed'
[data-side]The side where the content is positioned
[data-align]The alignment of the content

Hour

Component for selecting hours.

Prop

Type

Minute

Component for selecting minutes.

Prop

Type

Second

Component for selecting seconds.

Prop

Type

Period

Component for selecting AM/PM in 12-hour format.

Prop

Type

Separator

Visual separator between time units.

Prop

Type

Clear

Button to clear the selected time.

Prop

Type

Input

Inline editable input field for time segments (hour, minute, second, period).

Prop

Type

CSS VariableDescription
--time-picker-hour-input-widthCan be set directly on the hour segment input to override the width for that specific input.
--time-picker-minute-input-widthCan be set directly on the minute segment input to override the width for that specific input.
--time-picker-second-input-widthCan be set directly on the second segment input to override the width for that specific input.
--time-picker-period-input-widthCan be set directly on the period segment input to override the width for that specific input.

Accessibility

Keyboard Interactions

Input Navigation

KeyDescription
TabMoves focus to the next segment (hour → minute → second → period)
ShiftTabMoves focus to the previous segment
ArrowLeftMoves focus to the previous segment
ArrowRightMoves focus to the next segment

Value Editing

KeyDescription
0-9Type digits to enter time values. Auto-pads single digits (1 → 01) and auto-advances after two digits
ArrowUpIncrements the focused segment value (wraps around at max)
ArrowDownDecrements the focused segment value (wraps around at min)
BackspaceDeleteClears the focused segment back to placeholder (--)
EnterConfirms input and removes focus
EscapeCancels editing and restores previous value

Period (AM/PM) Shortcuts

KeyDescription
A1Sets period to AM
P2Sets period to PM
ArrowUpArrowDownToggles between AM and PM
KeyDescription
SpaceOpens/closes the dropdown when trigger is focused
EnterSelects highlighted option in dropdown
EscapeCloses the dropdown
ArrowUpNavigates to previous option in dropdown list
ArrowDownNavigates to next option in dropdown list

Notes

Behavior

  • Native HTML time input behavior: Replicates the exact behavior of <input type="time"> for maximum familiarity
    • Auto-pads single digits instantly (typing "1" shows "01")
    • Smart auto-advance after two digits or when digit exceeds maximum first digit
    • Partial time values supported (e.g., "10:--" instead of "10:00")
    • Selection preserved after actions for seamless typing
  • Inline editing: All time segments are always visible and editable, no need to open the dropdown
  • Dropdown for convenience: The clock icon trigger opens a dropdown for easier selection with mouse/touch
  • Keyboard-first design: Full keyboard navigation between segments using arrow keys or tab
  • Clear segments: Press Backspace or Delete on a selected segment to clear it back to "--"

Format & Locale

  • Automatic format detection: Display format (12-hour vs 24-hour) is automatically detected from user's locale settings
  • Consistent value format: The time value is always stored in 24-hour format ("HH:mm" or "HH:mm:ss"), regardless of display format
  • Locale override: Use the locale prop to explicitly set a locale (e.g., "en-US" for 12-hour, "en-GB" for 24-hour)
  • Period shortcuts: In 12-hour format, use A/P or 1/2 keys to quickly set AM/PM

Customization

  • The minuteStep, hourStep, and secondStep props allow customization of selection intervals
  • Use the min and max props to restrict the selectable time range
  • The showSeconds prop enables/disables second selection
  • Use the segmentPlaceholder prop to customize empty segment display:
    • String format: segmentPlaceholder="--" applies the same placeholder to all segments
    • Object format: segmentPlaceholder={{ hour: "hh", minute: "mm", second: "ss", period: "aa" }} for per-segment control
  • CSS variables are automatically set on InputGroup based on placeholder length for proper input sizing
  • Override CSS variables for custom widths:
    • On InputGroup: <TimePickerInputGroup className="[--time-picker-hour-input-width:4ch]">
    • On individual Input: <TimePickerInput segment="hour" className="[--time-picker-hour-input-width:4ch]" />