Skip to content

Scheduler

A day/week/month/agenda event-grid — a time-canvas that positions, packs and edits events, with timezone/DST-correct math and recurrence.

Betav0.4.2added in v0.2.0@garn/ui/scheduler
On this page

Default

The full editor: a week canvas with a useScheduler toolbar, all-day lane, overlap packing, drag/keyboard create-move-resize, selection and the live now-line.

Views

Week Editor

The full editor: a week canvas with a useScheduler toolbar, all-day lane, overlap packing, drag/keyboard create-move-resize, selection and the live now-line.

Day

A single day — dense overlaps show the interval-graph column packing.

Month

The month day-grid — spanning bars for multi-day events, per-cell “+N more”, role=grid keyboard navigation.

Agenda

The agenda list — events grouped by day, including a recurring (rrule) sync.

Layout

Dense

Heavy overlaps with `maxStack` — extra columns collapse into a “+N” Popover.

Appearance

Tones

The event tone × appearance range (solid / soft / outline).

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add scheduler
First time? Set up garn in your project
Registry
scheduler
Deps
class-variance-authoritylucide-react@radix-ui/react-slot
Registry deps
utilsuse-controllable-stateuse-announceuse-prefers-reduced-motionuse-isomorphic-layout-effectdrag-gesturepopover

Import

import { Scheduler, SchedulerEvent } from "@garn/ui/scheduler";

Anatomy

The parts this component is built from — compose them to assemble it.

SchedulerRequired[data-slot="scheduler"]

Root + provider (generic over the event type). Owns view/date/selection + the drag machine and the clock; renders whichever view (time-canvas / month / agenda) view selects. Lifts useScheduler for a consumer toolbar.

SchedulerEvent[data-slot="scheduler-event"]

The default event block — tone × appearance (badge tokens), a leading tone rail, optional resize handles, and asChild to render an <a>/custom card. Exported for use inside renderEvent.

Props

The component's public props and their types.

agendaDays
Description
Days spanned by the agenda view.
Type
number
Default
14
apiRef
Description
An imperative handle for driving the scheduler from outside a child.
Type
React.Ref<SchedulerApi>
date
Description
Controlled anchor date the current view is built around.
Type
Date
defaultDate
Description
Uncontrolled initial anchor date.
Type
Date
defaultSelectedId
Description
Uncontrolled initial selected-event id.
Type
string
Default
null
defaultView
Description
Uncontrolled initial view.
Type
SchedulerView
Default
"week"
editable
Description
Allow events to be moved and resized (per-event `editable: false` opts one out).
Type
boolean
Default
false
eventsRequired
Description
The events to place on the grid. Recurring events (`rrule`) expand automatically.
Type
E[]
firstDay
Description
First day of the week (0 = Sunday … 6 = Saturday). Defaults to the locale's.
Type
number
hour12
Description
Force a 12- or 24-hour clock. Defaults to the locale's convention.
Type
boolean
locale
Description
BCP-47 locale for date/time formatting. Defaults to the runtime locale.
Type
string
maxStack
Description
Cap on side-by-side columns for colliding events before they compress.
Type
number
monthMax
Description
Max events shown in a month-view day cell before "+N more".
Type
number
Default
3
nowIndicator
Description
Draw the live "now" line across the time views.
Type
boolean
Default
true
onDateChange
Description
Called when the anchor date changes (navigation, today, go-to-date).
Type
(date: Date) => void
onEventClick
Description
Fired when an event is activated (click / Enter).
Type
(event: E, meta: SchedulerEditMeta) => void
onEventMove
Description
Fired when an event is dragged to a new time (needs `editable`).
Type
(event: E, range: { start: Date; end: Date; }, meta: SchedulerEditMeta) => void
onEventResize
Description
Fired when an event is resized (needs `editable`).
Type
(event: E, range: { start: Date; end: Date; }, meta: SchedulerEditMeta) => void
onExternalDrop
Description
Fired when something is dropped onto the grid from outside (native drag).
Type
(info: { start: Date; end: Date; }) => void
onRangeSelect
Description
Fired when a time range is selected (needs `selectable`).
Type
(range: { start: Date; end: Date; allDay: boolean; }) => void
onSelectedChange
Description
Called when the selected event changes.
Type
(id: string | null) => void
onSlotClick
Description
Fired when an empty slot is clicked, with the time at that point.
Type
(at: Date) => void
onViewChange
Description
Called when the active view changes.
Type
(view: SchedulerView) => void
ref
Description
No description.
Type
React.Ref<HTMLDivElement>
renderEvent
Description
Custom renderer for an event block — receives the original event plus a context (view, placement, drag/clip flags). Omit to use the default block.
Type
(event: E, ctx: SchedulerEventContext) => React.ReactNode
scrollTime
Description
Time the canvas is initially scrolled to, "HH:MM" (time views).
Type
string
selectable
Description
Allow dragging (or click) across empty time to select a range — fires `onRangeSelect`.
Type
boolean
Default
false
selectedId
Description
Controlled id of the selected event (`null` = none).
Type
string
slotDuration
Description
Minutes per time slot on the vertical axis (the grid-line spacing).
Type
number
Default
30
slotMaxTime
Description
Last time shown on the vertical axis, "HH:MM" (use "24:00" for end-of-day).
Type
string
Default
"24:00"
slotMinTime
Description
First time shown on the vertical axis, "HH:MM".
Type
string
Default
"00:00"
snapDuration
Description
Minutes that drag-create/move/resize snaps to. Defaults to `slotDuration`.
Type
number
timeZone
Description
IANA time zone for display + positioning. Default: the runtime's local zone.
Type
string
view
Description
Controlled active view (day / week / work-week / month / agenda).
Type
SchedulerView

