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
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.
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.
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.
Value
Always tabular-nums + slashed-zero so the digits never wobble. Format is set by
formatorformatTime.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.
minandmaxshorten 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | TimeOfDay | null | — | Controlled value. Pass null for 'no time selected'. |
| defaultValue | TimeOfDay | null | null | Uncontrolled initial value. |
| onValueChange | (next: TimeOfDay | null) => void | — | Fires when a slot is committed (click or Enter). |
| size | "input" | "filter" | "input" | Form field with floating label, or compact dashboard chip. |
| label | string | — | Floating label for the input size. Required for input; ignored for filter. |
| placeholder | string | "Select a time" | Shown when the value is null. |
| helpText | ReactNode | — | Helper line below the input. Hidden when error is set. |
| error | string | boolean | — | Truthy reframes the surface to the rose error tone. Strings render below the field. |
| step | number | 30 | Minutes between slots. Common values 15, 30, 60. Smaller steps mean more rows; pick the granularity the decision actually needs. |
| min / max | TimeOfDay | — | Inclusive 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) => string | — | Override formatter — wins over format. Use for locale-specific output (German 14.30, etc). |
| disabled | boolean | false | Greys the surface and prevents the popover from opening. |