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.
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-slotCopy 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 Attribute | Value |
|---|---|
[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 Attribute | Value |
|---|---|
[data-disabled] | Present when the time picker is disabled |
[data-invalid] | Present when the time picker is invalid |
| CSS Variable | Description | Default |
|---|---|---|
--time-picker-hour-input-width | The width of the hour input segment, dynamically calculated based on the hour placeholder length. | 2ch (for '--' placeholder) |
--time-picker-minute-input-width | The width of the minute input segment, dynamically calculated based on the minute placeholder length. | 2ch (for '--' placeholder) |
--time-picker-second-input-width | The width of the second input segment, dynamically calculated based on the second placeholder length. | 2ch (for '--' placeholder) |
--time-picker-period-input-width | The 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 Attribute | Value |
|---|---|
[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 Attribute | Value |
|---|---|
[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 Variable | Description |
|---|---|
--time-picker-hour-input-width | Can be set directly on the hour segment input to override the width for that specific input. |
--time-picker-minute-input-width | Can be set directly on the minute segment input to override the width for that specific input. |
--time-picker-second-input-width | Can be set directly on the second segment input to override the width for that specific input. |
--time-picker-period-input-width | Can be set directly on the period segment input to override the width for that specific input. |
Accessibility
Keyboard Interactions
Input Navigation
| Key | Description |
|---|---|
| Tab | Moves focus to the next segment (hour → minute → second → period) |
| ShiftTab | Moves focus to the previous segment |
| ArrowLeft | Moves focus to the previous segment |
| ArrowRight | Moves focus to the next segment |
Value Editing
| Key | Description |
|---|---|
| 0-9 | Type digits to enter time values. Auto-pads single digits (1 → 01) and auto-advances after two digits |
| ArrowUp | Increments the focused segment value (wraps around at max) |
| ArrowDown | Decrements the focused segment value (wraps around at min) |
| BackspaceDelete | Clears the focused segment back to placeholder (--) |
| Enter | Confirms input and removes focus |
| Escape | Cancels editing and restores previous value |
Period (AM/PM) Shortcuts
| Key | Description |
|---|---|
| A1 | Sets period to AM |
| P2 | Sets period to PM |
| ArrowUpArrowDown | Toggles between AM and PM |
Dropdown Interactions
| Key | Description |
|---|---|
| Space | Opens/closes the dropdown when trigger is focused |
| Enter | Selects highlighted option in dropdown |
| Escape | Closes the dropdown |
| ArrowUp | Navigates to previous option in dropdown list |
| ArrowDown | Navigates 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
localeprop 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, andsecondStepprops allow customization of selection intervals - Use the
minandmaxprops to restrict the selectable time range - The
showSecondsprop enables/disables second selection - Use the
segmentPlaceholderprop 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
- String format:
- CSS variables are automatically set on
InputGroupbased 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]" />
- On