Plus 278 inherited native <div> attributes.

SchedulerEvent

asChild
Description
Render the single child element as the event block (via Radix `Slot`) instead of a `<button>` — for a custom event surface inside `renderEvent`.
Type
boolean
Default
false
onResizeStart
Description
Called when a resize handle is pressed, with which edge and the pointer event.
Type
(edge: "start" | "end", e: React.PointerEvent) => void
ref
Description
No description.
Type
React.Ref<HTMLButtonElement>
resizable
Description
Show top/bottom resize handles on the block.
Type
boolean

Plus 291 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="scheduler"][data-slot="scheduler-header"][data-slot="scheduler-day-heading"][data-slot="scheduler-all-day"][data-slot="scheduler-axis"][data-slot="scheduler-body"][data-slot="scheduler-day-column"][data-slot="scheduler-event"][data-slot="scheduler-now-indicator"][data-slot="scheduler-overflow"][data-slot="scheduler-month"][data-slot="scheduler-day-cell"][data-slot="scheduler-agenda"]

States

SelectorState
[data-slot=scheduler][data-view]view
[data-today]today
[data-slot=scheduler-event][data-selected=true]selected
[data-slot=scheduler-event][data-dragging=true]dragging
[data-slot=scheduler-event][data-moving=true]moving
[data-slot=scheduler-event][data-continuation]continuation
PropertyTokenTier
hourHeight--garn-scheduler-hourcomponent · identity
axisWidth--garn-scheduler-axiscomponent · identity
nowLine--garn-danger-solidsemantic
surface--garn-cardsemantic
hairline--garn-bordersemantic
selectedRing--garn-brand-solidbrandablesemantic

Private instance vars (never externalize): --ev-start--ev-span--ev-col--ev-colspan--ev-cols--bc--bs--ln--e-rail

When to use

Displaying and editing events against time — a day/week planner, a booking/availability grid, a team calendar, a room/resource schedule, or an agenda feed.

Reach for something else when

  • Picking a single date or range (use date-picker / calendar / date-range picker)
  • a read-only month wall-calendar with no time axis (a calendar may suffice)
  • a plain list of items with no temporal layout (use list / table / timeline).

Overview

Scheduler is a time canvas, not a date picker. In its day / week / work-week views it draws a vertical hour axis with day columns, slot lines, an all-day lane and a live now-line, and positions events by their start/end — packing overlapping events side-by-side. view="month" switches to a day-grid with multi-day spanning bars, and view="agenda" to a chronological list. It is a full editor: drag empty time to create, drag a block to move it (even across days), drag its edges to resize — by pointer or keyboard — with optimistic callbacks so you own the events. All time math flows through one clock, so a timeZone and DST transitions stay correct, and events with an rrule expand to occurrences for the visible span. For picking a single date, use calendar/date-picker — Scheduler can't shrink to that, and DayPicker can't stretch to this.

Guidelines

Own the data; treat the callbacks as intents. events is a controlled data prop — onEventMove / onEventResize / onRangeSelect hand you the proposed range (plus a meta identifying the recurrence occurrence via baseId + occurrenceStart); apply it to your own state. Nothing mutates until you do.

Style only through tone × appearance and the render slot. Give events a tone (the shared vocabulary) and appearance (solid/soft/outline); reach for renderEvent when you need custom content — it applies in every placement (column, all-day, month cell, agenda row). Don't hand-position or hardcode colors; the canvas computes geometry into CSS variables.

Keep it operable and honest. The keyboard model is the differentiator — arrow-navigate events, Enter to open, Shift/Alt+arrows to reschedule; don't strip the focus ring or the sr-only instructions. Pass date/defaultDate for SSR-deterministic output, and a timeZone when events live in a fixed zone.

Best practices

Do
  • Keep events in your own state and apply the move/resize/range callbacks optimistically.
  • Crop the band with slotMinTime/slotMaxTime and land the scroll with scrollTime (e.g. business hours).
  • Give overlapping-heavy days a maxStack so dense clusters collapse into a “+N more”.
  • Pass a timeZone when events are anchored to a fixed zone; pass date/defaultDate for SSR.
  • Build a toolbar from useScheduler (or drive it via apiRef).
