DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CalendarDate PickerTime PickerTime RangeTime Slots
AI agentsContributing
  1. Components
  2. Time Range

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/ui

When 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. minTime and maxTime bound the pair; shouldDisableTime receives the endpoint for rules that differ per end.
  • Posting each end in a form. startName and endName serialize each end through its own hidden input.
  • A single clear for the pair. clearable empties 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 value yourself and pass errorMessage.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { TimeRange } from "@tessinaui/ui";
example.tsx
{/* 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

GestureResult
Press a triggerOpens the wheel on that endpoint; focus lands on the first column
Press the other trigger while openRetargets the wheel to that endpoint — the popover stays open
Enter on the wheel, editing startMoves to the end endpoint
Enter on the wheel, editing endCloses and returns focus to the end trigger
Escape / click outsideCloses; picks already applied stay (instant-apply)

API Reference

PropTypeDefaultDescription
value{ start: string | null; end: string | null }—Controlled range, each end "HH:mm" or "HH:mm:ss" 24h
defaultValueTimeRangeValue{ 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
clearablebooleanfalseOne 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
showSecondsbooleanfalseSeconds column and "HH:mm:ss" values
hoursStep / minutesStep / secondsStepnumber1Wheel intervals — each must divide its cycle
minTime / maxTimestring—One selectable window shared by both ends
shouldDisableTime(value, unit, endpoint) => boolean—Disable specific times per endpoint
showLabelsbooleanfalseUnit 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
errorMessageReactNode—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"inheritedOn-color well recipe on coloured surfaces
width"narrow" | "default" | "wide""default"Wheel column width preset
disabledbooleanfalseLocks both triggers and hides the clear
readOnlybooleanfalseTriggers stay focusable; the wheel never opens
placeholderstring"--:-- --" / "--:--"Text in an unset trigger
separatorReactNode"–"Decorative glyph between the triggers
startName / endNamestring—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
localestring"en-US" ("ar" when rtl)Wheel column order and AM/PM glyphs
stringsPartial<TimeRangeStrings>—Override any label, including startLabel, endLabel and range
skeletonbooleanfalsePlaceholder with two trigger boxes
classNamestring—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-label or aria-labelledby on the root rename it, and data-* and className land on the same root.
  • Triggers never carry aria-invalid or aria-required — both are unsupported on buttons. Communicate errors with errorMessage: both triggers are described by it (and carry data-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/maxTime bound both ends together. Rules that differ per end (earliest start, latest end) go through shouldDisableTime's endpoint argument, 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 xs and 300px at xl (12-hour). In a narrower column each trigger truncates its own time, so stack the pair there with [&>[data-slot=time-range-fields]]:flex-col and 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 are DatePicker mode="range"; circular bedtime-style dials compose CircularSlider; an "All day" switch is a composition over disabled, not a prop.
Time PickerTime Slots

On this page

When to useWhen not to useInstallationUsageExamplesDefaultSizesFormatsControlled and overnightAll dayTimeline layoutIn a formErrorOn colorSkeletonRTLInteractionAPI ReferenceAccessibilityNotes