Source: https://monarchui.dev/components/time-grid/

Components · Date, time and scheduling

# TimeGrid

Day and week view: hours, an all-day row, overlap layout, now line, working hours and slots.

@rookcom/monarch-react/scheduling

Component

## Work week

## Usage examples

### Day view: create and drag

## Purpose

Places events on a day or week by time, so people see overlaps and free time and can create or move events. Needed by: calendar.

### Use when

- The day and week views of a calendar.
- Any schedule where free time matters: interviews, bookings, shifts.

### Avoid when

- A whole month at once: use CalendarMonthView.
- Picking one free slot from a list: show the slots as buttons instead.
- Long-running work on a timeline (weeks or months per row): a timeline or Gantt view fits better.

## Design principles

- Overlapping events sit side by side: each cluster of overlapping events splits the column evenly (the Rook Calendar layout).
- Time is the structure: one hour gutter, hour lines and faint half-hour lines, shading outside working hours.
- Inject `now` (and `timeZone`, `locale`) for deterministic server rendering and tests.
- Creating works from both pointer and keyboard: slot buttons are one tab stop with arrow-key movement.
- Dragging is an enhancement; the product must also offer a non-drag way to reschedule.

## API reference

```tsx
import { TimeGrid } from '@rookcom/monarch-react/scheduling';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `TimeGrid` | component | client | `.mn-time-grid` |

The root `<div role="group">` (ref) is the scroll container for both axes; name it with `aria-label` (for example "Week of 2 November").

Days are ISO dates; events are `CalendarEvent` objects placed in `timeZone` with Intl. All-day events go in the all-day row; multi-day timed events are clipped to each day.

`onSlotClick` receives `{ day, minutes, start }`; `onEventMove` receives `{ event, start, end }` with the event’s duration kept.

`workingHours` takes the WeeklyHoursEditor value (`{ day, start, end }[]`).

Resizing events by dragging their edge is out of scope: Rook Calendar only moves events by drag. Products change durations in their event form.

Generated from the TypeScript declarations of `packages/react/src/components/scheduling/time-grid`.

## Properties

### `TimeGrid`

The day and week view of a calendar: an hour gutter, an all-day row and one column per day, with overlapping events laid out side by side, a now indicator, working-hours shading and click-to-create slots that also work from the keyboard.

| Prop | Type | Description |
| --- | --- | --- |
| `days` required | `readonly string[]` | The day columns, in order: one for a day view, seven for a week. `'2026-11-04'`. |
| `canMoveEvent` | `((event: CalendarEvent) => boolean)` | Which events may be dragged. Default: all, when `onEventMove` is set. |
| `className` | `string` |  |
| `endHour` | `number` | End of the visible range (exclusive hour). Default 24. |
| `events` | `readonly CalendarEvent[]` |  |
| `hourCycle` | `HourCycle` | Force a 12- or 24-hour clock (`'12'` or `'24'`, as in the date pickers); by default the locale decides. |
| `hourHeight` | `number` | Height of one hour in pixels. Default 48. |
| `id` | `string` |  |
| `labels` | `TimeGridLabels` |  |
| `locale` | `string` | BCP 47 locale for day and time labels. |
| `now` | `string \| number \| Date \| null` | The current time, for the now indicator and today's header. Inject it for server rendering and tests. When left out the grid reads the clock after mounting and moves the line every minute; `null` hides it. |
| `onDayClick` | `((day: string) => void)` | Makes multi-day headers buttons, for example to open the day view. |
| `onEventClick` | `((event: CalendarEvent) => void)` |  |
| `onEventMove` | `((move: TimeGridEventMove) => void)` | Turns on dragging events to a new time or day (pointer only; touch keeps scrolling). The product decides whether to apply or confirm the move. Always offer another way to reschedule, such as an action in the event's popover. |
| `onSlotClick` | `((slot: TimeGridSlot) => void)` | Called when an empty slot is chosen, by pointer (snapped) or by keyboard (Enter on a slot button). Turns on the slot buttons. |
| `renderEvent` | `((event: CalendarEvent, chip: ReactElement) => ReactNode)` | Wrap or replace each event's chip, for example in a PopoverTrigger. Keep a single focusable element. |
| `scrollToMinutes` | `number` | Minutes from midnight to scroll to on mount and when the first day changes. Default: an hour before the earliest event, else 08:00. |
| `showAllDayRow` | `boolean` | Show the all-day row even when no all-day event is visible. |
| `snapMinutes` | `number` | Pointer clicks and drags snap to this many minutes. Default 15. |
| `startHour` | `number` | First visible hour. Default 0. |
| `timeZone` | `string` | IANA zone the grid is drawn in. Defaults to the runtime's zone; pass it for server rendering. |
| `unavailable` | `readonly TimeGridUnavailable[]` | Hatched blocks such as time off or closures. |
| `workingHours` | `readonly WeeklyHoursInterval[]` | Working hours as `{ day, start, end }` intervals (ISO weekday 1–7, minutes from midnight), the WeeklyHoursEditor value. Time outside them is shaded. Leave out to shade nothing. |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `days` | `'1 day'``'2–7 days'` | A day view or a week (or work-week) view. Several days get a minimum column width and scroll sideways on narrow screens. |
| `startHour / endHour` | `'0–23'``'1–24'` | The visible range of hours. Events outside it are clipped. |
| `hourHeight` | `'number (px)'` | Height of one hour. Default 48. |
| `showAllDayRow` | `'true'``'false'` | Keep the all-day row when no all-day event is visible. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Today | `data-today (header, column)` | Accent date in the header and the now line in its column; "Today" in the header name. |
| Outside working hours | – | Muted shading (`.mn-time-grid__shade`), decorative. |
| Unavailable | – | Hatched block with its label (time off, closures). |
| Creatable | `data-creatable (column)` | Cell cursor and hover highlight on slots. |
| Dragging | `data-dragging (root), data-ghosted (event)` | The event fades and a dashed ghost shows the new time. |
| Compact event | `data-density="compact"` | Events under about 38px show start time and title on one line. |

## Interaction behaviour

- Clicking empty time calls `onSlotClick` with the time snapped down to `snapMinutes`.
- Clicking an event calls `onEventClick`; `renderEvent` can wrap it in a popover trigger.
- With `onEventMove`, a mouse or pen drag of more than 4px moves the event (snapped, across days); the following click is swallowed. Touch keeps scrolling. Escape cancels a drag.
- On mount and when the first day changes, the grid scrolls to `scrollToMinutes` (default an hour before the first event, else 08:00).
- Without `now`, the grid reads the clock after mounting and moves the line every minute.

## Keyboard support

| Keys | Action |
| --- | --- |
| Tab | Day headers (with `onDayClick`), all-day events, then the slot buttons (one stop) and events in time order. |
| ArrowUp / ArrowDown | Previous or next hour slot. |
| ArrowLeft / ArrowRight | Same hour on the previous or next day (mirrored in right-to-left). |
| Home / End | First or last visible hour; with Ctrl, also the first or last day. |
| Enter / Space | On a slot: `onSlotClick` at that hour. On an event: activates it. |
| Escape | Cancels a drag in progress. |

Keyboard creation uses whole hours; pointer creation snaps to `snapMinutes`. Moving an event by keyboard is left to the product’s reschedule action.

## Accessibility

Role

group (root and each day column)

Accessible name

Each column is named by its full date; slots are named "Create event, Wednesday 4 November, 09:00"; events "title, time range, status".

- The hour gutter, shading, now line and drag ghost are decorative (`aria-hidden`).
- Day headers expose the full date to assistive technology and `aria-current="date"` for today.
- Dragging has a non-drag alternative by contract (WCAG 2.5.7): document a reschedule action next to `onEventMove`.
- Verified with axe in jsdom and in Chromium, Firefox and WebKit on /components/time-grid.

## Responsive behaviour

- A multi-day grid keeps columns at least 5.5rem wide and scrolls sideways inside its own box, with the hour gutter and headers sticky.
- A single day fills its container and never scrolls sideways; show the day view on phones.
- Height is capped by `--mn-time-grid-max-height` (40rem); the body scrolls inside it.

## Theme support

- Lines, shading and surfaces are Monarch tokens; light, dark and more-contrast themes apply automatically.
- The now line uses `--mn-color-danger-solid`; today’s date uses the product accent.
- In forced-colours mode the now line uses Highlight.

## Do and don't

### Do

- Name the grid with the range it shows.
- Pass `now`, `timeZone` and `locale` when rendering on the server.
- Confirm or undo drag moves in the product (a toast with Undo, or a confirm step for bookings).
- Offer a reschedule action in the event popover for keyboard and touch users.

### Don't

- Do not compute overlap layout yourself; pass all events for the range.
- Do not show a seven-day week on phones; switch to the day view or AgendaList.

## Related components

- [EventChip](https://monarchui.dev/components/event-chip/): The event blocks and all-day bars.
- [WeeklyHoursEditor](https://monarchui.dev/components/weekly-hours-editor/): Edits the value `workingHours` takes.
- [CalendarMonthView](https://monarchui.dev/components/calendar-month-view/): The month view.
- [AgendaList](https://monarchui.dev/components/agenda-list/): The list view, and the phone layout of a week.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [EventChip](https://monarchui.dev/components/event-chip/) |

`workingHours` reuses the WeeklyHoursEditor value type (a type-only import).

## Testing coverage

- Events placed by time; overlapping events split into columns; short events compact with the full range in the name.
- All-day row, now line at the injected time in today’s column, Today in the header name.
- `now={null}` hides the line; the clock is read after mount when left out.
- Working-hours shading and unavailable blocks.
- Pointer slot click snapped to the grid.
- Keyboard slots: one tab stop, arrows, Home/End, Ctrl+End, Enter creates in the given time zone.
- Event and day-header callbacks.
- Drag moves an event across days keeping its length, shows a ghost and swallows the click; touch, tiny moves and non-movable events are ignored.
- Visible hour range clips events; no axe violations; renders on the server without the clock.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/time-grid; Cross-browser axe: /components/time-grid |

Tests: `packages/react/src/components/scheduling/time-grid`

## All example code

### Work week

```tsx
import { TimeGrid, type CalendarEvent } from '@rookcom/monarch-react/scheduling';

