Time Range
A start–end time pair sharing one wheel surface — two field triggers, an active endpoint the popover retargets in place, overnight ranges left legal, one clear for the pair, and form serialization per end.
pnpm add @tessinaui/uiWhen to use
- A start and an end time that belong together. Two field triggers share one wheel, and the active end retargets in place.
- Opening hours, shifts and quiet hours. An end earlier than its start is a legal overnight range, never swapped or flagged.
- One window for both ends.
minTimeandmaxTimebound the pair;shouldDisableTimereceives the endpoint for rules that differ per end. - Posting each end in a form.
startNameandendNameserialize each end through its own hidden input. - A single clear for the pair.
clearableempties both ends at once.
When not to use
- Two independent times. With no shared surface, use two TimePickers.
- A date range. Use DatePicker with
mode="range". - Availability slots. Use Time Slots.
- A single time or a duration. Use TimePicker.
- A form that forbids overnight ranges. The component never flags an end before its start; validate
valueyourself and passerrorMessage.
Installation
pnpm add @tessinaui/uiUsage
import { TimeRange } from "@tessinaui/ui";{/* Uncontrolled */}
<TimeRange defaultValue={{ start: "09:00", end: "17:00" }} />
{/* Controlled range and endpoint */}
<TimeRange
value={range}
onChange={setRange}
activeEndpoint={endpoint}
onActiveEndpointChange={setEndpoint}
/>
{/* Asymmetric rules: the endpoint is the third argument */}
<TimeRange shouldDisableTime={(value, unit, endpoint) => endpoint === "start" && value < "06:00"} />Examples
Default
Two triggers, one popover. Open either end; the wheel edits that end.
Sizes
Each trigger runs the Field ladder (32/36/40/48/56), identical to a lone popover TimePicker of the same size.
Formats
format and showSeconds apply to both ends. The emitted values are always 24h.
Controlled and overnight
value and activeEndpoint are both controllable. An end earlier than its start wraps past midnight — the component never swaps, clamps or flags it.
All day
An "All day" or "Closed" switch is product chrome: wire it to disabled, which locks both triggers and hides the clear.
Timeline layout
A stacked timeline with a dot-and-connector rail is layout around the component. Stack the pair with className, drop the separator, and pass the visible labels through strings so the triggers announce what is shown.
In a form
startName and endName post each end through its own hidden input. Name the group from a visible label with aria-labelledby.
Error
errorMessage explains why the range is invalid. It renders below the pair in a polite
live region mounted from first render and describes both triggers, so either end repeats
why when focused. TimeRange never flags an end before its start by itself — that is an
overnight range — so a form that forbids overnight ranges validates value and passes the
message, as this one does.
On color
Inside a coloured Surface both fields inherit on-color, and tone overrides it either way. The popover is a page surface, so it resets to the default tone.
Skeleton
RTL
dir="rtl" puts the start field on the right, moves the clear button to the left edge and aligns the popup to the right. It also defaults the strings to Arabic. Wheel column order follows locale, not dir, and times keep Western digits. Unset, dir is inherited from the page, but the strings stay English: only an explicit dir="rtl" defaults them to Arabic.
Interaction
| Gesture | Result |
|---|---|
| Press a trigger | Opens the wheel on that endpoint; focus lands on the first column |
| Press the other trigger while open | Retargets the wheel to that endpoint — the popover stays open |
Enter on the wheel, editing start | Moves to the end endpoint |
Enter on the wheel, editing end | Closes and returns focus to the end trigger |
Escape / click outside | Closes; picks already applied stay (instant-apply) |
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
value | { start: string | null; end: string | null } | — | Controlled range, each end "HH:mm" or "HH:mm:ss" 24h |
defaultValue | TimeRangeValue | { start: null, end: null } | Uncontrolled initial range |
onChange | (value: TimeRangeValue) => void | — | Fires with the whole range when either end changes |
onClear | () => void | — | Fires when the clear affordance empties both ends |
clearable | boolean | false | One clear for the pair, on the trailing trigger, while either end is set |
activeEndpoint | "start" | "end" | — | Controlled endpoint the wheel edits |
defaultActiveEndpoint | "start" | "end" | "start" | Uncontrolled initial endpoint |
onActiveEndpointChange | (endpoint) => void | — | Fires when the wheel retargets |
format | "12h" | "24h" | "12h" | Display format for both ends — values stay 24h |
showSeconds | boolean | false | Seconds column and "HH:mm:ss" values |
hoursStep / minutesStep / secondsStep | number | 1 | Wheel intervals — each must divide its cycle |
minTime / maxTime | string | — | One selectable window shared by both ends |
shouldDisableTime | (value, unit, endpoint) => boolean | — | Disable specific times per endpoint |
showLabels | boolean | false | Unit labels beside the selected numbers on the wheel |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Trigger and wheel density |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Semantic colour for both triggers and the wheel band |
errorMessage | ReactNode | — | Error text below the pair. Announced politely and joined into both triggers' aria-describedby; implies intent="error" unless another intent is set. Consumer-supplied — an end before its start is an overnight range, never flagged automatically. Hidden while readOnly |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Trigger radius; the popover caps full at rounded-3xl |
tone | "default" | "on-color" | inherited | On-color well recipe on coloured surfaces |
width | "narrow" | "default" | "wide" | "default" | Wheel column width preset |
disabled | boolean | false | Locks both triggers and hides the clear |
readOnly | boolean | false | Triggers stay focusable; the wheel never opens |
placeholder | string | "--:-- --" / "--:--" | Text in an unset trigger |
separator | ReactNode | "–" | Decorative glyph between the triggers |
startName / endName | string | — | Serialize each end via its own hidden input |
open / defaultOpen / onOpenChange | — | — | Controlled open state of the shared popover |
dir | "ltr" | "rtl" | — | Chrome direction — the start trigger leads in reading order. Unset, it inherits the page's direction |
locale | string | "en-US" ("ar" when rtl) | Wheel column order and AM/PM glyphs |
strings | Partial<TimeRangeStrings> | — | Override any label, including startLabel, endLabel and range |
skeleton | boolean | false | Placeholder with two trigger boxes |
className | string | — | Classes on the root group |
Accessibility
VoiceOver + Safari: passed 2026-09-14. The root reads "Time range, group", the trigger "Start time, 9:00 AM, dialog pop up button". In the open dialog Down reads "10 selected (10 of 12)", and Escape returns to "Start time, 10:00 AM". NVDA and JAWS are untested.
- Two named triggers. Each is a button named by its endpoint and its value ("Start time, 9:00 AM" or "End time, Empty"), so a reader knows which end it is about to move before pressing. Only the trigger that owns the open popover reports
aria-expanded="true". - One named surface. The popover and its wheel group are named by the active endpoint. The wheel's columns keep the TimePicker listbox contract: one tab stop each,
aria-activedescendant, arrow, Page and Home/End keys. - Retargeting is announced. Switching endpoints while the popover is open keeps focus where it is, so a polite status region says "Selecting end time" (or start).
- The root is a group named "Time range" by default;
aria-labeloraria-labelledbyon the root rename it, anddata-*andclassNameland on the same root. - Triggers never carry
aria-invalidoraria-required— both are unsupported on buttons. Communicate errors witherrorMessage: both triggers are described by it (and carrydata-invalid), and it is announced through a polite region that exists before the message does.
Notes
- Overnight is legal. An end earlier than its start is an overnight range (22:00 → 06:00). Nothing validates end-before-start, by design: the research across scheduling apps found wrap-around ranges rendered as normal in every case.
- One window, per-endpoint rules.
minTime/maxTimebound both ends together. Rules that differ per end (earliest start, latest end) go throughshouldDisableTime'sendpointargument, never a second window — a per-end window is the shape that invites end-before-start constraints. - Narrow columns. Side by side, the pair needs about 250px at
xsand 300px atxl(12-hour). In a narrower column each trigger truncates its own time, so stack the pair there with[&>[data-slot=time-range-fields]]:flex-coland drop the separator, as the timeline example does. - Commit model. Instant-apply, like TimePicker: the wheel writes the active end as it settles. Enter is the one discrete "done" gesture a wheel has, so it is the only thing that advances start → end.
- Boundaries — two independent times (no shared surface) are two
TimePickers; presets and "Now" live on TimePicker's own popover and list panels; date ranges areDatePicker mode="range"; circular bedtime-style dials composeCircularSlider; an "All day" switch is a composition overdisabled, not a prop.