Don't
  • Don't mutate the passed events in a callback and expect a re-render — update your source of truth.
  • Don't hardcode event colors or positions — use tone/appearance and let the canvas place them.
  • Don't remove the composed event accessible name inside a custom renderEvent.
  • Don't use it as a date picker — it won't shrink to that role.

Troubleshooting

Dragging or resizing an event does nothing — it snaps back.

Cause. events is a controlled data prop; the callbacks are intents, not mutations. The component never changes your data for you.

Fix. In onEventMove / onEventResize, apply the returned range to your own state (setEvents(prev => prev.map(...))).

An event doesn't appear, or lands at the wrong height.

Cause. It falls outside the visible band (slotMinTime/slotMaxTime) so it's cropped, or it's an all-day event shown in the all-day lane rather than the time grid.

Fix. Widen the band, or set allDay: true for date-spanning events. Positioning is wall-clock based, so also check the event's timeZone interpretation.

A recurring event's drag/click hands back a synthetic id like "sync::1720000000000".

Cause. RRULE occurrences are generated per visible span with a composite id; that's the on-screen occurrence, not your base event.

Fix. Use the 3rd meta arg — { recurring, baseId, occurrenceStart } — to map back to the base event you own and decide "this" vs "all".

SSR hydration warning about the calendar showing a different week on the server.

Cause. With no date/defaultDate, the uncontrolled anchor defaults to "now", which differs between server and client render.

Fix. Pass date (controlled) or defaultDate for deterministic SSR output.

Custom renderEvent content shows in day/week but not in month or agenda.

Cause. Older assumption — renderEvent now runs in every placement and receives ctx.placement.

Fix. Branch on ctx.placement ("column" | "all-day" | "month" | "agenda") to render appropriately for each surface.

On a phone, trying to drag-create just scrolls the grid.

Cause. Touch uses a long-press to start a drag so a quick swipe can still scroll.

Fix. Press and hold (~350ms) on empty time to begin drag-create; a normal swipe scrolls as expected.

Accessibility

Role
group
ARIA APG
grid
Focus
Roving tabindex over events (time/agenda) and over day cells (month); focus is restored to the view region on nav; focus-visible rings preserved.

Accessibility requirements

warn

The root exposes an accessible name describing the visible range (default: "Schedule, {range}").

when always

warn

Every focusable event control has a composed accessible name including its title and time; a custom renderEvent must not remove it.

when an event is rendered

warn

The event is a single focusable control (its first covered day); other segments are aria-hidden and not tabbable.

when a multi-day event spans columns/weeks

warn

The month is role=grid with columnheaders and roving-focusable gridcell days carrying date + event labels.

when view="month"

Keyboard

TabEnter the events; one event is tabbable (roving tabindex). In month, cells are the tab stops.
ArrowUpArrowDownArrowLeftArrowRightTime/agenda views: traverse events in chronological reading order. Month: move by day cell (±1 / ±7).
HomeEndFirst / last event (or start/end of the month row).
EnterSpaceActivate the focused event (onEventClick); in month, fire onSlotClick for the focused day.
ShiftArrowUpMove the focused event earlier by one snap (when editable).
ShiftArrowDownMove the focused event later by one snap.
ShiftArrowLeftMove the focused event back one day (wall-clock preserved).
ShiftArrowRightMove the focused event forward one day.
AltArrowUpShorten the focused event by one snap.
AltArrowDownLengthen the focused event by one snap.
EscapeCancel an in-progress pointer drag (no commit).
A1.3.1Info and RelationshipsA2.1.1KeyboardA4.1.2Name, Role, ValueAA2.4.7Focus VisibleAA1.4.10Reflow
  • The root is role=group with an accessible name for the visible range (aria-roledescription="event scheduler") and an sr-only aria-describedby that states the keyboard model.
  • The decorative grid, hour axis and now-line are presentational (aria-hidden); the real semantics live on the events.
  • Each event is a focusable control with a composed accessible name ("{title}, {start} to {end}, {weekday date}"); a recurring occurrence appends "(repeats)", a clipped multi-day segment "(continues)".
  • One event is tabbable at a time (roving tabindex); a multi-day event is focusable only on its first covered day — later days are aria-hidden continuation segments (one control per event).
  • Selection is exposed via aria-pressed="true" on the selected event (not visual-only).
  • All-day events are a separate labelled group. The month view is role=grid with weekday columnheaders and gridcell days whose labels carry the date + event count/titles.
  • Navigation and view changes are announced in a polite live region; drag/resize results are announced too. Focus is restored to the view region after navigating.
  • Geometry uses logical properties, so columns and event boxes mirror under dir="rtl".