Components · Forms

Time picker

A trigger that opens a scrollable list of time slots. Click to commit. Pair two for a between-X-and-Y filter, bound either by min and max, switch between 15- and 60-minute granularity.

Documentedby Derek Fidler

Used to start the daily schedule.

Click to open. Arrow keys move the highlight, Enter commits, Esc returns focus to the trigger.

Overview.

Time picker is a TextField-shaped trigger that opens a scrollable column of time slots. Click a slot, the field commits and closes. The geometry intentionally mirrors Date picker so the two read as a pair — the popover content swaps a calendar for a list, but the trigger, the focus ring, the disabled tone, and the error reframing are identical.

No Cancel / Apply footer

Date picker uses a footer because navigating months is exploratory and a stray click shouldn't commit. Time picker doesn't — a single column of times has no exploration phase, so clicking a slot commits immediately. Esc closes without commit if the user changes their mind mid-scroll.

Sizes.

Two surfaces. The form-field input is the default; the filter chip is the dashboard / toolbar variant.

size="input"

Form-field shape with floating label. The default — use anywhere a single time fits inside a form.

size="filter"

Compact chip for dashboards and toolbars. Pair two of them for a between-X-and-Y filter.

States.

Empty, filled, with helper, error, disabled. Same vocabulary as every other input in the system.

Empty

Filled

Used to start the daily schedule.

With helper

Opens before the building's earliest unlock window.

Error

Disabled

Filter — empty

Step.

Pick the granularity that matches the operational reality. 30 minutes is the default; 15 for appointment slots; 60 when finer detail would imply false precision.

step={15}

Quarter-hour granularity. Use for appointment booking and short slots.

step={30}

The default. Half-hour resolution covers store hours, schedules, and most operational use.

step={60}

Hourly only. Use when finer detail would imply false precision (cron, daily windows).

24h vs 12h.

24-hour is the default — it's the European convention and matches every other timestamp in the product. Pass format="12h" for AM/PM markets, or formatTime for a custom function (locale-specific separators, half-past spelling, etc.).

format="24h"

European default. Matches our currency style and every other timestamp in the product.

format="12h"

AM/PM markets — pass when the merchant's locale expects it.

Bounded by min and max.

Out-of-bounds slots are pruned from the list, not rendered as greyed-out rows. A long ghost list above and below the valid window costs more attention than it gives back.

Bounded to 07:00 – 11:00.

Slots outside [min, max] are dropped from the list rather than rendered greyed-out — a long disabled list is unhelpful.

Range filters.

The system doesn't ship a dedicated TimeRangePicker. Two TimePickers compose the same idea with less surface area — bound the second picker by the first's value, and bump the end forward if the user picks a start past it.

to

No dedicated TimeRangePicker — pair two TimePickers, bound the second by the first.

Anatomy.

Three parts on the trigger row, plus the popover that opens beneath it. The popover is the same rounded-corner card the rest of the system uses for floating UI.

  1. Floating label

    Sits above the value when filled, hidden when empty so the placeholder isn't doubled-up. Same pattern as Date picker and the rest of the form inputs.

  2. Value

    Always tabular-nums + slashed-zero so the digits never wobble. Format is set by format or formatTime.

  3. Clock icon

    Right-aligned glyph. Hairline stroke; never filled. Greys out together with the rest of the trigger when disabled.

Behavior.

  • Click commits. Picking a slot writes the value and closes the popover. There is no Apply button — time is a single intent.
  • Open scrolls to the selected slot. The popover opens with the currently-selected (or nearest in-bounds) slot centred in the viewport. Arrow keys then walk forward or back from there — no rummaging.
  • Type a digit, jump to that hour. Numeric type-ahead with a 600 ms buffer: type 9 to jump to the first 09:XX slot, or 1430 to jump to 14:30 specifically. Keystrokes that arrive after the buffer expires start a new search.
  • Out-of-bounds slots are pruned, not disabled. min and max shorten the list rather than rendering a long greyed-out tail. A hard-bounded picker still shows the user only what they can choose.
  • Mouse hover and keyboard arrow are the same state. Same rule as the rest of the system: there is no separate “mouse-focus” rectangle. Moving the cursor moves the highlight, ArrowDown does the same.

