Scheduler

The grid of hours with the appointments on it: a week of days or a day of people, the now line, each event a card in its kind and its person’s colour. The component the system’s origin asks for.

Try it

April 2023

Week
Day
Day, by person
WET
Monday, April 17
Tuesday, April 18
Wednesday, April 19
Thursday, April 20
Friday, April 21
Saturday, April 22
Sunday, April 23
All-day
                  A press or a drag makes a slot; the × removes it.

                  April 2023

                  MondayTuesdayWednesdayThursdayFridaySaturdaySunday
                  Event type

                  The now line is pinned to the drawn 11:16 on the 20th of April 2023 so the page holds still; left out, the component reads the clock. With scrollToDay a week wider than its region opens with today in the middle, a day picked in the same week is the day shown, and Today brings today back. Below md, 768, the week gives way to the day, as the phone drawing has it, and the grid has the width: the calendar and the filters open over it from a button, as a Drawer, and so do a new event and a pressed one. From md they sit under the grid, and beside it only where the week’s 976 and their 280 both fit, which this column never is.

                  Creating, moving and the keyboard

                  Press an empty slot for a thirty-minute draft, or drag for the span you want, snapping to the quarter hour; the dashed “(No title)” pulses while the New event panel is open, as drawn, and the panel closes it. Drag a card to move it, along its day or into another; select a card and drag the bar at its foot to change its end. With Availability on, the same press or drag makes a slot at once, the drawn teal, with a × to remove it, and the panel writes the slots as text for the clipboard: the Vimcal gesture. Every gesture has a key, and a key acts on the hot event, the one under the pointer if there is one, else the one with focus: Shift with Up and Down move it by a quarter, Shift with Left and Right to the next day; Alt+Shift with Up and Down change its end; ⌘C or Ctrl+C copies it, ⌘V pastes it after the hot event or at the cursor, ⌘D duplicates it, Delete removes it. With the grid itself focused, Enter places a cursor at the first hour in view; the arrows move it, Shift with Up and Down stretch it, Enter proposes it, Escape drops it. A move or a resize shows a Toast with Undo, which is the page’s one line: the callback hands back the event as it was. Nothing drags unless its callback is given, and on touch a tap proposes and nothing drags, so the grid keeps its scroll.

                  Drawn as the product’s calendar: the weekly view, the daily view with several people, the week with several people, the daily view on a phone, an agenda list for a phone, and a sheet of the appointment’s states. The package takes the grid; the toolbar, the mini-calendar, the filters and the panel of the selected event are the screen’s, and here they are the Button, the Select, the Calendar, the Checkbox and a Card. Press an event, or Tab to one and use the arrows: up and down within a day, left and right to the nearest event in the next day that has one, Home and End, Enter to select.

                  A day, by person

                  WET
                  Julia RobertsJulia Roberts
                  Elizabeth HallElizabeth Hall
                  Laura LeeLaura Lee
                  Anthony JacksonAnthony Jackson
                  Léa MartinLéa Martin
                  All-day

                            resources makes a column per person, headed by the Avatar and the name, and a tone on the resource paints every event in the column from the category palette: six hues, each the accent’s own four stops, measured. An event names its own tone when it needs one. Léa Martin starts at noon, so her workingHours band the morning. A week with several people is drawn and is not built; a week is a week of days (this said it was not drawn; corrected 2026-09-24, fidelity audit).

                            Kinds, and the past

                            Thursday, April 20
                            All-day

                              Six kinds on one tone: confirmed is the fill with its label on it; pending the tint with the fill as an edge; cancelled a card with the words struck; blocker the tint under dots of the fill; external a card with a bar of the fill at the start; availability the tint with a dashed edge, for the second phase to create. Past is derived from now: an event that has ended loses the fill for the tint, and nothing loses opacity, so every text on a past card still measures. By duration, as drawn: a quarter of an hour is one line, half an hour the time under it, an hour the icon in its circle. Overlaps take lanes side by side, the column divided.

                              Where it departs from the drawing, and why

                              The product’s blue is the theme’s accent and its people’s colours are the category palette, with the label and the text measured on each fill and tint, which the drawn pastel greens and yellows with white on them would not clear. The past is faded by tone rather than by opacity, for the same reason. The drawn hatch outside the working day is surface/sunken, a band rather than a pattern; the coral now line is border/danger, the theme’s ember; today’s 28 circle is the Calendar’s 32. The cascade the week with several people draws for overlapping events is not replicated, since at a third card the cascade hides the first one’s time; they take lanes. The on-appointment and people-waiting bars are product states with no token behind them, and are not built. The drawn twelve-hour and twenty-four-hour clocks are the locale’s, not an option.

                              Best practice, and the alternatives

                              A column is a list of buttons under a heading, one per event, which is what Google Calendar and Outlook give a screen reader, rather than a grid of ninety-six quarter-hours a day that a reader would walk cell by cell. Times are wall-clock strings with no zone, the time a receptionist reads off the wall, and the formatting is Intl’s for the locale; the Calendar’s rule that no Dateleaves the component holds here, and so does its lesson about a runtime west of UTC. The now line is a prop, so a page can pin it and a test can hold it. Dragging to create and to move came as the second phase, with the keyboard equivalents. A phone here is the day with one column, as the drawing has it; the agenda list drawn for a phone is not built, nor a month view (this said the agenda list was not drawn; corrected 2026-09-24, fidelity audit). The alternative to columns per person is a filter that shows one person’s week: both are the same component with a different view.

                              Accessibility

                              The region is named by label and focusable, so a keyboard can scroll it. Each column is a section named by its day, “Thursday, April 20”, or its person; each event a button named by its title, its time and its kind, “Justin Anderson, 11:00 AM – 1:00 PM, Confirmed”, and the selected one carries aria-current. One event holds the tab stop; the arrows, Home and End move it and the focus with it, so leaving and coming back lands where the reader was. The now line, the hour labels and the day numbers are decoration to a reader; the names carry the information.

                              Measures

                              Measures
                              PartValue
                              Hourdensity/hour (--scheduler-hour) — 80, 64 compact; for the drawn 81
                              Column floor128 (--scheduler-column), for the drawn 129; a share of the rest above it
                              Hours column80 wide, the labels caption/md in text/tertiary on their line
                              Header64 tall; weekday caption/sm uppercase, day body/md Medium, today in a 32 accent circle
                              All-day row28 (--scheduler-all-day), the chip filling it, for the drawn 28 and 29; its words caption/md Semibold, centred, 8 and the hairline in
                              Card4 8, radius 8, 6 in from the column; min 20 tall (--scheduler-event-min), a quarter of the 80 hour; drawn 24, which would lay each quarter-hour card over the next by 4 — a decision still open
                              Card texttitle 12 / 16 Semibold, time 11 / 14 Medium; the icon in a 24 circle from an hour up
                              Now linea border/danger hairline with a 12 dot at today’s column; its time in text/danger
                              Off hourssurface/sunken bands outside workingHours

                              Using it

                              TSX
                              import { Scheduler } from 'alpenglow';
                              import type { SchedulerEvent } from 'alpenglow';
                              
                              const [date, setDate] = useState('2023-04-20');
                              const [selected, setSelected] = useState<string | null>(null);
                              
                              <Scheduler
                                label="Appointments"
                                view="week"
                                date={date}
                                weekStartsOn={1}
                                hours={{ start: 7, end: 20 }}
                                workingHours={{ start: 9, end: 18 }}
                                zoneLabel="WET"
                                maxHeight={640}
                                events={events}
                                selectedId={selected}
                                onSelect={(event) => setSelected(event.id)}
                              />
                              
                              // A day, one column per person, each in a colour of the category palette.
                              <Scheduler
                                label="Appointments"
                                view="day"
                                date={date}
                                resources={[
                                  { id: 'jr', name: 'Julia Roberts', tone: 'moss', avatar: <Avatar name="Julia Roberts" size="xxs" /> },
                                  { id: 'eh', name: 'Elizabeth Hall', tone: 'ember' },
                                ]}
                                events={events}
                              />
                              
                              // The gestures: each arms itself with its callback. A press or a drag proposes a
                              // draft; the caller keeps it while its panel is open. A move hands back the event
                              // as it was, so Undo is one line.
                              <Scheduler
                                …
                                draft={draft}
                                onCreate={(proposed) => setDraft(proposed)}
                                onMove={(event, next) => { update(event.id, next); toast('Moved', { action: { label: 'Undo', onClick: () => update(event.id, event) } }); }}
                                onResize={(event, next) => update(event.id, next)}
                                onRemove={(event) => remove(event.id)}
                                createKind={availability ? 'availability' : 'confirmed'}
                              />
                              
                              // The drawn "Copy to clipboard": one line per day.
                              navigator.clipboard.writeText(formatSlots(events.filter((e) => e.kind === 'availability')));
                              
                              // An event: wall-clock times, no zone; a kind; a tone of its own if it needs one.
                              const event: SchedulerEvent = {
                                id: '42', title: 'Justin Anderson',
                                start: '2023-04-20T11:00', end: '2023-04-20T12:00',
                                resourceId: 'jr', kind: 'pending', icon: <VideoIcon />,
                              };
                              Scheduler props
                              PropTypeDefault
                              labelstringrequired
                              dateISODaterequired
                              eventsSchedulerEvent[] — { id, title, start, end, resourceId?, kind?, tone?, icon?, allDay? }required
                              view'week' | 'day''week'
                              weekStartsOn0–60
                              daysnumber (week view)7
                              resourcesSchedulerResource[] — { id, name, avatar?, tone?, workingHours? } (day view)—
                              selectedId / onSelectstring | null; (event) => void—
                              hours{ start, end } in hours{ 0, 24 }
                              workingHours{ start, end }—
                              nowISODateTime | nullthe clock, after hydration
                              localestring'en-US'
                              zoneLabel / allDayLabelReactNode / string— / 'All-day'
                              kindLabelsPartial<Record<SchedulerKind, string>>Confirmed, Pending approval, …
                              maxHeightnumber | string—
                              scrollTonumber (an hour)workingHours.start, or the first event
                              scrollToDayboolean | number — a week wider than the region opens with today, or date, in the middle; date when it changes; a new number againoff
                              renderEvent(event) => ReactNode—
                              onCreate(draft: { start, end, resourceId?, title?, from? }) => void— (arms press, drag, Enter, paste, duplicate)
                              onMove / onResize(event, next: { start, end, resourceId? }) => void— (arms the drag, the handle, Shift+arrows)
                              onRemove(event) => void— (the × on availability, Delete)
                              draft{ start, end, resourceId?, title? } | null—
                              createKindSchedulerKind'confirmed'
                              step / defaultDurationminutes15 / 30
                              draftLabel / removeLabelstring'(No title)' / 'Remove'
                              classNamestring—

                              not built A long press to drag on touch; a week of several people; a month view; an agenda list; a second zone’s hours, the Vimcal Time Travel.