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.
week view. Use the arrow keys to move between events. Press Enter to open an event. Hold Shift and press arrow keys to move the focused event, or hold Alt and press up or down to resize it. Drag across empty time to create an event.
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.
week view. Use the arrow keys to move between events. Press Enter to open an event. Hold Shift and press arrow keys to move the focused event, or hold Alt and press up or down to resize it. Drag across empty time to create an event.
Day
A single day — dense overlaps show the interval-graph column packing.
day view. Use the arrow keys to move between events. Press Enter to open an event.
Month
The month day-grid — spanning bars for multi-day events, per-cell “+N more”, role=grid keyboard navigation.
month view. Use the arrow keys to move between events. Press Enter to open an event.
Agenda
The agenda list — events grouped by day, including a recurring (rrule) sync.
agenda view. Use the arrow keys to move between events. Press Enter to open an event.
Layout
Dense
Heavy overlaps with `maxStack` — extra columns collapse into a “+N” Popover.
day view. Use the arrow keys to move between events. Press Enter to open an event.
Appearance
Tones
The event tone × appearance range (solid / soft / outline).
day view. Use the arrow keys to move between events. Press Enter to open an event.
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- Source
- scheduler.tsx
- 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 } from "@garn/ui/scheduler";Props
Generated from the component's TypeScript types.
agendaDays- Description
- Days spanned by the agenda view. Default 14.
- Type
number
apiRef- Description
- An imperative handle for driving the scheduler from outside a child.
- Type
React.Ref<SchedulerApi>
date- Description
- No description.
- Type
Date
defaultDate- Description
- No description.
- Type
Date
defaultSelectedId- Description
- No description.
- Type
string
defaultView- Description
- No description.
- Type
SchedulerView
editable- Description
- No description.
- Type
boolean
eventsRequired- Description
- No description.
- Type
E[]
firstDay- Description
- No description.
- Type
number
hour12- Description
- No description.
- Type
boolean
locale- Description
- No description.
- Type
string
maxStack- Description
- No description.
- Type
number
monthMax- Description
- Max events shown in a month-view day cell before "+N more". Default 3.
- Type
number
nowIndicator- Description
- No description.
- Type
boolean
onDateChange- Description
- No description.
- Type
(date: Date) => void
onEventClick- Description
- No description.
- Type
(event: E, meta: SchedulerEditMeta) => void
onEventMove- Description
- No description.
- Type
(event: E, range: { start: Date; end: Date; }, meta: SchedulerEditMeta) => void
onEventResize- Description
- No description.
- 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
- No description.
- Type
(range: { start: Date; end: Date; allDay: boolean; }) => void
onSelectedChange- Description
- No description.
- Type
(id: string | null) => void
onSlotClick- Description
- No description.
- Type
(at: Date) => void
onViewChange- Description
- No description.
- Type
(view: SchedulerView) => void
ref- Description
- No description.
- Type
React.Ref<HTMLDivElement>
renderEvent- Description
- No description.
- Type
(event: E, ctx: SchedulerEventContext) => React.ReactNode
scrollTime- Description
- No description.
- Type
string
selectable- Description
- No description.
- Type
boolean
selectedId- Description
- No description.
- Type
string
slotDuration- Description
- No description.
- Type
number
slotMaxTime- Description
- No description.
- Type
string
slotMinTime- Description
- No description.
- Type
string
snapDuration- Description
- No description.
- Type
number
timeZone- Description
- IANA time zone for display + positioning. Default: the runtime's local zone.
- Type
string
view- Description
- No description.
- Type
SchedulerView
Plus 278 inherited native <div> attributes.
SchedulerEvent
asChild- Description
- No description.
- Type
boolean
onResizeStart- Description
- No description.
- Type
(edge: "start" | "end", e: React.PointerEvent) => void
ref- Description
- No description.
- Type
React.Ref<HTMLButtonElement>
resizable- Description
- No description.
- Type
boolean
Plus 291 inherited native HTML attributes.
Styling
Target these data-slots 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"]| 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
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.
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.
Enforceable contracts
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".
- Status
- Beta
- Version
v0.3.0- Introduced
v0.2.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.