Skip to main content

Data

TimeGrid

A day, work week or week of hours, with events placed by their times: the calendar grid of a mail or meeting app. Each day has a column under a header, today's marked; all-day events have a row of their own; hours run down the side in the locale's words. Events that overlap sit side by side, each a button (a link with href) that shows its title first and is named for screen readers by the words it shows, in their order, then its day and its status ("Design review, 9:00 – 10:00 AM, Thursday, needs your answer"). Its lines are separate words even where a column stacks them, so the name holds what it shows and a speech-input user can say it to press it (WCAG 2.5.3, Label in Name). The grid is one Tab stop: the arrow keys move through events in time order (Up and Down) and across days (Left and Right), Home and End go to the first and the last, and Enter opens one. It opens scrolled to now or to the working day (to today's first event when now is before it), and a now line runs across today, under events so it never crosses their text.

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/time-grid#v1.3.10

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/time-grid

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/time-grid#v1.3.10 ourostack/ouro-ui/time-grid-tests#v1.3.10
import { TimeGrid } from "@/components/ouro/time-grid/time-grid";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<TimeGrid
  label="Team calendar"
  date="2026-03-12"
  view="work-week"
  events={[{ id: "1", title: "Design review", start: "2026-03-12T09:00:00Z", end: "2026-03-12T10:00:00Z", status: "unanswered" }]}
  onEventSelect={(event) => open(event.id)}
  className="h-[40rem]"
/>

Use instead when

  • Use Calendar instead to pick a date from a month, and Table or ItemGroup for an agenda list of events.

Props

Type: TimeGridProps.

allDayLabel
Type: string
Default: "All day"
The all-day row's name, shown beside it and heard in each all-day event's name. Translate it with your app.
backgroundEvents
Type: readonly TimeGridEvent[]
Events that are not the person's own, such as a shared calendar, a colleague's or a room's. Each day draws them in a lane of their own at its start, beside the person's events: thin bars that name themselves in a tooltip in a week, and labelled ones in a day. Each is a button that onEventSelect gets, reached with the arrow keys like the others. Timed events only: those that are all day, or 24 hours or longer, are left out.
backgroundLabel
Type: string
Default: "Other calendars"
What the background events are, heard after each one's title, time and day, and as the name of each day's lane: "Shared calendar". Translate it with your app.
className
Type: string
Layout only: the grid's height (h-full, h-[40rem]), width and margins. Appearance classes warn in development. In a grid taller than a day, the hours grow so the day ends at its foot.
date (required)
Type: string
The day to show, as an ISO date ("2026-03-12"). week and work-week show the week it is in. Move it with your own Today, Previous and Next buttons.
events (required)
Type: readonly TimeGridEvent[]
The events. Each shows on every day it covers that the grid shows; the rest are left out.
expandedId
Type: string | null
The id of the event whose popover of your own is open: it says so to screen readers (aria-expanded) and gets an outline until you clear it.
label (required)
Type: string
What the grid shows, read by screen readers as its name: "Team calendar". Required, because a scrolling page of hours says nothing on its own. It is not shown.
locale
Type: string
Default: "en-US"
The BCP 47 locale for the day names, the hours ("9 AM", "09"), the times and the first day of the week: "en-GB" starts weeks on Monday. The default is the same on the server and in the browser, so hydration matches.
now
Type: Date | string | number
The current time, when your app keeps the clock (a demo, a test, a time it shares with other views). Without it, the grid reads the browser's clock and moves the now line each minute.
nowAdornment
Type: ReactNode
Something small drawn where the now line meets the hours: a mascot, an avatar. Decoration only; screen readers skip it.
onDateChange
Type: ((date: string) => void)
Called with the ISO date to show when a key moves the days while focus is in the grid: Page Down and ⌥↓ (Alt+↓) the next day or week, Page Up and ⌥↑ the one before, and ⌥⇧Y (Alt+Shift+Y) today, as in Teams. Set date to it. TimeGridNav gives the same moves as buttons, with these keys in their tooltips.
onEventSelect
Type: ((event: TimeGridEvent, details: TimeGridSelectDetails) => void)
Called with the event when someone presses it, or presses Enter on it, and with its element and whether it is a background event, so a popover of your own can point at it.
onSlotSelect
Type: ((slot: TimeGridSlot) => void)
Called when someone presses an empty part of a day, with the slot under the pointer, for a new event there. A mouse shows the slot before the press. Slots answer a pointer only, so give keyboards a New event button of your own as well.
ref
Type: Ref<HTMLDivElement>
The grid's root element.
renderDaySummary
Type: ((day: string) => ReactNode)
Content of your own under each day's header, such as a count or a load bar. It gets the day as an ISO date. Screen readers read it, so give a bar words of its own.
selectedId
Type: string | null
The selected event's id: it gets an outline and aria-current.
slotMinutes
Type: 15 | 30 | 60
Default: 30
How long a slot onSlotSelect gets is, in minutes: 15, 30 or 60.
stackLabel
Type: ((count: number) => string)
Default: (count) => `${count} events`
The words on a stack of a day's background events drawn as one: in a day too narrow to label each of them, overlapping background events show as one block with their count, which opens their list. Translate it with your app.
statusLabels
Type: TimeGridStatusLabels
Default: { tentative: "tentative", free: "free", unanswered: "needs your answer", cancelled: "cancelled" }
The words screen readers hear for each status, after the event's title. Translate them with your app.
timeZone
Type: string
The IANA time zone the grid's clock reads ("Europe/Berlin"): which day and hour each event falls on, where the now line is, and the times shown. Without it, the reader's own, once the page runs in the browser; the server writes UTC until then.
view
Type: "day" | "week" | "work-week"
Default: "week"
day, work-week (the week without the locale's weekend) or week, from the locale's first day of the week.
workingHours
Type: { start: number; end: number }
Default: { start: 8, end: 17 }
The working day, in hours from midnight ({ start: 8.5, end: 17 }). The hours outside it, and weekend days, are shaded, and the grid opens scrolled to its start unless today is shown, when it opens at now, or at today's first event when now is before it.

Native attributes

Also accepts the attributes in React's HTMLAttributes (id, aria-*, event handlers), passed to the rendered element, except those named on this page.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • defaultDate: Not a prop. The day is date, which your app keeps: <TimeGrid date="2026-03-12">.
  • defaultView: Not a prop. The view is view: <TimeGrid view="work-week">, which your app keeps.
  • onSelectEvent: Not a prop. Ouro UI uses onEventSelect, which gets the event.
  • onSelectSlot: Not a prop. Ouro UI uses onSlotSelect, which gets { start, end }.
  • scrollToTime: Not a prop. The grid opens at the start of workingHours, or at now when today shows.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Parts

For what these props cannot express, pass as children the parts in @/components/ouro/time-grid/time-grid-parts. The nesting they need follows.

import {
  TimeGridNav,
} from "@/components/ouro/time-grid/time-grid-parts";

Required nesting

<TimeGridNav date={date} view={view} onDateChange={setDate} />
<TimeGrid label="Team calendar" date={date} view={view} onDateChange={setDate} events={events} />
TimeGridNav
What it is: Today, Previous and Next for a TimeGrid: three buttons that move its date by its view's day or week, each with its keyboard shortcut in its tooltip. The grid takes the same keys while focus is in it, with onDateChange: Page Up and ⌥↑ (Alt+↑) the period before, Page Down and ⌥↓ the next, ⌥⇧Y (Alt+Shift+Y) today. Use your own buttons instead when the toolbar picks a month or a date as well; give the grid onDateChange for its keys either way.

Types

TimeGridEvent

One event on a TimeGrid.

interface TimeGridEvent {
  /** Unique among the grid's events. `onEventSelect` gets the event back, and `selectedId` names it. */
  id: string;
  /** What the event is, shown first and read first: `"Design review"`. */
  title: string;
  /**
   * When it starts: a `Date`, an ISO string or a timestamp in milliseconds. A calendar date with
   * no time (`"2026-03-12"`) makes the event all day, on that day for every reader, in the
   * all-day row.
   */
  start: Date | string | number;
  /**
   * When it ends. A timed event needs one. An all-day event's `end` is its last day, included
   * (`"2026-03-14"` for three days from the 12th); without it the event is one day. A timed event
   * 24 hours or longer goes in the all-day row across the days it covers.
   */
  end?: Date | string | number | undefined;
  /** A second line where there is room: a place, a room, who organised it. Screen readers hear it last. */
  description?: string | undefined;
  /** Its colour, from your theme. @default "primary" */
  tone?: TimeGridTone | (string & { 'danger is "destructive"; accent is "primary"; neutral is "secondary"': never }) | undefined;
  /**
   * What it does to the person's time. `busy` is filled; `tentative` is hatched; `free` is
   * outlined; `unanswered`, an invitation still waiting for an answer, is outlined in dashes;
   * `cancelled` is struck through. Screen readers hear every status but busy, in the words of
   * `statusLabels`.
   * @default "busy"
   */
  status?: TimeGridStatus | (string & { 'Use "busy", "tentative", "free", "unanswered" or "cancelled"': never }) | undefined;
  /** Makes the event a link to its page, through `OuroProvider`'s router link for a path in your app. */
  href?: string | undefined;

  /**
   * Not a field. An all-day event's `start` is a date: `{ start: "2026-03-12" }`.
   */
  allDay?: { 'Pass start as a date: start: "2026-03-12"': never };
  /** Not a field. Colours come from the theme: `{ tone: "success" }`. */
  color?: { 'Use tone: "success"': never };
}

TimeGridSelectDetails

What TimeGrid's onEventSelect gets besides the event.

interface TimeGridSelectDetails {
  /** The event's element, to anchor a popover of your own to (`<Popover anchor={ref}>`). */
  element: HTMLElement;
  /** Whether it is one of `backgroundEvents`. */
  background: boolean;
}

TimeGridSlot

An empty stretch of time someone pressed, from TimeGrid's onSlotSelect.

interface TimeGridSlot {
  start: Date;
  end: Date;
}

TimeGridStatusLabels

The words screen readers hear for each status but busy.

type TimeGridStatusLabels = Partial<Record<Exclude<TimeGridStatus, "busy">, string>>;

Claim sources