Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Feld

Stunden und Minuten sind Spinbuttons. 7 4 5 tippen, und das Feld springt von selbst weiter, Pfeile gehen in Schritten, die Rücktaste zurück. Ein leeres Feld zeigt, was es erwartet, eine Zeit vor der Öffnung erklärt sich.

Im Studio öffnen

Liste

Zeiten in Schritten von 15 oder 30 Minuten, Tippen springt zur Zeit. Die Liste fürs Ende zeigt die Dauer ab Beginn, ein früheres Ende wird markiert, ein späterer Beginn nimmt das Ende mit.

Im Studio öffnen

Walzen

Walzen, die einrasten, wie auf dem Telefon. Wischen, eine Zeile antippen oder Pfeiltasten nutzen, das Band markiert die Wahl und der Satz darunter folgt.

Im Studio öffnen

Zifferblatt

Den Zeiger auf die Stunde ziehen, danach geht es zu den Minuten. Der Zeiger nimmt den kurzen Weg, Pfeile gehen in Schritten, der Hinweis daneben wechselt bei später Ankunft.

Im Studio öffnen

Von bis

Beginn und Ende in einem Feld mit der Dauer. Ein verschobener Beginn behält die Dauer, ein Ende nach Mitternacht sagt Folgetag. Hier als Öffnungszeiten mit Schalter je Tag.

Im Studio öffnen

Details

Was Zeitauswahl mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/time-picker.tsx
Bringt mit
utilskiticoninputselect
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 17 eigene Texte

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.

  • variantoptional
    TimePickerVariant

    fieldlistwheeldialrange

  • valueoptional
    string | null

    "HH:MM" in 24 hours

  • defaultValueoptional
    string | null
  • onValueChangeoptional
    (value: string) => void
  • endValueoptional
    string

    range: the end, "HH:MM"

  • defaultEndValueoptional
    string
  • onEndValueChangeoptional
    (value: string) => void
  • stepoptional
    number

    Minutes per step, e.g. 5, 15 or 30. Default 15 (list, wheel, range) and 1 (field, dial).

  • minoptional
    string

    Earliest and latest time, "HH:MM"

  • maxoptional
    string
  • durationFromoptional
    string

    list: show the duration from this time next to each option, e.g. for an end time

  • hour12optional
    boolean

    12 hours with AM and PM. Default: English yes, German no.

  • sizeoptional
    ControlSize
  • nameoptional
    string
  • idoptional
    string
  • disabledoptional
    boolean
  • requiredoptional
    boolean
  • invalidoptional
    boolean
  • placeholderoptional
    string
  • aria-labeloptional
    string

    Accessible 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
hoursEnglisch HoursDeutsch Stunden
minutesEnglisch MinutesDeutsch Minuten
periodEnglisch AM or PMDeutsch Vormittag oder Nachmittag
amEnglisch AMDeutsch AM
pmEnglisch PMDeutsch PM
placeholderHEnglisch hhDeutsch hh
placeholderMEnglisch mmDeutsch mm
chooseEnglisch Choose a timeDeutsch Uhrzeit wählen
fromEnglisch FromDeutsch Von
toEnglisch ToDeutsch Bis
durationEnglisch DurationDeutsch Dauer
hourModeEnglisch Choose the hourDeutsch Stunde wählen
minuteModeEnglisch Choose the minutesDeutsch Minuten wählen
hEnglisch hDeutsch Std.
minEnglisch minDeutsch Min.
nextDayEnglisch next dayDeutsch Folgetag
clockEnglisch ClockDeutsch 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
components/ui/time-picker.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert