Time Slots
A booking time-slot grid grouped by morning and afternoon, with taken slots struck out and a confirm bar.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/time-slots.json'use client'
import { useState } from 'react'
import './time-slots.css'
export interface TimeSlot { time: string; taken?: boolean }
export interface TimeSlotsProps {
slots: TimeSlot[]
day?: string
value?: string
defaultValue?: string
onConfirm?: (t: string) => void
hour12?: boolean
fill?: 'dark' | 'light'
className?: string
}
export const fmtTime = (t: string, h12: boolean) => {
const [h, m] = t.split(':').map(Number)
if (!h12) return t
return `${((h + 11) % 12) + 1}:${String(m).padStart(2, '0')} ${h < 12 ? 'am' : 'pm'}`
}
/** Booking time slots with a confirm bar. */
export function TimeSlots({ slots, day = 'Thursday, Nov 12', value, defaultValue, onConfirm, hour12 = true, fill = 'dark', className }: TimeSlotsProps) {
const [inner, setInner] = useState(defaultValue)
const [done, setDone] = useState<string>()
const v = value ?? inner
const groups = [['Morning', slots.filter((s) => Number(s.time.slice(0, 2)) < 12)], ['Afternoon', slots.filter((s) => Number(s.time.slice(0, 2)) >= 12)]] as const
return (
<div className={['sd-tsl', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-tsl__day">{day} <span>· 30 min</span></div>
{groups.map(([g, list]) => list.length > 0 && (
<div key={g}>
<div className="sd-tsl__grp" id={`tsl-${g}`}>{g}</div>
<div className="sd-tsl__grid" role="radiogroup" aria-labelledby={`tsl-${g}`}>
{list.map((s) => (
<button key={s.time} type="button" role="radio" aria-checked={v === s.time} aria-disabled={s.taken || undefined} className="sd-tsl__s" onClick={() => { if (!s.taken) { setInner(s.time); setDone(undefined) } }}>
{fmtTime(s.time, hour12)}
</button>
))}
</div>
</div>
))}
<div className="sd-tsl__bar" data-open={(v && !done) || undefined}>
<div><button type="button" className="sd-tsl__cta" tabIndex={v && !done ? 0 : -1} onClick={() => { if (v) { setDone(v); onConfirm?.(v) } }}>Confirm {v ? fmtTime(v, hour12) : ''}</button></div>
</div>
{done && <div className="sd-tsl__done" role="status">Booked for {fmtTime(done, hour12)}</div>}
</div>
)
}.sd-tsl { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-tsl[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-tsl button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-tsl :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-tsl .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-tsl { width: min(100%, 320px); padding: 14px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-tsl__day { font-weight: 600; font-size: 14px; margin-bottom: 12px; }
.sd-tsl__day span { color: var(--mute); font-weight: 400; }
.sd-tsl__grp { font-size: 11.5px; color: var(--mute); margin: 10px 0 6px; }
.sd-tsl__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.sd-tsl__s { height: 36px; border-radius: 10px; font-size: 13px; font-variant-numeric: tabular-nums; box-shadow: inset 0 0 0 1px var(--line); background: var(--bg) !important; transition: background-color 180ms, color 180ms, transform 140ms; }
.sd-tsl__s:hover:not([aria-disabled='true']) { box-shadow: inset 0 0 0 1px var(--mute); }
.sd-tsl__s:active:not([aria-disabled='true']) { transform: scale(0.94); }
.sd-tsl__s[aria-checked='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; font-weight: 600; }
.sd-tsl__s[aria-disabled='true'] { color: var(--mute); text-decoration: line-through; opacity: 0.5; cursor: not-allowed; box-shadow: none; background: var(--soft) !important; }
.sd-tsl__bar { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 300ms cubic-bezier(0.3, 1.2, 0.4, 1); }
.sd-tsl__bar[data-open] { grid-template-rows: 1fr; }
.sd-tsl__bar > div { overflow: hidden; }
.sd-tsl__cta { margin-top: 12px; width: 100%; height: 42px; border-radius: 12px; background: var(--strong) !important; color: var(--on) !important; font-weight: 600; font-size: 14px; }
.sd-tsl__done { margin-top: 12px; font-size: 13px; text-align: center; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-tsl *, .sd-tsl *::before, .sd-tsl *::after { animation: none !important; transition: none !important; } }Overview
Pick a time and the slot fills while a confirm bar rises from the bottom with the chosen time spelled out. Taken slots are struck through and skipped by keyboard.
A 12 / 24 hour switch only changes the labels.
When to use
- Appointment, demo and table booking flows.
When not to use
- Free-form times: use a time input.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/time-slots.json$
npx shadcn@latest add https://sundaeui.dev/r/time-slots.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/time-slots.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/time-slots.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy time-slots.tsx and time-slots.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { TimeSlots } from '@/components/sundae/time-slots'
<TimeSlots />Props
| Prop | Type | Default |
|---|---|---|
slots*Times as "HH:MM" (24h). | { time: string; taken?: boolean }[] | — |
value / defaultValueSelected time. | string | — |
onConfirmCalled on confirm. | (t: string) => void | — |
hour12Show 12-hour labels. | boolean | true |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A radiogroup per period; taken slots are aria-disabled.
- The confirm button names the selected time.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-tsl; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Time Slots free?
- Yes. Time Slots is free for personal and commercial projects, with full source on this page.
- How do I install Time Slots?
- Run npx shadcn@latest add https://sundaeui.dev/r/time-slots.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Time Slots support keyboard and screen readers?
- A radiogroup per period; taken slots are aria-disabled. The confirm button names the selected time.
- Does Time Slots respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Week Strip
Forms · RefinedRepeat Picker
Forms · Refined- Pro
Wheel Picker
Forms · Refined - Pro
Date Range Presets
Forms · Refined Time Field
Forms · RefinedCron Builder
Forms · Refined