const at = (day: string, time: string) => `${day}T${time}:00Z`;

const WEEK = ['2026-11-02', '2026-11-03', '2026-11-04', '2026-11-05', '2026-11-06'];

const HOURS = [1, 2, 3, 4, 5].flatMap((day) => [
  { day, start: 540, end: 720 },
  { day, start: 780, end: 1080 },
]);

const EVENTS: CalendarEvent[] = [
  {
    id: 't1',
    title: 'Conference',
    start: '2026-11-03',
    end: '2026-11-04',
    allDay: true,
    color: '#ca8a04',
  },
  {
    id: 't2',
    title: 'Stand-up',
    start: at('2026-11-02', '09:00'),
    end: at('2026-11-02', '09:15'),
    color: '#0f766e',
  },
  {
    id: 't3',
    title: 'Design review',
    start: at('2026-11-04', '09:30'),
    end: at('2026-11-04', '11:00'),
    detail: 'Room 4',
    color: '#7c3aed',
  },
  {
    id: 't4',
    title: 'Interview',
    start: at('2026-11-04', '10:00'),
    end: at('2026-11-04', '11:00'),
    status: 'tentative',
    color: '#0891b2',
  },
  {
    id: 't5',
    title: 'Lunch & learn',
    start: at('2026-11-05', '12:00'),
    end: at('2026-11-05', '13:00'),
    color: '#2563eb',
  },
  {
    id: 't6',
    title: 'Retrospective',
    start: at('2026-11-06', '15:00'),
    end: at('2026-11-06', '16:00'),
    status: 'cancelled',
    color: '#c2410c',
  },
  {
    id: 't7',
    title: 'Planning',
    start: at('2026-11-03', '14:00'),
    end: at('2026-11-03', '15:30'),
    color: '#db2777',
  },
];