Accessibility.

The trigger is a button with aria-haspopup="dialog"; the popover is a non-modal role="dialog" with a single role="listbox" inside it. Keyboard focus moves into the listbox on open and returns to the trigger on commit or Esc.

  • ↑ ↓Move highlight one slot.
  • PgUp / PgDnJump roughly an hour at the current step.
  • Home / EndFirst or last slot in the list.
  • Enter / SpaceCommit the highlighted slot.
  • EscClose without commit; focus returns to the trigger.
  • 0 – 9Type-ahead: jump to the first slot whose hour starts with the typed digits.

Code.

Two patterns: a simple controlled time, and a between-X-and-Y range built from two pickers.

Controlled

tsx

import { TimePicker, type TimeOfDay } from "@flatpay-dk/ui";

function OpeningHour() {
  const [open, setOpen] = React.useState<TimeOfDay | null>({ hours: 9, minutes: 30 });
  return (
    <TimePicker
      label="Opening time"
      value={open}
      onValueChange={setOpen}
      step={30}
      helpText="Used to start the daily schedule."
    />
  );
}

Range filter

tsx

import { TimePicker, type TimeOfDay } from "@flatpay-dk/ui";

const minutes = (t: TimeOfDay) => t.hours * 60 + t.minutes;

function HoursRange() {
  const [from, setFrom] = React.useState<TimeOfDay | null>({ hours: 9, minutes: 0 });
  const [to,   setTo]   = React.useState<TimeOfDay | null>({ hours: 17, minutes: 0 });

  return (
    <div className="flex items-center gap-3">
      <TimePicker
        size="filter"
        value={from}
        onValueChange={(next) => {
          setFrom(next);
          // Bump the end forward if the new start is past it.
          if (next && to && minutes(next) > minutes(to)) setTo(next);
        }}
      />
      <span className="text-sm font-semibold text-foreground/55">to</span>
      <TimePicker
        size="filter"
        value={to}
        min={from ?? undefined}
        onValueChange={setTo}
      />
    </div>
  );
}

Best practices.

Do

Bound the picker tightly. Store hours typically span 06:00–24:00, not the full 1440 minutes.

Don't

Don't show every minute of the day at 1-minute granularity. Pick the step that matches the decision.

Do

Use 24h in operational contexts (settlement windows, schedules). Reserve 12h for customer-facing AM/PM markets.

For ad-hoc nudges, use a stepper or quick-pick chips

Don't

Don't reach for a Time picker when the answer is 'now' or 'in 5 minutes'. A relative-time control is cheaper.

Props.

PropTypeDefaultDescription
valueTimeOfDay | nullControlled value. Pass null for 'no time selected'.
defaultValueTimeOfDay | nullnullUncontrolled initial value.
onValueChange(next: TimeOfDay | null) => voidFires when a slot is committed (click or Enter).
size"input" | "filter""input"Form field with floating label, or compact dashboard chip.
labelstringFloating label for the input size. Required for input; ignored for filter.
placeholderstring"Select a time"Shown when the value is null.
helpTextReactNodeHelper line below the input. Hidden when error is set.
errorstring | booleanTruthy reframes the surface to the rose error tone. Strings render below the field.
stepnumber30Minutes between slots. Common values 15, 30, 60. Smaller steps mean more rows; pick the granularity the decision actually needs.
min / maxTimeOfDayInclusive lower / upper bound. Slots outside the window are dropped from the list.
format"24h" | "12h""24h"Display format. 24h is the European default.
formatTime(t: TimeOfDay) => stringOverride formatter — wins over format. Use for locale-specific output (German 14.30, etc).
disabledbooleanfalseGreys the surface and prevents the popover from opening.