Time Grid
betaA day of rooms or other resources: one row per room, time across, each booking a soft block in its event type's color. Book free time, and drag a booking to another time or room.
Preview
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/time-grid
Use it for
- Room, desk, and equipment bookings: a day of rooms or other resources, in a Card.
- Pass resources, one per row, and bookings, each with its resource's id and 24-hour times.
- Set type for the event's color: training blue, match green, tournament teal, social violet, meeting slate.
- Pass now on today's day. A blue line marks it, the day opens on it, and bookings that have ended go quiet.
- Give a booking detail, PeekCard props, for its peek. Put the booking's own actions there, like Open, Move, and Cancel.
- Set closed on a room that cannot be booked, with the reason.
- Keep bookings in state. onMove hands back a booking's new room and times after each drop, and where it was when ⌘Z puts it back.
- Hand back a function from onCreate that takes the new booking away. ⌘Z calls it for 8 seconds.
- Tell the people booked in onSettle. It runs once the 8-second ⌘Z window ends, so ⌘Z never takes back a message already sent.
- Leave out onMove and onCreate for a day people only read.
Avoid it for
- Telling the event type by color alone. Each booking's name and peek say the type in words.
- Weeks or months. This is one day.
- Your own undo button or timer. Moves and new bookings join the page's ⌘Z stack.
- Days much wider than the screen that people rearrange a lot. A drag does not scroll the day for you.
Props
- resources
The rooms or other resources, one row each. closed is why it is closed.
{ id: string; name: string; meta?: string; closed?: string }[]
- bookings
The day's bookings. resource is a resource's id, and times are 24-hour.
{ id: string; resource: string; start: string; end: string; title: string; type?: "training" | "match" | "tournament" | "social" | "meeting"; meta?: string; detail?: PeekCardProps | ReactNode }[]
- start
The day's first time, 24-hour.
string · default "09:00"
- end
The day's last time, 24-hour.
string · default "18:00"
- step
Minutes the slot and a moved booking go by. Drags snap to it.
number · default 30
- length
Minutes a booking made with a click or Enter lasts. It stops short at the next booking.
number · default 60
- now
The time now, like "14:20", when the day shown is today.
string
- hourWidth
Pixels per hour before the day scrolls sideways.
number · default 72
- label
Names the day for screen readers.
string · default "Bookings"
- onCreate
Free time was booked: by a drag, a click, a second tap, or Enter. Hand back a function that takes it away, and ⌘Z calls it for 8 seconds.
(slot: { resource: string; start: string; end: string }) => void | (() => void)
- onMove
A booking was dragged, or moved with the keys, to free time: its new room and times. ⌘Z moves it back, calling this with where it was.
(booking: { id: string; resource: string; start: string; end: string }) => void
- onSettle
A move, or a booking made here that onCreate can take away, is final: its ⌘Z window ended. A new booking has no id.
(booking: { id?: string; resource: string; start: string; end: string }, change: "move" | "create") => void
- className
Extra classes. Later classes win.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| resources | { id: string; name: string; meta?: string; closed?: string }[] | - | The rooms or other resources, one row each. closed is why it is closed. |
| bookings | { id: string; resource: string; start: string; end: string; title: string; type?: "training" | "match" | "tournament" | "social" | "meeting"; meta?: string; detail?: PeekCardProps | ReactNode }[] | - | The day's bookings. resource is a resource's id, and times are 24-hour. |
| start | string | "09:00" | The day's first time, 24-hour. |
| end | string | "18:00" | The day's last time, 24-hour. |
| step | number | 30 | Minutes the slot and a moved booking go by. Drags snap to it. |
| length | number | 60 | Minutes a booking made with a click or Enter lasts. It stops short at the next booking. |
| now | string | - | The time now, like "14:20", when the day shown is today. |
| hourWidth | number | 72 | Pixels per hour before the day scrolls sideways. |
| label | string | "Bookings" | Names the day for screen readers. |
| onCreate | (slot: { resource: string; start: string; end: string }) => void | (() => void) | - | Free time was booked: by a drag, a click, a second tap, or Enter. Hand back a function that takes it away, and ⌘Z calls it for 8 seconds. |
| onMove | (booking: { id: string; resource: string; start: string; end: string }) => void | - | A booking was dragged, or moved with the keys, to free time: its new room and times. ⌘Z moves it back, calling this with where it was. |
| onSettle | (booking: { id?: string; resource: string; start: string; end: string }, change: "move" | "create") => void | - | A move, or a booking made here that onCreate can take away, is final: its ⌘Z window ended. A new booking has no id. |
| className | string | - | Extra classes. Later classes win. |
Accessibility
- The day is a group named by label.
- Each booking reads its type, title, times, and room, like "Meeting, Design review, 11:00–12:30, Room 1". Ended bookings add "ended".
- The day is one Tab stop. ←→ move by step, ↑↓ go to the room above or below at the same time and pass closed ones, and Home and End go to the day's ends.
- On free time the stop is a button named like "Book Room 1, 10:30 to 11:30", and Enter books it. On a booking the stop is the booking. Enter goes into its peek, or says "11:00 is booked".
- A room's Clash is a Tab stop of its own, before the day. So is each booking on a closed room, since the keys pass closed rooms.
- Space picks a booking up. ←→ move it by step, Shift with ←→ changes its end, ↑↓ take it to the next open room, Space or Enter drops it, and Esc puts it back.
- A booking that is up, the slot while it shows, and a drag all own Esc, even inside a Sheet. The next Esc closes the sheet.
- Focus leaving a booking that is up drops it where it is, or puts it back from booked time.
- A polite live region says each step: picked up, each place it passes, where it dropped or went back, and why, like Taken or Closed.
- Every drag has the same moves on the keys. A mouse lifts a booking after 4 px, a finger after holding still for 350 ms, and a finger moving scrolls the day.
- ⌘Z (Ctrl+Z) puts a move back, or takes a new booking away, for 8 seconds. Screen readers hear what went back.
- The type is in words as well as color, and the peek's subtitle says it too.
- Booking text is the theme's foreground and muted text on a tint of 14% or less, so it keeps the card's contrast.
- The now line's time is text, read as "Now 14:20".
- Clash is a peek trigger. Its peek names the bookings that overlap.
- A closed room says Closed and why in words, not only with its shade.
- Forced colors keep a border on every booking and on the ended dot, and the rings on a lifted or landed booking become outlines.
- Reduced motion turns off the glide. Bookings step to their new places.
Examples
Book free time and drag bookings
const [bookings, setBookings] = useState(DAY);
<Card className="gap-0 py-2">
<TimeGrid
label="Room bookings"
resources={ROOMS}
bookings={bookings}
now="14:20"
onCreate={(slot) => {
const id = crypto.randomUUID();
setBookings((all) => [...all, { id, ...slot, title: "New booking" }]);
return () => setBookings((all) => all.filter((booking) => booking.id !== id));
}}
onMove={(moved) => setBookings((all) => all.map((booking) => (booking.id === moved.id ? { ...booking, ...moved } : booking)))}
onSettle={tellPeopleBooked}
/>
</Card>Read only, with nothing to book or move
<Card className="gap-0 py-2">
<TimeGrid label="Evening bookings" start="17:00" end="21:00" hourWidth={120} resources={ROOMS} bookings={EVENING} />
</Card>