Skip to content
UI Kit
Accent color

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

5 variants

Variants

Field

Hours and minutes are spin buttons. Type 7 4 5 and the field moves on by itself, arrows step, Backspace goes back. An empty field shows what it wants, a time before opening explains itself.

Open in Studio

List

Times in steps of 15 or 30 minutes, typing jumps to a time. The end list shows the duration from the start, an earlier end is marked, a later start carries the end along.

Open in Studio

Wheels

Scroll wheels that snap, as on a phone. Swipe, tap a row or use the arrow keys, the band marks the choice and the sentence below follows.

Open in Studio

Clock face

Drag the hand to the hour, it moves on to the minutes. The hand takes the short way round, arrows step, the note beside it changes for a late arrival.

Open in Studio

From to

Start and end in one field with the duration. Moving the start keeps the duration, an end after midnight says next day. Here as opening hours with a switch per day.

Open in Studio

Details

What Time picker brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/time-picker.tsx
Brings along
utilskiticoninputselect
npm packages
None
Languages
English and German, 17 texts of its own

Props

The TimePickerProps interface from components/ui/time-picker.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.

  • 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

Built-in texts

Time picker writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 17 texts
hoursEnglish HoursGerman Stunden
minutesEnglish MinutesGerman Minuten
periodEnglish AM or PMGerman Vormittag oder Nachmittag
amEnglish AMGerman AM
pmEnglish PMGerman PM
placeholderHEnglish hhGerman hh
placeholderMEnglish mmGerman mm
chooseEnglish Choose a timeGerman Uhrzeit wählen
fromEnglish FromGerman Von
toEnglish ToGerman Bis
durationEnglish DurationGerman Dauer
hourModeEnglish Choose the hourGerman Stunde wählen
minuteModeEnglish Choose the minutesGerman Minuten wählen
hEnglish hGerman Std.
minEnglish minGerman Min.
nextDayEnglish next dayGerman Folgetag
clockEnglish ClockGerman 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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard