Scheduler
A day/week/month/agenda event-grid — a time-canvas that positions, packs and edits events, with timezone/DST-correct math and recurrence.
@garn/ui/schedulerDefault
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.
npx garn-ui add scheduler- 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
| Selector | State | Description |
|---|---|---|
[data-slot=scheduler][data-view] | view | The active view is reflected on the root as `data-view` (day/week/work-week/month/agenda). |
[data-today] | today | The day heading / month cell for the current date. |
[data-slot=scheduler-event][data-selected=true] | selected | The selected event — a brand ring; also aria-pressed="true" (selection is never aria-current, which is reserved for the current item in a set of pages/steps/dates). |
[data-slot=scheduler-event][data-dragging=true] | dragging | An event being resized (in-place preview; raised + shadowed). |
[data-slot=scheduler-event][data-moving=true] | moving | An event being moved — the original dims while a ghost previews in the target column. |
[data-slot=scheduler-event][data-continuation] | continuation | A presentational, aria-hidden segment on the later days of a multi-day event (focusable only on the first day). |
| Property | Token | Tier |
|---|---|---|
hourHeight | --garn-scheduler-hour | component · identity |
axisWidth | --garn-scheduler-axis | component · identity |
nowLine | --garn-danger-solid | semantic |
surface | --garn-card | semantic |
hairline | --garn-border | semantic |
selectedRing | --garn-brand-solidbrandable | semantic |
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-rangepicker) - a read-only month wall-calendar with no time axis (a
calendarmay 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
- Keep
eventsin your own state and apply the move/resize/range callbacks optimistically. - Crop the band with
slotMinTime/slotMaxTimeand land the scroll withscrollTime(e.g. business hours). - Give overlapping-heavy days a
maxStackso dense clusters collapse into a “+N more”. - Pass a
timeZonewhen events are anchored to a fixed zone; passdate/defaultDatefor SSR. - Build a toolbar from
useScheduler(or drive it viaapiRef).
- 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/appearanceand 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
The root exposes an accessible name describing the visible range (default: "Schedule, {range}").
when always
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
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
The month is role=grid with columnheaders and roving-focusable gridcell days carrying date + event labels.
when view="month"
Keyboard
| Tab | Enter the events; one event is tabbable (roving tabindex). In month, cells are the tab stops. |
| ArrowUpArrowDownArrowLeftArrowRight | Time/agenda views: traverse events in chronological reading order. Month: move by day cell (±1 / ±7). |
| HomeEnd | First / last event (or start/end of the month row). |
| EnterSpace | Activate the focused event (onEventClick); in month, fire onSlotClick for the focused day. |
| ShiftArrowUp | Move the focused event earlier by one snap (when editable). |
| ShiftArrowDown | Move the focused event later by one snap. |
| ShiftArrowLeft | Move the focused event back one day (wall-clock preserved). |
| ShiftArrowRight | Move the focused event forward one day. |
| AltArrowUp | Shorten the focused event by one snap. |
| AltArrowDown | Lengthen the focused event by one snap. |
| Escape | Cancel an in-progress pointer drag (no commit). |
- 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".