Time picker
Pick a time of day as a segmented field, a list in steps with durations, scroll wheels, a clock face or a from to range. 24 hours in German, AM and PM in English.
One of 7 components in Pickers and selections
Variants
Details
What Time picker brings along, read from the registry and the source.
- Category
- Pickers and selections
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/time-picker.tsx
- npm packages
- None
- Languages
- English and German, 17 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TimePickerProps interface from components/ui/time-picker.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.
variantoptionalTimePickerVariantfieldlistwheeldialrange
valueoptionalstring | null"HH:MM" in 24 hours
defaultValueoptionalstring | nullonValueChangeoptional(value: string) => voidendValueoptionalstringrange: the end, "HH:MM"
defaultEndValueoptionalstringonEndValueChangeoptional(value: string) => voidstepoptionalnumberMinutes per step, e.g. 5, 15 or 30. Default 15 (list, wheel, range) and 1 (field, dial).
minoptionalstringEarliest and latest time, "HH:MM"
maxoptionalstringdurationFromoptionalstringlist: show the duration from this time next to each option, e.g. for an end time
hour12optionalboolean12 hours with AM and PM. Default: English yes, German no.
sizeoptionalControlSizenameoptionalstringidoptionalstringdisabledoptionalbooleanrequiredoptionalbooleaninvalidoptionalbooleanplaceholderoptionalstringaria-labeloptionalstringAccessible name when there is no visible label
Built-in texts
Time picker writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 17 texts
| Key | English | German |
|---|---|---|
| hours | English Hours | German Stunden |
| minutes | English Minutes | German Minuten |
| period | English AM or PM | German Vormittag oder Nachmittag |
| am | English AM | German AM |
| pm | English PM | German PM |
| placeholderH | English hh | German hh |
| placeholderM | English mm | German mm |
| choose | English Choose a time | German Uhrzeit wählen |
| from | English From | German Von |
| to | English To | German Bis |
| duration | English Duration | German Dauer |
| hourMode | English Choose the hour | German Stunde wählen |
| minuteMode | English Choose the minutes | German Minuten wählen |
| h | English h | German Std. |
| min | English min | German Min. |
| nextDay | English next day | German Folgetag |
| clock | English Clock | German Uhr |
Accessibility
Read from the source of the component.
- Time picker handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Backspace and Space itself.
- Sets aria-checked, aria-describedby, aria-disabled, aria-label, aria-labelledby, aria-live, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles group, radio, radiogroup, slider and spinbutton.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 627 lines
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { CONTROL_HEIGHT, Label, controlBox, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
/*
* Pick a time of day. Values are "HH:MM" in 24 hours, the display follows the
* language (14:30 or 2:30 PM). Five looks: a segmented field where every part
* is a spin button (type digits, arrows step), a list in steps with the
* duration from a start, scroll wheels as on a phone, a clock face you drag
* or steer with arrows, and a from to field that keeps the duration when the
* start moves.
*/
const TEXTS = {
en: {
hours: 'Hours', minutes: 'Minutes', period: 'AM or PM', am: 'AM', pm: 'PM', placeholderH: 'hh', placeholderM: 'mm', choose: 'Choose a time',
from: 'From', to: 'To', duration: 'Duration', hourMode: 'Choose the hour', minuteMode: 'Choose the minutes', h: 'h', min: 'min', nextDay: 'next day', clock: 'Clock',
},
de: {
hours: 'Stunden', minutes: 'Minuten', period: 'Vormittag oder Nachmittag', am: 'AM', pm: 'PM', placeholderH: 'hh', placeholderM: 'mm', choose: 'Uhrzeit wählen',
from: 'Von', to: 'Bis', duration: 'Dauer', hourMode: 'Stunde wählen', minuteMode: 'Minuten wählen', h: 'Std.', min: 'Min.', nextDay: 'Folgetag', clock: 'Uhr',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Time helpers */
/* ------------------------------------------------------------------ */
const pad = (n: number) => String(n).padStart(2, '0');
export const toMinutes = (t: string) => {
const [h, m] = t.split(':').map(Number);
return (h || 0) * 60 + (m || 0);
};
export const fromMinutes = (n: number) => {
const x = ((Math.round(n) % 1440) + 1440) % 1440;
return `${pad(Math.floor(x / 60))}:${pad(x % 60)}`;
};
/** 14:30 in German, 2:30 PM in English (or as chosen with hour12) */
export function formatTime(t: string, locale: 'en' | 'de', hour12 = locale === 'en') {
const [h, m] = t.split(':').map(Number);
if (!hour12) return `${h}:${pad(m)}`;
return `${h % 12 || 12}:${pad(m)} ${h < 12 ? 'AM' : 'PM'}`;
}
/** 90 → "1 h 30 min" */
export function formatDuration(minutes: number, t: Pick<Texts, 'h' | 'min'>) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return [h && `${h} ${t.h}`, m && `${m} ${t.min}`].filter(Boolean).join(' ') || `0 ${t.min}`;
}
export type TimePickerVariant = 'field' | 'list' | 'wheel' | 'dial' | 'range';
export interface TimePickerProps extends FieldOwnProps {
variant?: TimePickerVariant;
/** "HH:MM" in 24 hours */
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string) => void;
/** range: the end, "HH:MM" */
endValue?: string;
defaultEndValue?: string;
onEndValueChange?: (value: string) => void;
/** Minutes per step, e.g. 5, 15 or 30. Default 15 (list, wheel, range) and 1 (field, dial). */
step?: number;
/** Earliest and latest time, "HH:MM" */
min?: string;
max?: string;
/** list: show the duration from this time next to each option, e.g. for an end time */
durationFrom?: string;
/** 12 hours with AM and PM. Default: English yes, German no. */
hour12?: boolean;
size?: ControlSize;
name?: string;
id?: string;
disabled?: boolean;
required?: boolean;
invalid?: boolean;
placeholder?: string;
/** Accessible name when there is no visible label */
'aria-label'?: string;
labels?: Partial<Texts>;
className?: string;
}
/* ------------------------------------------------------------------ */
/* Segmented field */
/* ------------------------------------------------------------------ */
const SEG =
'relative inline-grid h-10 min-w-11 place-items-center rounded-[5px] px-[3px] sm:h-7 sm:min-w-[2ch] tabular-nums outline-none transition-[background-color,color] duration-150 caret-transparent focus:bg-[color-mix(in_oklab,var(--primary)_18%,transparent)] focus:text-foreground sm:h-7';
/** One spin button segment: digits typed in, arrows step, wraps around */
function Segment({ value, min, max, step = 1, label, placeholder, text, onChange, onFull, onBack, disabled, className, inputRef }: {
value: number | null; min: number; max: number; step?: number; label: string; placeholder: string; text?: string; onChange: (v: number) => void; onFull?: () => void; onBack?: () => void; disabled?: boolean; className?: string; inputRef?: (el: HTMLSpanElement | null) => void;
}) {
const typed = useRef('');
const span = max - min + 1;
const wrap = (v: number) => ((((v - min) % span) + span) % span) + min;
return (
<span
ref={inputRef}
role="spinbutton"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value ?? undefined}
aria-valuetext={value === null ? placeholder : text ?? String(value)}
aria-disabled={disabled || undefined}
onBlur={() => (typed.current = '')}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
typed.current = '';
const d = e.key === 'ArrowUp' ? step : -step;
onChange(value === null ? (d > 0 ? min : max - ((max - min) % step)) : wrap(Math.round((value + d) / step) * step));
} else if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
onChange(e.key === 'Home' ? min : max);
} else if (/^\d$/.test(e.key)) {
e.preventDefault();
const next = typed.current + e.key;
const n = Number(next);
if (n > max) {
typed.current = e.key;
onChange(Math.max(min, Number(e.key)));
} else {
typed.current = next;
onChange(Math.max(min, n));
}
// Two digits, or one that cannot start a larger number, move on to the next part
if (typed.current.length >= String(max).length || Number(typed.current) * 10 > max) {
typed.current = '';
onFull?.();
}
} else if (e.key === 'Backspace') {
e.preventDefault();
typed.current = '';
onBack?.();
}
}}
className={cn(SEG, value === null && 'text-subtle-foreground', className)}
>
{value === null ? placeholder : text ?? pad(value)}
</span>
);
}
function TimeSegments({ value, onChange, hour12, t, disabled, step = 1, labelledBy, describedBy }: { value: string | null; onChange: (v: string) => void; hour12: boolean; t: Texts; disabled?: boolean; step?: number; labelledBy?: string; describedBy?: string }) {
const refs = useRef<(HTMLSpanElement | null)[]>([]);
const h24 = value ? Number(value.split(':')[0]) : null;
const m = value ? Number(value.split(':')[1]) : null;
const pm = h24 !== null && h24 >= 12;
const setH = (h: number) => onChange(`${pad(hour12 ? (h % 12) + (pm ? 12 : 0) : h)}:${pad(m ?? 0)}`);
const setM = (n: number) => onChange(`${pad(h24 ?? 9)}:${pad(n)}`);
const go = (i: number) => refs.current[i]?.focus();
const shownH = h24 === null ? null : hour12 ? h24 % 12 || 12 : h24;
return (
<span role="group" aria-labelledby={labelledBy} aria-describedby={describedBy} className="inline-flex items-center">
<Segment inputRef={(el) => (refs.current[0] = el)} value={shownH} min={hour12 ? 1 : 0} max={hour12 ? 12 : 23} label={t.hours} placeholder={t.placeholderH} text={shownH === null ? undefined : hour12 ? String(shownH) : pad(shownH)} onChange={setH} onFull={() => go(1)} disabled={disabled} />
<span aria-hidden className="px-px text-subtle-foreground">:</span>
<Segment inputRef={(el) => (refs.current[1] = el)} value={m} min={0} max={59} step={step} label={t.minutes} placeholder={t.placeholderM} onChange={setM} onFull={() => hour12 && go(2)} onBack={() => go(0)} disabled={disabled} />
{hour12 && (
<span
ref={(el) => {
refs.current[2] = el;
}}
role="spinbutton"
tabIndex={disabled ? -1 : 0}
aria-label={t.period}
aria-valuemin={0}
aria-valuemax={1}
aria-valuenow={pm ? 1 : 0}
aria-valuetext={h24 === null ? undefined : pm ? t.pm : t.am}
onKeyDown={(e) => {
if (disabled || h24 === null) return;
const k = e.key.toLowerCase();
if (['arrowup', 'arrowdown'].includes(k) || (k === 'a' && pm) || (k === 'p' && !pm)) {
e.preventDefault();
onChange(`${pad((h24 + 12) % 24)}:${pad(m ?? 0)}`);
} else if (k === 'backspace') {
e.preventDefault();
go(1);
}
}}
className={cn(SEG, 'ms-1 px-1.5 text-[0.86em] font-medium', h24 === null && 'text-subtle-foreground')}
>
{pm ? t.pm : t.am}
</span>
)}
</span>
);
}
/* ------------------------------------------------------------------ */
/* Wheel */
/* ------------------------------------------------------------------ */
const ROW = 40;
/** One scroll snapping column. Scrolling, a click on a row, or the arrow keys choose. */
function Wheel({ items, index, onIndex, label, format }: { items: number[]; index: number; onIndex: (i: number) => void; label: string; format: (n: number) => string }) {
const ref = useRef<HTMLDivElement>(null);
const settling = useRef<number | undefined>(undefined);
const fromScroll = useRef(false);
// Bring the chosen row to the middle when the value changes from outside
useEffect(() => {
const el = ref.current;
if (!el) return;
if (fromScroll.current) {
fromScroll.current = false;
return;
}
el.scrollTo({ top: index * ROW, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}, [index]);
return (
<div className="relative h-[200px] w-16 sm:w-[4.5rem]">
<div
ref={ref}
role="spinbutton"
tabIndex={0}
aria-label={label}
aria-valuenow={items[index]}
aria-valuemin={items[0]}
aria-valuemax={items[items.length - 1]}
aria-valuetext={format(items[index])}
onScroll={(e) => {
const el = e.currentTarget;
window.clearTimeout(settling.current);
settling.current = window.setTimeout(() => {
const i = Math.max(0, Math.min(items.length - 1, Math.round(el.scrollTop / ROW)));
if (i !== index) {
fromScroll.current = true;
onIndex(i);
}
}, 90);
}}
onKeyDown={(e) => {
const d = { ArrowDown: 1, ArrowUp: -1, PageDown: 5, PageUp: -5 }[e.key];
if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
return onIndex(e.key === 'Home' ? 0 : items.length - 1);
}
if (!d) return;
e.preventDefault();
onIndex(Math.max(0, Math.min(items.length - 1, index + d)));
}}
className="kit-no-scrollbar h-full snap-y snap-mandatory overflow-y-auto overscroll-contain rounded-lg outline-none [mask-image:linear-gradient(to_bottom,transparent,#000_30%,#000_70%,transparent)] focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]"
>
<div style={{ height: 80 }} aria-hidden />
{items.map((n, i) => (
<div
key={n}
aria-hidden
onClick={() => onIndex(i)}
className={cn('flex h-10 cursor-pointer snap-center items-center justify-center text-lg tabular-nums transition-[color,scale] duration-200', i === index ? 'scale-110 font-semibold text-foreground' : 'text-muted-foreground')}
>
{format(n)}
</div>
))}
<div style={{ height: 80 }} aria-hidden />
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Dial */
/* ------------------------------------------------------------------ */
const R_OUT = 104;
const R_IN = 70;
function Dial({ value, onChange, hour12, t, step }: { value: string; onChange: (v: string) => void; hour12: boolean; t: Texts; step: number }) {
const [mode, setMode] = useState<'h' | 'm'>('h');
const [h, m] = value.split(':').map(Number);
const ref = useRef<SVGSVGElement>(null);
const drag = useRef(false);
const pm = h >= 12;
const minuteStep = Math.max(1, step);
const angleFor = (n: number, of: number) => (n / of) * 360;
const target = mode === 'h' ? angleFor(h % 12, 12) : angleFor(m, 60);
// The hand takes the short way: from 330 to 0 it turns 30 degrees forward, not 330 back
const turned = useRef(target);
const prev = turned.current;
const handAngle = prev + ((((target - prev) % 360) + 540) % 360) - 180;
useEffect(() => {
turned.current = handAngle;
});
const inner = mode === 'h' && !hour12 && (h === 0 || h > 12);
const handLen = inner ? R_IN : R_OUT;
const fromPoint = (x: number, y: number, finish: boolean) => {
const box = ref.current!.getBoundingClientRect();
const cx = box.left + box.width / 2;
const cy = box.top + box.height / 2;
const scale = box.width / 260;
const dx = (x - cx) / scale;
const dy = (y - cy) / scale;
const ang = (Math.atan2(dx, -dy) * 180) / Math.PI;
const a = (ang + 360) % 360;
if (mode === 'h') {
let hour = Math.round(a / 30) % 12;
const dist = Math.hypot(dx, dy);
if (hour12) hour = hour + (pm ? 12 : 0);
else if (dist < (R_IN + R_OUT) / 2) hour = hour === 0 ? 0 : hour + 12;
else if (hour === 0) hour = 12;
onChange(`${pad(hour % 24)}:${pad(m)}`);
if (finish) setMode('m');
} else {
const mins = (Math.round(a / (6 * minuteStep)) * minuteStep) % 60;
onChange(`${pad(h)}:${pad(mins)}`);
}
};
const key = (e: React.KeyboardEvent) => {
const d = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 5, PageDown: -5 }[e.key];
if (e.key === 'Enter' && mode === 'h') {
e.preventDefault();
return setMode('m');
}
if (!d) return;
e.preventDefault();
if (mode === 'h') onChange(`${pad((h + d + 24) % 24)}:${pad(m)}`);
else onChange(fromMinutes(h * 60 + m + d * minuteStep).replace(/^\d\d/, pad(h)));
};
const hours = Array.from({ length: 12 }, (_, i) => i || 12);
const big = (n: number) => pad(n);
const shownH = hour12 ? h % 12 || 12 : h;
return (
<div className="grid justify-items-center gap-4">
<div className="flex items-center gap-2">
<div className="flex items-baseline gap-1 text-[2.75rem] font-semibold leading-none tracking-[-0.03em] tabular-nums">
<button type="button" aria-pressed={mode === 'h'} aria-label={t.hourMode} onClick={() => setMode('h')} className={cn('min-w-11 rounded-lg px-2 py-1 outline-none transition-colors focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]', mode === 'h' ? 'bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-primary' : 'text-foreground hover:bg-accent')}>
{hour12 ? shownH : big(shownH)}
</button>
<span aria-hidden className="text-subtle-foreground">:</span>
<button type="button" aria-pressed={mode === 'm'} aria-label={t.minuteMode} onClick={() => setMode('m')} className={cn('min-w-11 rounded-lg px-2 py-1 outline-none transition-colors focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]', mode === 'm' ? 'bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-primary' : 'text-foreground hover:bg-accent')}>
{pad(m)}
</button>
</div>
{hour12 && (
<div
role="radiogroup"
aria-label={t.period}
onKeyDown={(e) => {
// Radio group after the APG: arrows switch between AM and PM, one tab stop
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
e.preventDefault();
onChange(`${pad((h + 12) % 24)}:${pad(m)}`);
const group = e.currentTarget;
requestAnimationFrame(() => group.querySelector<HTMLElement>('[aria-checked="true"]')?.focus());
}}
className="ms-2 grid overflow-hidden rounded-lg border border-border text-[13px] font-medium"
>
{[false, true].map((isPm) => (
<button
key={String(isPm)}
type="button"
role="radio"
aria-checked={pm === isPm}
tabIndex={pm === isPm ? 0 : -1}
onClick={() => pm !== isPm && onChange(`${pad((h + 12) % 24)}:${pad(m)}`)}
className={cn('h-8 min-w-11 px-3 outline-none transition-colors focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] max-sm:h-11', isPm && 'border-t border-border', pm === isPm ? 'bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-primary' : 'text-muted-foreground hover:bg-accent')}
>
{isPm ? t.pm : t.am}
</button>
))}
</div>
)}
</div>
<svg
ref={ref}
viewBox="-130 -130 260 260"
role="slider"
tabIndex={0}
aria-label={mode === 'h' ? t.hourMode : t.minuteMode}
aria-valuemin={0}
aria-valuemax={mode === 'h' ? 23 : 59}
aria-valuenow={mode === 'h' ? h : m}
aria-valuetext={mode === 'h' ? `${shownH} ${t.hours}` : `${m} ${t.minutes}`}
onKeyDown={key}
onPointerDown={(e) => {
drag.current = true;
(e.currentTarget as Element).setPointerCapture(e.pointerId);
fromPoint(e.clientX, e.clientY, false);
}}
onPointerMove={(e) => drag.current && fromPoint(e.clientX, e.clientY, false)}
onPointerUp={(e) => {
if (!drag.current) return;
drag.current = false;
fromPoint(e.clientX, e.clientY, true);
}}
className="aspect-square w-full max-w-[17rem] touch-none select-none rounded-full outline-none focus-visible:shadow-[0_0_0_2px_var(--background),0_0_0_4px_color-mix(in_oklab,var(--primary)_70%,transparent)]"
>
<circle r="128" className="fill-[color-mix(in_oklab,var(--foreground)_5%,var(--card))]" />
{/* The hand turns smoothly between values, never the long way around */}
<g style={{ transform: `rotate(${round(handAngle, 2)}deg)`, transition: 'transform .35s var(--ease-kit)' }} className="motion-reduce:!transition-none">
<line x1="0" y1="0" x2="0" y2={-handLen + 16} className="stroke-primary" strokeWidth="2" />
<circle cy={-handLen} r="17" className="fill-primary" style={{ transition: 'cy .3s var(--ease-kit)' }} />
</g>
<circle r="3.5" className="fill-primary" />
{mode === 'h'
? hours.map((n, i) => {
const a = (i / 12) * 2 * Math.PI;
const x = round(Math.sin(a) * R_OUT, 2);
const y = round(-Math.cos(a) * R_OUT, 2);
const on = hour12 ? (h % 12 || 12) === n : h === n;
return (
<g key={`o${n}`} aria-hidden>
<text x={x} y={y} dy="0.35em" textAnchor="middle" className={cn('text-[15px] font-medium tabular-nums', on ? 'fill-primary-foreground' : 'fill-foreground')}>
{n}
</text>
{!hour12 && (
<text x={round(Math.sin(a) * R_IN, 2)} y={round(-Math.cos(a) * R_IN, 2)} dy="0.35em" textAnchor="middle" className={cn('text-[12px] tabular-nums', h === (n === 12 ? 0 : n + 12) ? 'fill-primary-foreground font-medium' : 'fill-muted-foreground')}>
{n === 12 ? '00' : n + 12}
</text>
)}
</g>
);
})
: Array.from({ length: 12 }, (_, i) => i * 5).map((n, i) => {
const a = (i / 12) * 2 * Math.PI;
return (
<text key={`m${n}`} aria-hidden x={round(Math.sin(a) * R_OUT, 2)} y={round(-Math.cos(a) * R_OUT, 2)} dy="0.35em" textAnchor="middle" className={cn('text-[15px] font-medium tabular-nums', m === n ? 'fill-primary-foreground' : 'fill-foreground')}>
{pad(n)}
</text>
);
})}
</svg>
</div>
);
}
/* ------------------------------------------------------------------ */
/* TimePicker */
/* ------------------------------------------------------------------ */
function TimeBox(props: Omit<TimePickerProps, keyof FieldOwnProps> & { labelId?: string }) {
const {
variant = 'field', value: valueProp, defaultValue, onValueChange, endValue, defaultEndValue, onEndValueChange, step, min = '00:00', max = '23:59', durationFrom, hour12: h12, size = 'md',
name, id, disabled, required, invalid, placeholder, labels, className, labelId,
} = props;
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const hour12 = h12 ?? locale === 'en';
const c = useControl({ id, invalid, disabled, required });
const [inner, setInner] = useState<string | null>(defaultValue === undefined ? (variant === 'field' ? null : '09:00') : defaultValue);
const value = valueProp === undefined ? inner : valueProp;
const set = (v: string) => {
setInner(v);
onValueChange?.(v);
};
const boxRef = useRef<HTMLDivElement>(null);
useShake(boxRef, c.invalid);
const hidden = name ? <input type="hidden" name={name} value={value ?? ''} /> : null;
const fmt = (v: string) => formatTime(v, locale, hour12);
/* ---------- list ---------- */
const listStep = step ?? 15;
const options = useMemo(() => {
const out = [];
for (let n = toMinutes(min); n <= toMinutes(max); n += listStep) {
const v = fromMinutes(n);
const d = durationFrom ? n - toMinutes(durationFrom) : null;
out.push({ value: v, label: fmt(v), meta: d !== null && d > 0 ? formatDuration(d, t) : undefined, disabled: d !== null && d <= 0 });
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [min, max, listStep, durationFrom, locale, hour12]);
if (variant === 'list') {
return <Select id={c.id} options={options} value={value ?? undefined} onValueChange={set} placeholder={placeholder ?? t.choose} icon="clock" size={size} name={name} disabled={c.disabled} required={c.required} invalid={c.invalid} className={className} />;
}
/* ---------- wheel ---------- */
if (variant === 'wheel') {
const v = value ?? '09:00';
const [H, M] = v.split(':').map(Number);
const mStep = step ?? 5;
const hourItems = hour12 ? Array.from({ length: 12 }, (_, i) => i + 1) : Array.from({ length: 24 }, (_, i) => i);
const minuteItems = Array.from({ length: Math.ceil(60 / mStep) }, (_, i) => i * mStep);
const pm = H >= 12;
const hIndex = hourItems.indexOf(hour12 ? H % 12 || 12 : H);
const mIndex = Math.max(0, minuteItems.indexOf(Math.round(M / mStep) * mStep));
return (
<div role="group" aria-labelledby={labelId} aria-label={labelId ? undefined : props['aria-label']} className={cn('relative inline-flex items-center gap-1 rounded-xl border border-border bg-card px-3 shadow-[var(--highlight),var(--shadow-sm)]', className)}>
{/* The band that marks the chosen row */}
<span aria-hidden className="pointer-events-none absolute inset-x-2 top-1/2 h-10 -translate-y-1/2 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] shadow-[inset_0_0_0_1px_var(--border)]" />
<Wheel items={hourItems} index={hIndex} label={t.hours} format={(n) => (hour12 ? String(n) : pad(n))} onIndex={(i) => set(`${pad(hour12 ? (hourItems[i] % 12) + (pm ? 12 : 0) : hourItems[i])}:${pad(M)}`)} />
<span aria-hidden className="relative text-lg font-semibold text-subtle-foreground">:</span>
<Wheel items={minuteItems} index={mIndex} label={t.minutes} format={pad} onIndex={(i) => set(`${pad(H)}:${pad(minuteItems[i])}`)} />
{hour12 && <Wheel items={[0, 1]} index={pm ? 1 : 0} label={t.period} format={(n) => (n ? t.pm : t.am)} onIndex={(i) => (i === 1) !== pm && set(`${pad((H + 12) % 24)}:${pad(M)}`)} />}
{hidden}
</div>
);
}
/* ---------- dial ---------- */
if (variant === 'dial') {
return (
<div role="group" aria-labelledby={labelId} aria-label={labelId ? undefined : props['aria-label']} className={cn('w-full', className)}>
<Dial value={value ?? '09:00'} onChange={set} hour12={hour12} t={t} step={step ?? 1} />
{hidden}
</div>
);
}
/* ---------- range: from and to with the duration ---------- */
if (variant === 'range') {
return (
<RangeBox
start={value ?? '09:00'}
setStart={set}
endValue={endValue}
defaultEndValue={defaultEndValue}
onEndValueChange={onEndValueChange}
hour12={hour12}
t={t}
step={step ?? 15}
size={size}
c={c}
boxRef={boxRef}
labelId={labelId}
className={className}
name={name}
ariaLabel={props['aria-label']}
/>
);
}
/* ---------- field ---------- */
return (
<div
ref={boxRef}
data-invalid={c.invalid || undefined}
data-disabled={c.disabled || undefined}
className={cn('flex w-full items-center gap-2 ps-2.5 pe-3', CONTROL_HEIGHT[size], controlBox, className)}
>
<TimeSegments value={value} onChange={set} hour12={hour12} t={t} disabled={c.disabled} step={step ?? 1} labelledBy={labelId} describedBy={c['aria-describedby']} />
<Icon name="clock" size={16} className="ms-auto shrink-0 text-subtle-foreground" />
{hidden}
</div>
);
}
function RangeBox({ start, setStart, endValue, defaultEndValue, onEndValueChange, hour12, t, step, size, c, boxRef, labelId, className, name, ariaLabel }: {
ariaLabel?: string;
start: string; setStart: (v: string) => void; endValue?: string; defaultEndValue?: string; onEndValueChange?: (v: string) => void; hour12: boolean; t: Texts; step: number; size: ControlSize;
c: ReturnType<typeof useControl>; boxRef: React.RefObject<HTMLDivElement | null>; labelId?: string; className?: string; name?: string;
}) {
const [innerEnd, setInnerEnd] = useState(defaultEndValue ?? fromMinutes(toMinutes(start) + 60));
const end = endValue ?? innerEnd;
const setEnd = (v: string) => {
setInnerEnd(v);
onEndValueChange?.(v);
};
const dur = (toMinutes(end) - toMinutes(start) + 1440) % 1440 || 1440;
const overnight = toMinutes(end) <= toMinutes(start);
const fromId = useId();
const toId = useId();
// Moving the start keeps the duration: the end walks along
const moveStart = (v: string) => {
setStart(v);
setEnd(fromMinutes(toMinutes(v) + dur));
};
return (
<div ref={boxRef} role="group" aria-labelledby={labelId} aria-label={labelId ? undefined : ariaLabel} data-invalid={c.invalid || undefined} className={cn('flex w-full flex-wrap items-center gap-x-2 gap-y-1 ps-2.5 pe-3 sm:py-1', 'min-h-11 rounded-lg text-base sm:text-sm', size === 'lg' && 'min-h-12', controlBox, className)}>
<span id={fromId} className="sr-only">{t.from}</span>
<TimeSegments value={start} onChange={moveStart} hour12={hour12} t={t} step={step} labelledBy={fromId} />
{/* The arrow travels with the end time, so a wrapped line starts with it */}
<span className="inline-flex items-center gap-2">
<Icon name="arrow-right" size={14} className="shrink-0 text-subtle-foreground" />
<span id={toId} className="sr-only">{t.to}</span>
<TimeSegments value={end} onChange={setEnd} hour12={hour12} t={t} step={step} labelledBy={toId} />
</span>
{/* With AM and PM a phone has no room for the duration on the same line, it wraps to a line of its own */}
<span className="ms-auto inline-flex h-6 items-center gap-1 rounded-md bg-muted px-2 text-[12px] font-medium tabular-nums text-muted-foreground max-sm:my-2" aria-live="polite">
<span className="sr-only">{t.duration}</span>
<span key={dur} className="transition-[opacity,translate] duration-300 starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none">{formatDuration(dur, t)}</span>
{overnight && <span className="text-warning-foreground">· {t.nextDay}</span>}
</span>
{name && (
<>
<input type="hidden" name={`${name}-from`} value={start} />
<input type="hidden" name={`${name}-to`} value={end} />
</>
)}
</div>
);
}
/**
* Pick a time of day as a segmented field, a list in steps, wheels, a clock face or a from to range.
* <TimePicker label="Pickup" defaultValue="14:30" />
* <TimePicker variant="list" label="Ends" step={30} durationFrom="10:00" />
* <TimePicker variant="wheel" label="Alarm" step={5} />
* <TimePicker variant="dial" label="Start" defaultValue="09:15" />
* <TimePicker variant="range" label="Opening hours" defaultValue="08:00" defaultEndValue="18:00" />
*/
export function TimePicker({ label, hint, error, optional, labelAction, className, ...rest }: TimePickerProps) {
const labelId = useId();
const wrapped = Boolean(label || hint || error);
const control = <TimeBox {...rest} labelId={label ? labelId : undefined} invalid={rest.invalid ?? (error ? true : undefined)} className={wrapped ? undefined : className} />;
if (!wrapped) return control;
// List is a single focusable trigger and takes the label as usual, the others are groups named by the label
if (rest.variant === 'list') return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, control);
return (
<div className={cn('grid w-full min-w-0 grid-cols-[minmax(0,1fr)] content-start gap-2', className)}>
{label && (
<Label id={labelId} optional={optional} required={rest.required}>
{label}
</Label>
)}
{control}
{(error || hint) && <p className={cn('text-[13px] leading-5', error ? 'font-medium text-destructive' : 'text-muted-foreground')}>{error ?? hint}</p>}
</div>
);
}Frequent questions
How do I install Time picker?
With aiw add @ai-website/time-picker. The command puts components/ui/time-picker.tsx into your project, the code is yours from then on. It brings along icon, input and select. It needs no further npm packages.
Which variants does Time picker have?
5 variants, Field, List, Wheels, Clock face and From to. You pick one with the variant prop, for example variant="wheel".
Can I translate the texts of Time picker?
Yes. Time picker carries 17 texts of its own in English and German, such as “Hours”, “Minutes” and “AM or PM”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Time picker?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Time picker with aiw_components and install it with aiw_add @ai-website/time-picker. Name the variant in your prompt, for example variant="list", and the assistant writes your content straight into the props.
Can I adjust Time picker without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching TimePicker code to copy.