component — date & time

Time Field

A segmented time input (hour, minute, second, AM/PM) with keyboard-friendly editing, built on react-aria-components. shouldForceLeadingZeros pads the hour segment to two digits where the locale would not.

  • form
  • input
  • time
  • field
  • segmented
  • leading-zeros

05 examples

Default

A labelled time field with hour, minute, and AM/PM segments.

Event time
––––

With description

A hint rendered beneath the field.

Start time
930
Times are shown in your local timezone.

States

Disabled and invalid fields.

Disabled
1400
Invalid
2359
Choose a time during business hours.

With seconds

Use granularity to add a seconds segment.

Precise time
101530

Controlled

Wake-up time
800

source

time-field.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import type { DateInputProps, TimeFieldProps, TimeValue } from "react-aria-components"
import {
  composeRenderProps,
  DateInput as DateInputPrimitive,
  DateSegment,
  TimeField as TimeFieldPrimitive,
} from "react-aria-components"
import { useFieldSizing } from "@/lib/field-size"
import { cn } from "@/lib/utils"
import { fieldStyles } from "@/components/field"

/**
 * TimeField — quebi design system
 *
 * Built on react-aria-components. A segmented time input (hour / minute /
 * second / AM-PM) drawn like `Input`: boxed, its edge the focus colour on focus.
 * The segment being edited takes the `signal` fill; invalid turns the edge and
 * the digits `danger`, and disabled dims the field.
 *
 * `shouldForceLeadingZeros` pads the hour segment to two digits — the same prop
 * `DateField` takes, doing the one thing a time has to pad. Under a locale
 * whose clock already pads it changes nothing (`de-DE` renders `09:05` either
 * way); under `en-US` it turns `9:05 AM` into `09:05 AM`.
 *
 * `TimeInput` takes the same `size` scale as `Input` and `DateInput`, and reads
 * `FieldSizeContext` when it is not given one — so a TimeField in an editable
 * table cell is the height of every other control in that row.
 */
export function TimeField<T extends TimeValue>({ className, ...props }: TimeFieldProps<T>) {
  return (
    <TimeFieldPrimitive
      {...props}
      data-slot="control"
      className={composeRenderProps(className, (resolved) =>
        // The stack from the one place that owns it. `w-fit` after it, so a
        // bare TimeField is still as wide as its segments; the conform variant
        // puts `w-full` back on top, which is the footprint rule for a field.
        cn("group", fieldStyles, "w-fit", resolved),
      )}
    />
  )
}

/**
 * The padding half of the field size scale — the same three steps `Input` and
 * `DateInput` publish, so a time field and the button beside it are the same
 * height: `xs` is 30px and `sm` 38px, `md` is the default.
 * Spelled out here rather than imported from `date-field` so `TimeField` does
 * not gain a sibling component as a registry dependency for three strings; the
 * type size travels with it below, because a segmented field's height is its
 * segments' line box.
 */
const timeInputSizeStyles = {
  xs: "px-(--q-field-px) py-1.5",
  sm: "px-(--q-field-px) py-2",
  md: "px-(--q-field-px) py-2.5",
} as const

type TimeInputSize = keyof typeof timeInputSizeStyles

/** `bare` strips the field chrome (border, bg, rounding, focus ring, padding)
 *  so the TimeInput can be composed inside a wrapper that owns those. */
interface TimeInputProps extends Omit<DateInputProps, "children"> {
  bare?: boolean
  /**
   * Control height. Matches `Input`'s scale. Left out, it is whatever the
   * surrounding surface asked for — a table cell being the one that does. See
   * `@/lib/field-size`.
   */
  size?: TimeInputSize
}

export function TimeInput({ className, bare = false, size: sizeProp, ...props }: TimeInputProps) {
  const { size } = useFieldSizing({ size: sizeProp })
  const textSize = size === "xs" ? "text-xs" : "text-sm"
  return (
    <span data-slot="control" className={bare ? "relative block w-full" : "relative block"}>
      <DateInputPrimitive
        className={composeRenderProps(className, (resolved) =>
          cn(
            "relative block appearance-none text-quebi-fg",
            textSize,
            bare
              ? [
                  "w-full border-0 bg-transparent outline-none",
                  timeInputSizeStyles[size],
                ]
              : [
                  // `Input`'s box: focus or an open picker turns the edge the focus
                  // colour and adds a 1px shadow inside it, no ring.
                  "quebi-field",
                  timeInputSizeStyles[size],
                  "transition-[border-color,box-shadow] duration-150",
                  "outline-none focus-within:outline-none focus-within:shadow-(--q-field-focus)",
                  "group-open:shadow-(--q-field-focus)",
                  "data-invalid:[--q-field-edge:var(--q-danger)] focus-within:data-invalid:shadow-(--q-field-focus-danger)",
                  "in-disabled:cursor-not-allowed in-disabled:opacity-50",
                ],
            resolved,
          ),
        )}
        {...props}
      >
        {(segment) => (
          <DateSegment
            segment={segment}
            // The segment carries no resting `text-*` of its own, by design. It
            // is `inline` inside the wrapper above, so font size, colour and
            // numeral width all inherit — which makes the wrapper the one place
            // that sets them, and a `text-[10.5px] text-quebi-fg-muted
            // tabular-nums` passed as `className` reaches the digits instead of
            // losing to a hardcoded `text-sm text-quebi-fg` here. `DaySchedule`
            // is why: its editable edge times have to match static labels drawn
            // at 10.5px, and even `size="xs"` is 12px. `tracking-wider` stays —
            // that is a segmented-field affordance, not a size. The variants
            // below still name a colour, because they are states rather than a
            // resting style, and a state has to outrank what was inherited.
            className={cn(
              "inline shrink-0 rounded-(--q-radius-mark) px-1 py-0.5 tracking-wider caret-transparent outline-0 type-literal:px-0",
              "data-placeholder:not-data-focused:text-quebi-fg-subtle",
              "focus:bg-quebi-signal focus:text-quebi-on-signal",
              "focus:data-invalid:bg-quebi-danger focus:data-invalid:text-quebi-on-action",
              "forced-colors:focus:bg-[Highlight] forced-colors:focus:text-[HighlightText]",
              "forced-color-adjust-none forced-colors:text-[ButtonText]",
              "in-disabled:opacity-50 disabled:opacity-50 forced-colors:disabled:text-[GrayText]",
            )}
          />
        )}
      </DateInputPrimitive>
    </span>
  )
}