Time Slots
Slot and booking time selection — a day strip over a chip grid or time-window rows, an ASAP option, daypart groups, per-slot price and capacity meta, and explicit empty, unavailable and waitlist states.
pnpm add @tessinaui/uiWhen to use
- Choosing from real availability. A booking, appointment, delivery window or pickup where only some times are open and the rest should read as taken.
- Slots spread over several days. Pass
daysfor a strip above that day's slots; changing day clears the pick, because the old slot no longer exists. - Times that carry a price or a signal.
trailingholds a fee,badgea scarcity or highlight, andlayout="rows"gives a window room for both. - A booked slot that should stay visible.
disabledwithdisabledReasonkeeps it in place with the reason as text, instead of silently removing it. - An "as soon as possible" choice next to the scheduled ones:
asappins it first in the same group. - Honest empty states. No times swaps the list for an empty state, with
escapeHatchas the way out (a waitlist, "none of these work").
When not to use
- To type or scroll to an arbitrary time. Use TimePicker.
- To pick a date with no times attached. Use Calendar or DatePicker. Time Slots takes the day from a strip or composes with a Calendar for the full-page layout.
- To pick a start and an end time. Use TimeRange.
- To filter one daypart out of many. Compose SegmentedControl over the same data.
- For a long list of options that are not times. A radio group or Select fits better.
- For a confirmation step. Selection is immediate; put the confirm button in the sheet or page that hosts it (Bottom sheet).
Installation
pnpm add @tessinaui/uiUsage
import { TimeSlots, findTimeSlot } from "@tessinaui/ui";{/* Chip grid — columns follow the slot shape */}
<TimeSlots slots={[{ value: "09:00", label: "9:00 AM" }, { value: "09:30", label: "9:30 AM" }]} />
{/* Day strip + that day's slots */}
<TimeSlots days={days} selectedDay={day} onSelectedDayChange={setDay} slots={slotsFor(day)} />
{/* Time-window rows with a fee and an ASAP option */}
<TimeSlots layout="rows" asap slots={[{ value: "w1", label: "4:00 PM – 4:20 PM", trailing: "$2.99" }]} />
{/* Daypart groups */}
<TimeSlots slots={[{ type: "group", label: "Morning", slots: morning }]} />
{/* Controlled, with a fetch per day */}
<TimeSlots value={slot} onValueChange={setSlot} loading={isFetching} slots={data ?? []} />Examples
Default
A day strip over that day's slots. Changing day clears the pick, because the old slot no longer exists.
Day strip
Cards hold a date and a meta line; circles are the compact form. Unavailable days stay in the strip, struck through.
Window rows
layout="rows" — the window, a description line and an end-aligned fee. indicator="start" moves the radio first.
ASAP
asap pins an "as soon as possible" radio first in the same group — enabled, or disabled with the resume time. The ASAP-or-later mode cards compose RadioCard.
Slot meta
badge for scarcity or highlights, disabled + disabledReason for a booked slot that stays in place.
Dayparts
Group entries render headers with counts. Filtering to one daypart instead composes SegmentedControl over the same data.
Empty and escape hatch
No slots swaps the list for an empty state; escapeHatch is its action, and a ghost button under a list that does have times.
Bottom sheet
The sheet composes Drawer: the footer echoes the pick via findTimeSlot and its CTA stays disabled until there is one.
With a calendar
The full-page appointment layout — Calendar owns the day, and a timezone Select goes in timezoneNote.
Duration and timezone
helperText says how long the service takes; timezoneNote says which zone the times are in.
Loading
loading keeps the day strip live and shows placeholder slots, then announces the new count. TimeSlotsSkeleton covers the first load.
Sizes
sm and md. Chips lift to a 44px target on touch devices; rows already clear it.
States
Read-only keeps a booked slot focusable but locked; disabled takes the control out of the Tab sequence.
Error
errorMessage explains what is missing. It renders below the slots in a polite live region that exists before the message does, describes the radiogroup and marks it invalid. Validation is yours; the message is hidden while readOnly.
In a form
name serializes the selected slot for a native form post; required blocks submit until one is picked.
On color
Slots draw from the plate's ink and the pick becomes its inverse chip. tone inherits from the nearest Surface.
RTL
dir="rtl" mirrors the strip and grid and the arrow keys with them, and an RTL page does the same with no prop; time labels are bidi-isolated.
Variants
| Axis | Value | Behaviour |
|---|---|---|
layout | grid | Slot chips. Columns: 3 for single times, 2 for ranges or badges, 1 with a description or fee |
layout | rows | Full-width time-window rows with a radio at the start or end |
dayVariant | card | Label, date and meta line; selected is a tint plus border |
dayVariant | circle | Date in a disc with the label under it; selected disc is filled |
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
slots | TimeSlotsEntry[] | — | Slots and daypart groups for the current day |
value / defaultValue | string | null | null | The selected slot |
onValueChange | (value, { reason }) => void | — | reason is "press" or "keyboard" |
days | TimeSlotDay[] | — | Renders the day strip |
selectedDay / defaultSelectedDay | string | null | first enabled day | The selected day |
onSelectedDayChange | (day, { reason }) => void | — | Load that day's slots here |
dayVariant | "card" | "circle" | "card" | Day strip shape |
asap | boolean | TimeSlotsAsap | — | Pinned "as soon as possible" option |
layout | "grid" | "rows" | "grid" | Chip grid or window rows |
columns | 1 | 2 | 3 | auto | Grid columns |
indicator | "start" | "end" | "end" | Radio side in rows |
helperText | ReactNode | — | Duration or lead-time line |
timezoneNote | ReactNode | — | Timezone text, or a Select |
errorMessage | ReactNode | — | Error text below the slots. Announced politely, joined into the radiogroup's aria-describedby, and sets aria-invalid. Consumer-supplied; hidden while readOnly |
emptyTitle / emptyDescription | ReactNode | strings.empty | Empty-state copy |
escapeHatch | { label, onSelect } | — | Waitlist or "none of these work" action |
loading | boolean | false | Placeholder slots, aria-busy |
size | "sm" | "md" | "md" | Density |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Slot and day-card corners; published as --time-slots-item-radius |
tone | "default" | "on-color" | inherited | On-color recipe on coloured surfaces |
disabled / readOnly / required | boolean | false | Group states |
name / form | string | — | Form serialization |
dir | "ltr" | "rtl" | inherited | Direction, arrow keys included |
strings | Partial<TimeSlotsStrings> | English, Arabic under dir="rtl" | Labels and live-region templates ({count}, {day}). available and availableForDay take plural variants, key_<category> (available_few), picked by Intl.PluralRules; overriding a bare key drops the Arabic table's variants of it |
TimeSlot — value, label (the accessible name), start? / end? ("HH:mm"), description?, trailing?, badge?, badgeIntent?, disabled?, disabledReason?.
TimeSlotGroup — type: "group", label, icon?, count?, slots.
TimeSlotDay — value, label, sublabel?, meta?, disabled?, ariaLabel?.
findTimeSlot(slots, value) returns the slot behind a value, groups included.
Accessibility
VoiceOver + Safari: passed 2026-09-14. The day strip reads "Today, Sep 14, selected, radio button, 1 of 7"; Right reads "Tue, Sep 15 …" and the region announces "7 times available for Tue Sep 15"; Space on a slot reads "selected, 9:00 AM, radio button, 1 of 7". NVDA and JAWS are untested.
- Slots are one radiogroup — ASAP and every group's slots included — with a single tab stop. Arrow keys move and select in reading order in both layouts (the APG radio pattern; the grid is not a 2-D
role="grid").Home/Endjump to the first and last enabled slot.Spaceselects;Enterdoes not. - The day strip is a second radiogroup, not a tablist: choosing a day is a value with no panel of its own. Two groups, two tab stops.
- A slot's name is its time. Description, fee, badge and disabled reason are wired with
aria-describedby. - Disabled slots are natively disabled — arrows skip them — and their reason is visible text, reachable by a virtual cursor.
- Day changes are announced in a polite live region ("5 times available for Tue 15", "No available times for Wed 16") once
loadingclears. Nothing is announced on mount. - Errors are announced, not just coloured.
errorMessagelands in a polite region mounted from first render, so a message that appears later is spoken; the slots radiogroup is described by it and carriesaria-invalid. - Daypart sections are
role="group"named by their header. - If nothing is selected, or the value is stale or disabled, the tab stop falls back to the first enabled slot, so the list never drops out of the Tab sequence.
Notes
- Commit model — selection is immediate. A confirmation footer belongs to a sheet or page composition (see Bottom sheet), not to the component.
- Boundaries — typing or scrolling to an arbitrary time is
TimePicker; the day dimension beyond a strip isCalendar; a start–end pair is TimeRange; timezone selection composesSelect. - Not built yet — a days × windows availability matrix, a multi-day agenda with one value across days, solid-fill selected chips and "show more times" disclosure are recorded in the ledger's backlog.
- Formatting is yours — labels are display strings you format;
start/endare optional"HH:mm"wire values for sorting or posting.