Skip to content
txbrendan/ui

Time Grid

beta

A 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

Now 14:20
Room 1Seats 12 · screen
Room 2Seats 6
Room 3Closed ·Seats 6
Closed · Repainting until Sun 11 Oct
BoardroomSeats 20
Training, Workshop, 09:30–12:00 · 8 teams, Room 2, ended
Training, Support training, 11:30–13:00 · Host Sam Carter, Room 2, ended
Training, Sales training, 15:00–16:30, Room 2
Meeting, Planning, 10:00–11:00, Boardroom, ended
Social, Team drinks, 16:00–18:00 · All teams, Boardroom
Room 1Seats 12 · screen
Room 2Seats 6
Room 3Closed ·Seats 6
Closed · Repainting until Sun 11 Oct
BoardroomSeats 20
Training, Support training, 17:30–18:30 · Host Sam Carter, Room 1
Training, New hire training, 18:30–20:30 · Host Ava Patel, Room 1
Training, Sales training, 18:00–19:30, Room 2
Training, Workshop, 19:30–20:30, Room 2
Meeting, Team sync, 19:00–20:00, Boardroom
Social, Quiz night, 20:00–21:00 · All teams, Boardroom

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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>