Zeitauswahl
Eine Uhrzeit wählen, als Feld mit Segmenten, als Liste in Schritten mit Dauer, als Walzen, als Zifferblatt oder als Von-bis-Bereich. Deutsch in 24 Stunden, Englisch mit AM und PM.
Eine von 7 Components in Picker und Auswahlfelder
Variants
Details
Was Zeitauswahl mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Picker und Auswahlfelder
- Bereich
- Elements
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/time-picker.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 17 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle TimePickerProps aus components/ui/time-picker.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.
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
Eingebaute Texte
Diese Texte schreibt Zeitauswahl selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 17 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| hours | Englisch Hours | Deutsch Stunden |
| minutes | Englisch Minutes | Deutsch Minuten |
| period | Englisch AM or PM | Deutsch Vormittag oder Nachmittag |
| am | Englisch AM | Deutsch AM |
| pm | Englisch PM | Deutsch PM |
| placeholderH | Englisch hh | Deutsch hh |
| placeholderM | Englisch mm | Deutsch mm |
| choose | Englisch Choose a time | Deutsch Uhrzeit wählen |
| from | Englisch From | Deutsch Von |
| to | Englisch To | Deutsch Bis |
| duration | Englisch Duration | Deutsch Dauer |
| hourMode | Englisch Choose the hour | Deutsch Stunde wählen |
| minuteMode | Englisch Choose the minutes | Deutsch Minuten wählen |
| h | Englisch h | Deutsch Std. |
| min | Englisch min | Deutsch Min. |
| nextDay | Englisch next day | Deutsch Folgetag |
| clock | Englisch Clock | Deutsch Uhr |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Zeitauswahl reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Rücktaste und Leertaste.
- Setzt aria-checked, aria-describedby, aria-disabled, aria-label, aria-labelledby, aria-live, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen group, radio, radiogroup, slider und spinbutton.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 627 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Zeitauswahl?
Mit aiw add @ai-website/time-picker. Der Befehl legt die Datei components/ui/time-picker.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, input und select. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Zeitauswahl?
5 Varianten, Feld, Liste, Walzen, Zifferblatt und Von bis. Du wählst sie mit der Prop variant, zum Beispiel variant="wheel".
Kann ich die Texte von Zeitauswahl übersetzen?
Ja. Zeitauswahl bringt 17 eigene Texte auf Deutsch und Englisch mit, etwa „Stunden“, „Minuten“ und „Vormittag oder Nachmittag“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Zeitauswahl einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Zeitauswahl mit aiw_components und installieren sie mit aiw_add @ai-website/time-picker. Nenn im Prompt die Variante, etwa variant="list", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Zeitauswahl ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden TimePicker-Code zum Kopieren.