For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Time Field

A time of day typed segment by segment, on Input's field shell with a clock icon.

Installation

$ pnpm dlx shadcn@latest add @cobalt/time-field

Usage

import { TimeField } from "@/components/ui/time-field";
 
<TimeField
  label="Doors open"
  defaultValue={{ hour: 9, minute: 30 }}
  onChange={(value) => setDoorsOpen(value)}
/>;

Typing a digit fills a segment and moves on when no two-digit value could start with it. Up and Down change the focused segment, Left and Right move between segments, Home and End jump to its lowest and highest value, and Backspace or Delete clears it. The focused segment fills with the accent colour.

hourCycle={24} drops the AM/PM segment, granularity="second" adds seconds, and a value outside minValue and maxValue marks the field invalid. The value is { hour, minute, second? } with hour on a 0–23 clock. Pass name to submit it as HH:MM.

Source

"use client";

import { RiTimeLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { Label } from "@/components/ui/label";

type IconComponent = React.ComponentType<{
  className?: string;
  "aria-hidden"?: boolean | "true" | "false";
}>;

type SegmentKind = "hour" | "minute" | "second" | "dayPeriod";

interface TimeState {
  hour: number | null;
  minute: number | null;
  second: number | null;
}

export type TimeFieldGranularity = "hour" | "minute" | "second";

export interface TimeFieldValue {
  /** 0–23. */
  hour: number;
  /** 0–59. */
  minute: number;
  /** 0–59. Only read with `granularity="second"`. */
  second?: number;
}

export interface TimeFieldProps {
  label?: React.ReactNode;
  hint?: React.ReactNode;
  /** 36px (`default`) or 32px (`sm`). */
  size?: "default" | "sm";
  /** The clock by default; `null` hides it. */
  icon?: IconComponent | null;
  /** Which segments render. Default `minute`. */
  granularity?: TimeFieldGranularity;
  /** 12 adds the AM/PM segment. Default 12. */
  hourCycle?: 12 | 24;
  /** Controlled time. `null` clears the field. */
  value?: TimeFieldValue | null;
  defaultValue?: TimeFieldValue | null;
  /** Fires with the time once every visible segment is filled, `null` before. */
  onChange?: (value: TimeFieldValue | null) => void;
  /** Values before it mark the field invalid. */
  minValue?: TimeFieldValue;
  /** Values after it mark the field invalid. */
  maxValue?: TimeFieldValue;
  disabled?: boolean;
  required?: boolean;
  invalid?: boolean;
  /** Submits the time as `HH:MM` or `HH:MM:SS` through a hidden input. */
  name?: string;
  className?: string;
  fieldClassName?: string;
  "aria-label"?: string;
  "aria-describedby"?: string;
  "aria-invalid"?: React.AriaAttributes["aria-invalid"];
}

const EMPTY_STATE: TimeState = { hour: null, minute: null, second: null };

const SEGMENT_LABELS: Record<SegmentKind, string> = {
  hour: "Hour",
  minute: "Minute",
  second: "Second",
  dayPeriod: "AM or PM",
};

/**
 * Input's field shell (surface, hover and focus rings, invalid and disabled
 * states, two sizes) with a clock icon and a time of day typed segment by
 * segment. Typing fills a segment and moves on, Up and Down change the focused
 * segment, Left and Right move between them, and a value outside `minValue`
 * and `maxValue` marks the field invalid. The focused segment fills with the
 * accent colour.
 */
function pad(value: number) {
  return value.toString().padStart(2, "0");
}

function toState(
  value: TimeFieldValue | null | undefined,
  granularity: TimeFieldGranularity
): TimeState {
  if (!value) {
    return EMPTY_STATE;
  }
  return {
    hour: value.hour,
    minute: granularity === "hour" ? null : value.minute,
    second: granularity === "second" ? (value.second ?? 0) : null,
  };
}

function toValue(
  state: TimeState,
  granularity: TimeFieldGranularity
): TimeFieldValue | null {
  if (state.hour === null) {
    return null;
  }
  if (granularity === "hour") {
    return { hour: state.hour, minute: 0 };
  }
  if (state.minute === null) {
    return null;
  }
  if (granularity === "second") {
    if (state.second === null) {
      return null;
    }
    return { hour: state.hour, minute: state.minute, second: state.second };
  }
  return { hour: state.hour, minute: state.minute };
}

function serialize(value: TimeFieldValue | null) {
  return `${value?.hour ?? ""}:${value?.minute ?? ""}:${value?.second ?? ""}`;
}

function toSeconds(value: TimeFieldValue) {
  return value.hour * 3600 + value.minute * 60 + (value.second ?? 0);
}

function segmentBounds(kind: SegmentKind, hourCycle: 12 | 24) {
  if (kind === "hour") {
    return hourCycle === 12 ? { min: 1, max: 12 } : { min: 0, max: 23 };
  }
  if (kind === "dayPeriod") {
    return { min: 0, max: 1 };
  }
  return { min: 0, max: 59 };
}

const segmentClassName = cn(
  "inline-block rounded-[6px] px-0.5 text-sm leading-5 font-normal tabular-nums text-foreground caret-transparent outline-none",
  "transition-colors duration-100 ease-out motion-reduce:transition-none",
  "data-[placeholder]:text-[color:var(--foreground-subtle,var(--muted-foreground))]",
  "data-[focused]:bg-[oklch(from_var(--primary)_calc(l_+_0.08)_c_h)] data-[focused]:text-primary-foreground",
  "data-[focused]:data-[placeholder]:text-primary-foreground",
  "data-[disabled]:text-[color:var(--input-disabled-foreground,var(--muted-foreground))]",
  "data-[disabled]:data-[placeholder]:text-[color:var(--input-disabled-foreground,var(--muted-foreground))]"
);

const literalClassName =
  "px-0 text-[color:var(--foreground-subtle,var(--muted-foreground))]";

/**
 * Input's field shell (surface, hover and focus rings, invalid and disabled
 * states, two sizes) with a clock icon and a time of day typed segment by
 * segment. Typing fills a segment and moves on, Up and Down change the focused
 * segment, Left and Right move between them, and a value outside `minValue`
 * and `maxValue` marks the field invalid. The focused segment fills with the
 * accent colour.
 */
function TimeField({
  label,
  hint,
  size = "default",
  icon: Icon = RiTimeLine,
  granularity = "minute",
  hourCycle = 12,
  value,
  defaultValue,
  onChange,
  minValue,
  maxValue,
  disabled = false,
  required = false,
  invalid,
  name,
  className,
  fieldClassName,
  "aria-label": ariaLabel,
  "aria-describedby": ariaDescribedBy,
  "aria-invalid": ariaInvalid,
}: TimeFieldProps) {
  const labelId = React.useId();
  const hintId = React.useId();

  const isControlled = value !== undefined;
  const [state, setState] = React.useState<TimeState>(() =>
    toState(value ?? defaultValue, granularity)
  );
  const [syncedValue, setSyncedValue] = React.useState<string | null>(() =>
    isControlled ? serialize(value ?? null) : null
  );
  const [focused, setFocused] = React.useState<SegmentKind>("hour");
  const [focusWithin, setFocusWithin] = React.useState(false);
  const segmentRefs = React.useRef<
    Partial<Record<SegmentKind, HTMLSpanElement | null>>
  >({});
  const typingRef = React.useRef<{
    kind: SegmentKind;
    text: string;
    at: number;
  }>({ kind: "hour", text: "", at: 0 });

  // Adjust to a controlled value only when the parent hands us a new one, so
  // typing inside a field whose parent ignores onChange does not revert.
  const controlledKey = isControlled ? serialize(value ?? null) : null;
  if (isControlled && controlledKey !== syncedValue) {
    setSyncedValue(controlledKey);
    setState(toState(value, granularity));
  }

  const interactive = React.useMemo<SegmentKind[]>(() => {
    const kinds: SegmentKind[] = ["hour"];
    if (granularity !== "hour") {
      kinds.push("minute");
    }
    if (granularity === "second") {
      kinds.push("second");
    }
    if (hourCycle === 12) {
      kinds.push("dayPeriod");
    }
    return kinds;
  }, [granularity, hourCycle]);

  const current = toValue(state, granularity);
  const outOfRange =
    current !== null &&
    ((minValue !== undefined && toSeconds(current) < toSeconds(minValue)) ||
      (maxValue !== undefined && toSeconds(current) > toSeconds(maxValue)));
  const isInvalid =
    invalid === true ||
    ariaInvalid === true ||
    ariaInvalid === "true" ||
    outOfRange;

  const hourDisplay =
    state.hour === null
      ? null
      : hourCycle === 12
        ? state.hour % 12 || 12
        : state.hour;
  const dayPeriod = state.hour !== null && state.hour >= 12 ? "PM" : "AM";

  const update = (patch: Partial<TimeState>) => {
    const next = { ...state, ...patch };
    setState(next);
    const nextValue = toValue(next, granularity);
    if (serialize(nextValue) !== serialize(current)) {
      onChange?.(nextValue);
    }
  };

  const focusSegment = (kind: SegmentKind | undefined) => {
    if (kind) {
      segmentRefs.current[kind]?.focus();
    }
  };

  const setSegment = (kind: SegmentKind, raw: number) => {
    switch (kind) {
      case "hour": {
        if (hourCycle === 12) {
          const display = Math.min(12, Math.max(1, raw));
          const isPM = state.hour !== null && state.hour >= 12;
          update({ hour: (display % 12) + (isPM ? 12 : 0) });
          return;
        }
        update({ hour: Math.min(23, Math.max(0, raw)) });
        return;
      }
      case "minute": {
        update({ minute: Math.min(59, Math.max(0, raw)) });
        return;
      }
      case "second": {
        update({ second: Math.min(59, Math.max(0, raw)) });
        return;
      }
      case "dayPeriod": {
        const base = state.hour ?? 0;
        update({ hour: raw === 1 ? (base % 12) + 12 : base % 12 });
        return;
      }
      default: {
        break;
      }
    }
  };

  const stepSegment = (kind: SegmentKind, step: number) => {
    switch (kind) {
      case "hour": {
        update({ hour: ((state.hour ?? 0) + step + 24) % 24 });
        return;
      }
      case "minute": {
        update({ minute: ((state.minute ?? 0) + step + 60) % 60 });
        return;
      }
      case "second": {
        update({ second: ((state.second ?? 0) + step + 60) % 60 });
        return;
      }
      case "dayPeriod": {
        setSegment(
          "dayPeriod",
          state.hour !== null && state.hour >= 12 ? 0 : 1
        );
        return;
      }
      default: {
        break;
      }
    }
  };

  const advanceFrom = (kind: SegmentKind) => {
    const next = interactive[interactive.indexOf(kind) + 1];
    typingRef.current = { kind: next ?? kind, text: "", at: 0 };
    focusSegment(next);
  };

  const typeDigit = (kind: SegmentKind, digit: string) => {
    const now = Date.now();
    const typing = typingRef.current;
    const { min, max } = segmentBounds(kind, hourCycle);
    const number = Number(digit);
    // Like React Aria, a pending digit waits for the next one until the
    // focus moves or another key is pressed; there is no timeout.
    const continuing = typing.kind === kind && typing.text.length === 1;

    if (continuing) {
      const combined = Number(typing.text + digit);
      if (combined >= min && combined <= max) {
        typingRef.current = { kind, text: typing.text + digit, at: now };
        setSegment(kind, combined);
        advanceFrom(kind);
        return;
      }
    }

    if (number < min || number > max) {
      return;
    }

    typingRef.current = { kind, text: digit, at: now };
    setSegment(kind, number);
    // No two-digit value can start with this digit, so the segment is done.
    if (number * 10 > max) {
      advanceFrom(kind);
    }
  };

  const clearSegment = (kind: SegmentKind) => {
    if (kind === "hour" || kind === "dayPeriod") {
      update({ hour: null });
      return;
    }
    update(kind === "minute" ? { minute: null } : { second: null });
  };

  const handleSegmentKeyDown = (
    event: React.KeyboardEvent<HTMLSpanElement>,
    kind: SegmentKind
  ) => {
    if (disabled) {
      return;
    }

    const { min, max } = segmentBounds(kind, hourCycle);

    if (!/^[0-9]$/.test(event.key)) {
      typingRef.current = { kind, text: "", at: 0 };
    }

    switch (event.key) {
      case "ArrowLeft":
      case "ArrowRight": {
        event.preventDefault();
        const offset = event.key === "ArrowLeft" ? -1 : 1;
        const index = interactive.indexOf(kind);
        const next =
          interactive[index + offset] ??
          interactive[offset < 0 ? interactive.length - 1 : 0];
        focusSegment(next);
        return;
      }
      case "ArrowUp":
      case "ArrowDown": {
        event.preventDefault();
        stepSegment(kind, event.key === "ArrowUp" ? 1 : -1);
        return;
      }
      case "Home": {
        event.preventDefault();
        setSegment(kind, min);
        return;
      }
      case "End": {
        event.preventDefault();
        setSegment(kind, max);
        return;
      }
      case "Backspace":
      case "Delete": {
        event.preventDefault();
        const part =
          kind === "hour" || kind === "dayPeriod" ? state.hour : state[kind];
        if (part === null && kind !== "hour" && kind !== "dayPeriod") {
          clearSegment(interactive[interactive.indexOf(kind) - 1] ?? "hour");
          return;
        }
        clearSegment(kind);
        return;
      }
      default: {
        break;
      }
    }

    if (/^[0-9]$/.test(event.key) && kind !== "dayPeriod") {
      event.preventDefault();
      typeDigit(kind, event.key);
      return;
    }

    if (kind === "dayPeriod" && /^[ap]$/i.test(event.key)) {
      event.preventDefault();
      setSegment("dayPeriod", event.key.toLowerCase() === "p" ? 1 : 0);
    }
  };

  const renderSegment = (kind: SegmentKind) => {
    const { min, max } = segmentBounds(kind, hourCycle);
    let text: string;
    let placeholder = false;
    let valueNow: number | undefined;

    if (kind === "hour") {
      placeholder = hourDisplay === null;
      valueNow = hourDisplay ?? undefined;
      text =
        hourDisplay === null
          ? "--"
          : hourCycle === 12
            ? String(hourDisplay)
            : pad(hourDisplay);
    } else if (kind === "dayPeriod") {
      placeholder = state.hour === null;
      text = dayPeriod;
    } else {
      const part = state[kind];
      placeholder = part === null;
      valueNow = part ?? undefined;
      text = part === null ? "--" : pad(part);
    }

    return (
      <span
        key={kind}
        ref={(node) => {
          segmentRefs.current[kind] = node;
        }}
        role="spinbutton"
        data-slot="time-field-segment"
        data-placeholder={placeholder ? "" : undefined}
        data-focused={focusWithin && focused === kind ? "" : undefined}
        data-disabled={disabled ? "" : undefined}
        aria-label={SEGMENT_LABELS[kind]}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={valueNow}
        aria-valuetext={text}
        aria-invalid={isInvalid || undefined}
        aria-required={required || undefined}
        aria-disabled={disabled || undefined}
        tabIndex={disabled ? -1 : 0}
        className={segmentClassName}
        onFocus={() => {
          typingRef.current = { kind, text: "", at: 0 };
          setFocused(kind);
          setFocusWithin(true);
        }}
        onKeyDown={(event) => handleSegmentKeyDown(event, kind)}
      >
        {text}
      </span>
    );
  };

  return (
    <div
      data-slot="time-field"
      data-disabled={disabled ? "true" : undefined}
      data-invalid={isInvalid ? "true" : undefined}
      className={cn("group flex w-full flex-col items-start gap-1", className)}
    >
      {label ? (
        <Label
          id={labelId}
          data-slot="time-field-label"
          className="gap-0.5 leading-5 group-data-[disabled=true]:opacity-100"
          onClick={() => focusSegment(interactive[0])}
        >
          {label}
          {required && (
            <span aria-hidden="true" className="text-destructive">
              *
            </span>
          )}
        </Label>
      ) : null}
      <div className="relative w-full">
        <div
          data-slot="time-field-control"
          role="group"
          data-disabled={disabled ? "true" : undefined}
          data-invalid={isInvalid ? "true" : undefined}
          aria-label={ariaLabel}
          aria-labelledby={label ? labelId : undefined}
          aria-describedby={
            [ariaDescribedBy, hint ? hintId : undefined]
              .filter(Boolean)
              .join(" ") || undefined
          }
          aria-disabled={disabled || undefined}
          onBlur={(event) => {
            if (!event.currentTarget.contains(event.relatedTarget as Node)) {
              setFocusWithin(false);
            }
          }}
          className={cn(
            "peer relative flex w-full items-center rounded-[10px] bg-[var(--surface-strong,var(--muted))] text-sm leading-5 text-[color:var(--foreground-subtle,var(--muted-foreground))] ring-2 ring-transparent ring-inset outline-none transition-[background-color,box-shadow,color] duration-150 ease-[ease] motion-reduce:transition-none",
            "cursor-text whitespace-nowrap",
            "hover:not-focus-within:not-data-[disabled=true]:not-data-[invalid=true]:ring-[var(--border-hover,var(--border))]",
            // cobalt-allow-focus-within -- text input (segmented time inputs)
            "focus-within:not-data-[disabled=true]:not-data-[invalid=true]:ring-[var(--foreground-subtle,var(--ring))]",
            "data-[disabled=true]:not-data-[invalid=true]:bg-[var(--input-disabled-surface,var(--muted))] data-[disabled=true]:not-data-[invalid=true]:text-[color:var(--input-disabled-foreground,var(--muted-foreground))]",
            "data-[disabled=true]:cursor-not-allowed",
            "data-[invalid=true]:bg-[var(--input-invalid-surface,color-mix(in_srgb,var(--destructive)_15%,transparent))] data-[invalid=true]:text-destructive",
            size === "default" ? "min-h-9 p-2" : "h-8 px-1.5 py-2",
            // Line the first segment up with Input's text: after the icon at
            // 34px, or at 12px without one.
            Icon ? "ps-[34px]" : "ps-3",
            fieldClassName
          )}
          onPointerDown={(event) => {
            if (disabled) {
              return;
            }
            // Clicking the shell's padding or a separator focuses the first
            // segment, like clicking into a text field.
            if (
              (event.target as HTMLElement).dataset.slot !==
              "time-field-segment"
            ) {
              event.preventDefault();
              focusSegment(interactive[0]);
            }
          }}
        >
          {renderSegment("hour")}
          <span aria-hidden="true" className={literalClassName}>
            :
          </span>
          {granularity !== "hour" && renderSegment("minute")}
          {granularity === "second" && (
            <>
              <span aria-hidden="true" className={literalClassName}>
                :
              </span>
              {renderSegment("second")}
            </>
          )}
          {hourCycle === 12 && (
            <>
              <span
                aria-hidden="true"
                className={cn(literalClassName, "whitespace-pre")}
              >
                {" "}
              </span>
              {renderSegment("dayPeriod")}
            </>
          )}
        </div>
        {Icon ? (
          <Icon
            aria-hidden
            data-slot="time-field-icon"
            className="pointer-events-none absolute start-2 top-1/2 size-5 -translate-y-1/2 text-[color:var(--foreground-subtle,var(--foreground-subtle,var(--muted-foreground)))] peer-data-[invalid=true]:text-destructive peer-data-[disabled=true]:text-[color:var(--input-disabled-foreground,var(--muted-foreground))]"
          />
        ) : null}
        {name ? (
          <input
            type="hidden"
            name={name}
            value={
              current === null
                ? ""
                : granularity === "second"
                  ? `${pad(current.hour)}:${pad(current.minute)}:${pad(current.second ?? 0)}`
                  : `${pad(current.hour)}:${pad(current.minute)}`
            }
          />
        ) : null}
      </div>
      {hint ? (
        <p
          id={hintId}
          data-slot="time-field-hint"
          className={cn(
            "pt-px text-[12px] leading-4 font-medium tracking-[0.15px]",
            isInvalid ? "text-destructive" : "text-muted-foreground"
          )}
        >
          {hint}
        </p>
      ) : null}
    </div>
  );
}

export { TimeField };