Week and day view
StableDays across, hours down: a time grid with an all-day row, overlaps side by side, and a line at the current time.
TimeGrid draws any number of days at 48 pixels an hour, with sticky day headings, an hour gutter headed by the time zone, and an all-day row that folds into “+N” past three lanes. Overlapping events sit side by side, and a click on an empty slot reports a start snapped down to 15 minutes. NowLine is the current-time line on its own: a 2px rule led by the brand’s solid square.
A sample week
import { useState } from 'react'import { sampleEvents, type CalendarEvent } from '@rchursin/product/data/sample'import { TimeGrid } from '@rchursin/product/calendar'import { formatDateRange, speakable } from '@rchursin/product/calendar/dates'import { eventTime } from '@rchursin/product/calendar/events'import { addDays, addMinutes, formatDay, startOfWeek } from '@rchursin/product/lib/dates'import { Button, useToast } from '@rchursin/product/ui'
/** A fixed moment in the sample week, so the page reads the same on every visit */const NOW = new Date(2026, 9, 13, 10, 40)
/** * A week on its own copy of the sample events. Click an empty slot to add an hour-long event * there (snapped to 15 minutes), or use the button; open an event from its block. */export default function TimeGridWeek() { const toast = useToast() const [events, setEvents] = useState(() => sampleEvents(NOW)) const days = Array.from({ length: 7 }, (_, i) => addDays(startOfWeek(NOW), i))
Usage
import { HOUR_PX, NowBadge, NowLine, TimeGrid, gridLines } from '@rchursin/product/calendar'<TimeGrid days={week} events={events} now={now} openEventId={null} onOpenEvent={(event, el) => openDetails(event, el)} onCreateAt={(start) => openCreate(start)} label="Week of October 11 to 17, 2026" className="h-[32rem]"/>Examples
One day
One day is the same grid with one column, the layout phones start on.
import { sampleEvents } from '@rchursin/product/data/sample'import { TimeGrid } from '@rchursin/product/calendar'import { eventTime } from '@rchursin/product/calendar/events'import { formatDayLong, formatTime, startOfDay } from '@rchursin/product/lib/dates'import { useToast } from '@rchursin/product/ui'
const NOW = new Date(2026, 9, 13, 10, 40)const events = sampleEvents(NOW)
/** The day view is the same grid with one day. The Calendar app starts phones here, because seven columns don’t fit 390 pixels. */export default function TimeGridDay() { const toast = useToast() const day = startOfDay(NOW) return ( <div className="w-full max-w-sm overflow-hidden rounded-xl bg-surface ring-1 ring-line"> <TimeGrid days={[day]} events={events} now={NOW} startHour={9} openEventId={null} onOpenEvent={(event) => toast({ title: event.title, description: eventTime(event), icon: 'calendar' })} onCreateAt={(start) => toast({ title: 'New event', description: `The Calendar app opens the event form at ${formatTime(start)}.`, icon: 'calendar-plus' })} label={`${formatDayLong(day)} (sample)`} className="h-[28rem]" /> </div> )}Current time
NowLine and NowBadge outside the grid: the line crosses the event, and the time sits in reverse in the gutter.
import { sampleEvents } from '@rchursin/product/data/sample'import { EventBlock, gridLines, HOUR_PX, NowBadge, NowLine } from '@rchursin/product/calendar'import { at, dtf } from '@rchursin/product/lib/dates'import { useToast } from '@rchursin/product/ui'
const NOW = new Date(2026, 9, 13, 10, 40)/** “Focus: context tax”, 2 to 3:30 PM in the sample week */const event = sampleEvents(NOW).find((e) => e.id === 'e13')!/** A moment inside it, so the line always crosses the block */const time = at(event.start, 14, 50)/** Pixels down from 1 PM, the top of this slice of the grid */const y = (h: number, m = 0) => (h - 13 + m / 60) * HOUR_PX
/** NowLine and NowBadge on their own: three hours of a day column, drawn the way TimeGrid draws today. */export default function NowLineExample() { const toast = useToast() const nowY = y(14, 50) return ( <div className="grid w-full max-w-sm grid-cols-[56px_1fr] overflow-hidden rounded-xl bg-surface ring-1 ring-line" style={{ height: 3 * HOUR_PX }}> <div aria-hidden="true" className="relative"> {[14, 15].map((h) => // Hour labels make room for the time badge Math.abs(y(h) - nowY) < 12 ? null : ( <span key={h} className="absolute right-2 -translate-y-1/2 font-mono text-[10px] text-fg-3" style={{ top: y(h) }}> {dtf({ hour: 'numeric' }).format(at(time, h))} </span> ), )} <NowBadge y={nowY} time={time} /> </div> <div className="relative border-l border-line" style={gridLines}> <EventBlock event={event} height={1.5 * HOUR_PX - 2} style={{ top: y(14) + 1, left: 2, right: 4 }} onOpen={(e) => toast({ title: e.title, icon: 'calendar' })} /> <NowLine y={nowY} /> </div> </div> )}API
TimeGrid props
| Prop | Type | Default | Description |
|---|---|---|---|
daysrequired | Date[] | – | The days to show, left to right. One day makes the day view; with more, each column is at least 92px. |
eventsrequired | CalendarEvent[] | – | Timed and all-day events; only those on the shown days are drawn. |
nowrequired | Date | – | Draws the line across today when today is shown. Pass useNow() for a live line. |
openEventIdrequired | string | null | – | The event whose details are open (drawn raised, aria-expanded). |
onOpenEventrequired | (event: CalendarEvent, el: HTMLButtonElement) => void | – | An event was pressed. Anchor its details to el. |
onCreateAtrequired | (start: Date) => void | – | A click on an empty slot, snapped down to 15 minutes. |
onOpenDay | (day: Date) => void | – | Makes the day headings buttons (several days only), e.g. to open the day view. |
startHour | number | 8 | The hour scrolled to the top when the grid first shows. |
labelrequired | string | – | Name of the region, e.g. “Week of October 11 to 17, 2026”. |
className | string | – | Give it a height: it scrolls both ways inside. |
NowLine props
| Prop | Type | Default | Description |
|---|---|---|---|
yrequired | number | – | Pixels from the top of the day column: minutes since midnight ÷ 60 × HOUR_PX. |
NowBadge props
| Prop | Type | Default | Description |
|---|---|---|---|
yrequired | number | – | The same y as the line. |
timerequired | Date | – | The time to print. |
Grid helpers
| Prop | Type | Default | Description |
|---|---|---|---|
HOUR_PX | number | 48 | Pixels per hour. Event tops and heights, and the line, are worked out from it. |
gridLines | CSSProperties | – | A style for a day column: hour lines and fainter half-hour lines drawn as its background. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Tab | Moves through the day headings (when they are buttons), the all-day bars, then each day’s events in time order. |
| EnterSpace | Open the focused event, or the day from its heading. |
- A scrolling region named by label. Each day column is a group named like “Tuesday, October 13, 2026, 5 events”.
- Every event is a button named in full (“Standup, Tuesday 9:30 to 9:45 AM, Team calendar”) with aria-haspopup="dialog" and aria-expanded.
- Today’s heading says “Today, …” and carries aria-current="date".
- The hour gutter, the dashed preview that follows the pointer and the time line are hidden from screen readers; the zone is read as “Times in Pacific Time”.
- Creating by clicking a slot works with a mouse only, so keep a New event button (the Calendar app has one, and N).
Guidelines
Do
- Give the grid a fixed height. It scrolls inside, keeping the headings and the gutter in view.
- Name the region after the period shown, the way the Calendar app does.
- Pass a live now (useNow) in an app; these examples use a fixed sample time.
Don’t
- Don’t show seven columns on a phone; use one day.
- Don’t make clicking a slot the only way to create an event.
In use
Related
- CalendarThe whole calendar: week, day, month and agenda views, calendars told apart by pattern, and create, edit and delete.
- Month viewThe month as a keyboard grid: up to three events a day, “+N more” for the rest, and pattern squares on phones.
- Event blocksHow an event looks in each calendar view: a block in the week grid, a bar for all-day, a chip in a month cell.
- Event layoutThe calendar’s layout maths: overlapping events side by side, all-day bars stacked into lanes, and the sentence a screen reader hears.
- useMediaQuery & useNowTwo small hooks from the calendar: a media query as a boolean, and the current time that re-renders on each whole minute or second.
- Fills & patternsSolid, hatched, outline and dashed: the second “colour” of a black-and-white interface, plus the dot-grid canvas.
Source
The component’s own files, exactly as they run on this site.
/** * TimeGrid: the week and day views. Days across, hours down at 48px an hour, an all-day row on * top, hours in a gutter on the left (headed by the visitor's time zone) and a line at the * current time across today. One scroll box for both axes, so the header and gutter stay put * (sticky) while a phone scrolls a week sideways. Opens at 8 AM. * * Overlapping events sit side by side (column packing, see layout.ts). A click on an empty * slot creates an event there, snapped down to the quarter hour; a dashed preview follows the * pointer to show where it will land. */import { useLayoutEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type MouseEvent,} from "react"import { cx } from "../../components/cx"import type { CalendarEvent } from "../data/sample"