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

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

When 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 days for 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. trailing holds a fee, badge a scarcity or highlight, and layout="rows" gives a window room for both.
  • A booked slot that should stay visible. disabled with disabledReason keeps it in place with the reason as text, instead of silently removing it.
  • An "as soon as possible" choice next to the scheduled ones: asap pins it first in the same group.
  • Honest empty states. No times swaps the list for an empty state, with escapeHatch as 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

Terminal
pnpm add @tessinaui/ui

Usage

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

AxisValueBehaviour
layoutgridSlot chips. Columns: 3 for single times, 2 for ranges or badges, 1 with a description or fee
layoutrowsFull-width time-window rows with a radio at the start or end
dayVariantcardLabel, date and meta line; selected is a tint plus border
dayVariantcircleDate in a disc with the label under it; selected disc is filled

API Reference

PropTypeDefaultDescription
slotsTimeSlotsEntry[]—Slots and daypart groups for the current day
value / defaultValuestring | nullnullThe selected slot
onValueChange(value, { reason }) => void—reason is "press" or "keyboard"
daysTimeSlotDay[]—Renders the day strip
selectedDay / defaultSelectedDaystring | nullfirst enabled dayThe selected day
onSelectedDayChange(day, { reason }) => void—Load that day's slots here
dayVariant"card" | "circle""card"Day strip shape
asapboolean | TimeSlotsAsap—Pinned "as soon as possible" option
layout"grid" | "rows""grid"Chip grid or window rows
columns1 | 2 | 3autoGrid columns
indicator"start" | "end""end"Radio side in rows
helperTextReactNode—Duration or lead-time line
timezoneNoteReactNode—Timezone text, or a Select
errorMessageReactNode—Error text below the slots. Announced politely, joined into the radiogroup's aria-describedby, and sets aria-invalid. Consumer-supplied; hidden while readOnly
emptyTitle / emptyDescriptionReactNodestrings.emptyEmpty-state copy
escapeHatch{ label, onSelect }—Waitlist or "none of these work" action
loadingbooleanfalsePlaceholder 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"inheritedOn-color recipe on coloured surfaces
disabled / readOnly / requiredbooleanfalseGroup states
name / formstring—Form serialization
dir"ltr" | "rtl"inheritedDirection, arrow keys included
stringsPartial<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/End jump to the first and last enabled slot. Space selects; Enter does 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 loading clears. Nothing is announced on mount.
  • Errors are announced, not just coloured. errorMessage lands in a polite region mounted from first render, so a message that appears later is spoken; the slots radiogroup is described by it and carries aria-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 is Calendar; a start–end pair is TimeRange; timezone selection composes Select.
  • 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/end are optional "HH:mm" wire values for sorting or posting.
Time RangeField

On this page

When to useWhen not to useInstallationUsageExamplesDefaultDay stripWindow rowsASAPSlot metaDaypartsEmpty and escape hatchBottom sheetWith a calendarDuration and timezoneLoadingSizesStatesErrorIn a formOn colorRTLVariantsAPI ReferenceAccessibilityNotes