const base = {
  timeZone: 'UTC',
  locale: 'en-GB',
  now: at('2026-11-04', '10:40'),
  scrollToMinutes: 8 * 60,
} as const;

<div style={{ width: '100%' }}>
  <TimeGrid
    {...base}
    aria-label="Week of 2 November"
    days={WEEK}
    events={EVENTS}
    workingHours={HOURS}
    unavailable={[
      {
        id: 'off',
        start: at('2026-11-06', '09:00'),
        end: at('2026-11-06', '12:00'),
        label: 'Time off',
      },
    ]}
    onDayClick={() => {}}
    style={{ ['--mn-time-grid-max-height' as string]: '26rem' }}
  />
</div>
```

### Day view: create and drag

```tsx
import { useState } from 'react';
import { TimeGrid, type CalendarEvent } from '@rookcom/monarch-react/scheduling';

const at = (day: string, time: string) => `${day}T${time}:00Z`;

const HOURS = [1, 2, 3, 4, 5].flatMap((day) => [
  { day, start: 540, end: 720 },
  { day, start: 780, end: 1080 },
]);

const EVENTS: CalendarEvent[] = [
  {
    id: 't1',
    title: 'Conference',
    start: '2026-11-03',
    end: '2026-11-04',
    allDay: true,
    color: '#ca8a04',
  },
  {
    id: 't2',
    title: 'Stand-up',
    start: at('2026-11-02', '09:00'),
    end: at('2026-11-02', '09:15'),
    color: '#0f766e',
  },
  {
    id: 't3',
    title: 'Design review',
    start: at('2026-11-04', '09:30'),
    end: at('2026-11-04', '11:00'),
    detail: 'Room 4',
    color: '#7c3aed',
  },
  {
    id: 't4',
    title: 'Interview',
    start: at('2026-11-04', '10:00'),
    end: at('2026-11-04', '11:00'),
    status: 'tentative',
    color: '#0891b2',
  },
  {
    id: 't5',
    title: 'Lunch & learn',
    start: at('2026-11-05', '12:00'),
    end: at('2026-11-05', '13:00'),
    color: '#2563eb',
  },
  {
    id: 't6',
    title: 'Retrospective',
    start: at('2026-11-06', '15:00'),
    end: at('2026-11-06', '16:00'),
    status: 'cancelled',
    color: '#c2410c',
  },
  {
    id: 't7',
    title: 'Planning',
    start: at('2026-11-03', '14:00'),
    end: at('2026-11-03', '15:30'),
    color: '#db2777',
  },
];

const base = {
  timeZone: 'UTC',
  locale: 'en-GB',
  now: at('2026-11-04', '10:40'),
  scrollToMinutes: 8 * 60,
} as const;

function CreateDemo() {
  const [events, setEvents] = useState<CalendarEvent[]>(EVENTS.filter((e) => e.id === 't3'));
  return (
    <div style={{ width: '100%', maxWidth: 420 }}>
      <TimeGrid
        {...base}
        aria-label="Wednesday 4 November"
        days={['2026-11-04']}
        events={events}
        workingHours={HOURS}
        onSlotClick={(slot) =>
          setEvents((list) => [
            ...list,
            {
              id: `new-${list.length}`,
              title: 'New event',
              start: slot.start,
              end: new Date(slot.start.getTime() + 30 * 60000),
            },
          ])
        }
        onEventMove={({ event, start, end }) =>
          setEvents((list) => list.map((e) => (e.id === event.id ? { ...e, start, end } : e)))
        }
        style={{ ['--mn-time-grid-max-height' as string]: '22rem' }}
      />
    </div>
  );
}

<CreateDemo />
```
