Schedule
Mantine-Scheduler-style agenda component — Days/Month/Year/Agenda views over user-defined and static events, with a configurable Days window, click-drag-to-create, drag-to-move, edge-resize, a now-cursor, and configurable overlap prevention.
Schedule is a fully hand-built agenda component — Ark UI ships nothing scheduler-shaped, so
there's no headless machine underneath it. Calendar math goes through
@internationalized/date
(the same boundary Calendar/DatePicker use); drag and
resize are hand-rolled pointer gestures modeled on the notifier's own swipe-to-dismiss.
Seven real pieces under one compound namespace: Schedule (the root, which owns view-switching
and a shared toolbar), Schedule.DaysView (the timed grid — a visibleDays-sized window,
drag-to-move, edge-resize, a now-cursor, side-by-side concurrent events), Schedule.MonthView
(a read-only month grid), Schedule.YearView (a dot/count overview), Schedule.AgendaView
(a span of days as a list of events), Schedule.Toolbar (Prev/Next/Today/view-switcher, composable on
its own), and Schedule.NextUp (the same agenda list over an unbounded from-now window, with no
chrome of its own). Every view is independently mountable — nothing requires Schedule.Root.
import { Schedule } from "@42-central/ui-react/schedule";
<Schedule
data={events}
editable={["create", "drag", "resize", "remove"]}
onEventChange={(event, next, reason) => updateEvent(event.id, next)}
onEventRemove={(event) => removeEvent(event.id)}
onEventCreate={(ranges) => addEvents(ranges)}
/>;Code
<Schedule
data={events}
staticEvents={staticEvents}
startHour={7}
endHour={20}
slotDisabled={anyOf(disableOutsideHours(9, 17), disableWeekends())}
editable={['create', 'drag', 'resize', 'remove']}
onEventChange={(event, next, reason) => updateEvent(event.id, next)}
onEventRemove={(event) => removeEvent(event.id)}
onEventCreate={(ranges) => addEvents(ranges)}
onSelect={(event) => showDetails(event)}
/>Try switching views (top right), dragging an event, resizing from its top/bottom edge, removing
one with its ×, and press-dragging empty space to draft a new one — every gesture above is
live. The faintly tinted hours are outside 9am–5pm and weekends, blocked via
slotDisabled — the cursor also turns to not-allowed if you try to drop
something there. Drag a draft sideways into the next day and it turns red before you release:
the range would span the night between the two columns, which that same slotDisabled forbids
(see Disabled slots).
For a realistic screen putting Schedule and Schedule.NextUp together inside
AppShell, with a custom event-details/edit panel, jump to the
dashboard example, or view the full-page, unbounded version:
View full-page example
Value model
data: ScheduleEvent[] (required, draggable/resizable/removable) and
staticEvents?: ScheduleStaticEvent[] (default [], read-only but still clickable) are two
separate arrays, not one data array with a flag — the two kinds have categorically different
capability sets. Both are fully consumer-controlled: Schedule never mutates them, only reports
proposed changes through callbacks.
type ScheduleEventBase<P = unknown> = {
id: string; // stable identity — never derived from array index
start: Date; // wall-clock instant, native Date
end: Date; // exclusive — an event ending when another starts doesn't overlap it
title: string;
description?: ReactNode; // secondary content row in the details popover, same role as Field's helper text
color?: ScheduleEventColor; // "red" | "orange" | "yellow" | "green" | "teal" | "cyan" | "blue" | "purple" | "pink"
icon?: ReactNode; // icon={null} explicitly suppresses it; undefined = none set
payload?: P; // arbitrary typed data of your own, surfaced to renderEventDetails/renderOverflowDetails
allDay?: boolean; // Days view only — see All-day events below
};
type ScheduleEvent<P = unknown> = ScheduleEventBase<P> & {
kind: "event";
// Narrows the schedule-level `editable` grant further for this one event —
// never wider than it. undefined inherits the schedule-level grant as-is.
editable?: boolean | ("drag" | "resize" | "remove")[];
};
type ScheduleStaticEvent<P = unknown> = ScheduleEventBase<P> & {
kind: "static";
}; // read-only, still clickablekind is required and literal-pinned on every event — a compile-time guard against passing a
static event into data, or vice versa. Whether the remove button (or any other affordance)
renders at all is decided by editable (see Edit permissions) — onEventRemove
just reports when it's actually clicked. onEventChange fires with (event, { start, end }, reason)
where reason is "drag" or "resize". P is inferred from data, so event.payload in every
callback and render prop below comes back precisely typed — see
Event & day details.
Event & day details
Clicking an event selects it and opens a built-in details popover — title, time range,
description, icon and color, no wiring required. description is ReactNode, not just plain text,
so it can carry rich content (a link, a short list) the same way icon already does.
The popover and the selection are one piece of state, not two: it opens for whichever event is
selected. Set withEventDetails={false} to keep the selection and render the detail yourself —
a side panel, a drawer — or use renderEventDetails to keep the popover and
replace only what is inside it.
Month view has a second popover, listing that day's full, uncapped agenda with the same
list→single-event swap — and it hangs off the +N chip, the affordance that announces there is
something hidden in the first place. It is therefore only present on a day that actually overflows
maxEventsPerDay; on any other day the visible pills already show everything the list would. A row
inside it follows the identical rule as a pill — it selects that event, and withEventDetails
decides whether the built-in detail is what you get, no matter where the click originated.
onDayClick no longer gates that popover. The two used to be mutually exclusive, so wiring a day
click (to navigate somewhere of your own, say) silently removed the only route to the hidden
events — the exact dead end the popover exists to close. A day click and the +N disclosure are
now independent gestures.
renderEventDetails/renderOverflowDetails override the popovers' content without giving up the
built-in shell — handy for reaching into a typed payload the default content has no field for.
renderOverflowDetails still receives an onSelectEvent callback so custom row markup can opt
back into the built-in list→detail swap, and it works on every "+N" — the timed grid's and the
all-day row's as well as Month's.
Code
<Schedule.DaysView
data={events} // description on "Defense: Born2beroot" and "Evaluation: libft"
/>Both events above use the default popover — no renderEventDetails needed for rich content,
since description already accepts anything renderable: a link, a checklist, whatever the event
calls for.
Code
<Schedule.MonthView
data={events} // ScheduleEvent<{ room: string }>[]
staticEvents={staticEvents}
renderEventDetails={(event) => (
<div>
<p>{event.title}</p>
<p>Room: {event.payload?.room}</p>
</div>
)}
renderOverflowDetails={(hidden, { onSelectEvent }) => (
<ul>
{hidden.map((event) => (
<li key={event.id}>
<button onClick={() => onSelectEvent(event)}>{event.title}</button>
</li>
))}
</ul>
)}
/>Click a day, then an event inside it, to see the list→detail swap; click an event directly for the
same popover anchored to its own chip. Both come from the same selected id, so they can never
disagree about which event is current — and both stay off entirely under
withEventDetails={false}.
Where the popover opens
Every popover renders inside the view's own scroll area, not over the page: it scrolls with the grid and is clipped by it. In the Days view an event's popover opens beside the chip, not above or below it, since an event can be as tall as the grid itself. It then slides along the chip to stay in view while you scroll, flips to the other side when this one is full, and overlaps the chip when neither side fits (a single, full-width day). It never tucks under the sticky day headers or the hour gutter.
Code
<Schedule.DaysView
data={events} // "Hackathon: 24h" and "Exam day" run 00:00 → 24:00
visibleDays={4}
align="relative"
/>Configuring the views
views decides which entries the switcher offers, and in what order. "day" and "week" are
shorthands for the days grid sized to 1 and 7; "days" is the same grid at whatever visibleDays
you set; "month", "year" and "agenda" are themselves. Anything else is an ordinary entry
with its own id, window and label.
The default entries are still ["days", "month", "year"], so a <Schedule /> with no views
shows exactly the three tabs it always has — the agenda is opt-in.
Code
const VIEWS = [
'day',
'week',
{ id: 'fortnight', visibleDays: 14, label: '2 weeks' },
'month',
'year',
] as const
<Schedule data={events} views={VIEWS} defaultView="week" />Two things are worth knowing.
An entry's id is its identity; its view is its renderer. They coincide for plain
"days"/"month"/"year", which is why one name sufficed before — but they must not be conflated
once one schedule offers two differently-sized day windows. view/defaultView/onViewChange
carry the id, so under the entries above onViewChange reports "day", never "days".
Schedule is generic over the array, so those three props are typed to your exact ids and a
typo is a compile error. An array written inline narrows on its own — as const is only
needed when you hoist it into a variable first:
<Schedule views={["day", "week", "month"]} defaultView="week" /> // narrows
const VIEWS = ["day", "week", "month"] as const // narrows
const VIEWS: ScheduleViewOption[] = ["day", "week", "month"] // does NOT narrowThat last line is worth knowing rather than avoiding: annotating the array as
ScheduleViewOption[] is what you do when the entries are genuinely decided at runtime, and it
deliberately widens everything back open (see below).
Props follow the views
The array does not only decide which tabs appear — it decides which props exist. A prop only one renderer reads is not accepted by a schedule that never renders it:
<Schedule
views={["month", "year"]}
data={events}
slotDuration={30}
//^^^^^^^^^^^^ Type 'number' is not assignable to type
// 'Unavailable<"requires a days renderer in `views`">'
dayDisabled={disableWeekends()} // fine: Month and Year both read it
/>Before this, that slotDuration was accepted and silently dropped. The error names the renderer
it wanted, and the same applies to maxEventsPerDay (Month), onMonthClick
(Year), renderEvent (Agenda), and the whole editable family (Days). Even a value is checked:
align="relative" is a rolling day window, so it is rejected where no days view can use it.
Two consequences worth stating plainly:
- Omitting
viewschanges nothing. The default is["days", "month", "year"], which configures three of the four renderers, so every prop those read stays available. OnlyrenderEventis out of reach, because the default list has no agenda entry. - A
viewsarray built at runtime keeps every prop. There is nothing for the type to read, so it widens to every renderer rather than rejecting code that works. You get a dev-mode console warning naming the ignored props instead — the same two-layer schemeeditableuses.
The switcher does not render at all below two entries: a one-tab tablist is dead chrome. And
defaultView falls back to the first entry rather than a hard-coded "days", so
views={["month", "year"]} starts on Month instead of rendering a view with no tab selected.
Selection & navigating between views
Clicking an event selects it. selected/defaultSelected/onSelect is an ordinary
controlled/uncontrolled triple, and one id drives everything that follows from "this is the current
event" — the chip's styling, which details popover is open, which row of a +N list is expanded.
Navigation is deliberately not a Schedule prop. What a click means is yours to decide — open a
panel, route to a URL, go somewhere, nothing at all — and view/date/selected are already full
controlled pairs, so everything you need is there. useSchedule is sugar over exactly those props,
never a second authority:
Viewing month
Code
const schedule = useSchedule({ views: VIEWS, defaultView: 'month' })
<Schedule
{...schedule.props}
data={events}
onSelect={schedule.goTo('day')} // an event chip
onDayClick={schedule.goTo('day')} // a day cell, or a Days day-header
onMonthClick={schedule.goTo('month')} // a Year month header
/>One goTo covers all four click sources because its target is structural — a Date from a day or
month click, an event from onSelect, or null to deselect. Handed an event it selects it as well
as navigating, which is what makes it safe to pass as onSelect.
Selecting an event reveals it, but only when it isn't already on screen: if any part of its
range overlaps the visible window the anchor date stays put, and in Days view the grid scrolls to
centre the chip only when the chip is outside the scroll viewport. Testing the whole range rather
than just start is what makes a long multi-day event behave — it is visible on every day of the
window, so anchoring to its start would page away from what you asked to see. Focus is never moved
by a selected change; a chip click focuses on its own.
Navigating to a day reveals it the same way. Day columns have a 9rem floor, so a week is
already wider than most containers and a fortnight is far wider — the day you drilled into can
easily be scrolled past. Moving the anchor to a day scrolls its column into view, again only when
it is outside the scroll viewport. This is the inline axis only: whatever time of day you were
looking at stays exactly where it was, and focus is not moved here either.
You don't wire any of it. The anchor date already is the day that was clicked, so the built-in
drill-down and your own onDayClick — useSchedule().goTo('days') included — both get it. Paging
is excluded, since Prev/Next, the Now button and a drag held at the grid's edge are all going
somewhere on purpose rather than asking to see a particular day.
Neither reveal animates. A reveal is a side effect of going somewhere, not a gesture of its own,
and animating it reads as the grid drifting rather than as feedback — so revealBehavior is
"auto" by default and the jump is instant. Set it to "smooth" if you want the motion:
<Schedule data={events} revealBehavior="smooth" />"auto" defers to the platform, so a CSS scroll-behavior: smooth on your own container is the
way to turn this on globally; "instant" refuses to animate whatever the CSS says. "smooth"
still yields to reduced motion
— the prop can ask for motion, it can't override someone asking for none. The Now button is a
different case and keeps its animation: it answers a press, rather than following you somewhere.
Don't want any of it? withSelection={false} turns the whole concept off, and
withEventDetails={false} keeps the selection but leaves the detail rendering to you.
The selected chip carries data-selected, alongside the data-today/data-disabled stamps
already documented in testing. One gotcha if you query it: Ark's Tabs marks the
active view-switcher tab data-selected too, so a bare [data-selected] inside a schedule
matches two very different things — scope it, e.g.
[data-schedule-part="event-chip"][data-selected].
Several chips, one selection
Some things are drawn as more than one chip: an offer split around the booking that cuts into it,
or an event interrupted by a break. selected also takes a list of ids for these. Every listed chip
is painted selected, and the first id is the anchor. It is the only chip whose details popover
opens, and the one a selection change reveals.
onSelect still reports the single chip that was pressed. Map it to the group yourself, and put the
pressed id first so the popover opens where the click was. Press either green piece below:
Code
const [pressed, setPressed] = useState<ScheduleAnyEvent<Offer> | null>(null)
// The pressed piece first, so the popover opens where the click was.
const piecesOf = (event) => [
event.id,
...events
.filter((e) => e.id !== event.id && e.payload?.offer != null)
.filter((e) => e.payload?.offer === event.payload?.offer)
.map((e) => e.id),
]
<Schedule.DaysView
data={events}
selected={pressed ? piecesOf(pressed) : null}
onSelect={setPressed}
/>Derive the list on every render rather than storing it, so it stays right when the pieces are
re-cut. Passing a fresh array each time is fine, because the reveal keys on the anchor id and not
on the list. Uncontrolled selection (defaultSelected and a plain click) still selects one chip,
since only you know which chips belong together.
Static events, color & icon
A static event (school schedule, an existing commitment) renders identically to a regular one — and, critically, has no grip strips and no remove button, because the drag/resize hooks are never invoked for it at all. Color and icon stay fully overridable per event regardless of kind, so a static "class" event can still carry its own icon and tint.
Days view window
Schedule.DaysView renders visibleDays day-columns (default 7) sharing one hour axis —
visibleDays={1} is a single-day view, visibleDays={7} (the default) is a full week, and
anything else is a real, first-class window size, not just a workaround.
<Schedule.DaysView data={events} visibleDays={3} />align decides where that window starts — named align, not startOfWeek, since DaysView's
window isn't necessarily a week (visibleDays can be anything):
- A day-of-week value, or omitted (locale default) — the window snaps to that calendar
week's start, the same way the old week view always did. A
visibleDaysshorter than 7 then slides forward in whole windows until it contains the anchor, so the window always shows the day it is anchored on: withvisibleDays={5}and a Monday start, a Saturday anchor gives Sat–Wed rather than the Mon–Fri that would leave the anchor off screen entirely. AvisibleDaysof 7 or more never needs to slide, since the anchor is always inside the first window. The one exception isvisibleDays={1}, which always shows the anchor date itself regardless ofalign— a single day has no "start of week" to snap to, so this always matches the old standaloneDayView. "relative"— the window's first visible day is always the anchor date itself, with no calendar-week snap at all: a genuinely rolling window.
<Schedule.DaysView data={events} visibleDays={5} align="relative" />Code
<Schedule.DaysView data={events} staticEvents={staticEvents} visibleDays={5} align="relative" />Page with Prev/Next below and notice the window keeps rolling forward/back by exactly 5 days rather than re-snapping to a calendar week — press Now to bring today back into view.
Prev/Next always page the anchor by exactly visibleDays, in either mode — pagination never
re-anchors to "today." In "relative" mode specifically, that means once you page away with
Prev/Next, the window stops containing today until the Now button brings it back — a deliberate
property of a rolling window, not a bug.
With a calendar-week align and a visibleDays that doesn't divide 7, the window can't step as
evenly as the anchor does: a week holds one and a bit windows, so at visibleDays={5} successive
Next clicks move it by 5, then 2, then 7 days, and a full week's Saturday and Sunday are only
reachable by clicking into them from Month or Year. Use align="relative" for a window that
always steps by exactly its own length.
align is scoped per component: Schedule/DaysView accept
"relative", but Schedule.MonthView's own startOfWeek stays day-of-week-only — a month grid
genuinely is composed of calendar weeks, so it keeps the accurate startOfWeek name, and
"relative" there is a compile-time type error, not just an undocumented restriction.
Row density — hourHeight and slotDuration
slotDuration is 15 (the default), 30 or 60 — any other value is a compile error.
hourHeight (px per hour) defaults to defaultHourHeight(slotDuration) — a finer slotDuration
gets a taller default hour, so each slot's row stays a roughly-legible height regardless of how
many slots make up an hour, clamped to a sane range at either extreme. The default slotDuration
of 15 resolves to 48px, unchanged from before this scaling existed. Pass hourHeight explicitly
to override the derived default outright, regardless of slotDuration.
<Schedule.DaysView data={events} slotDuration={60} />
<Schedule.DaysView data={events} hourHeight={90} />slotDuration={60} — default hourHeight 28px (floor-clamped)
slotDuration={15} — default hourHeight 48px (the old flat default)
slotDuration={30} — default hourHeight 28px (floor-clamped)
hourHeight={90} — explicit override wins regardless of slotDuration
Code
<Schedule.DaysView data={events} slotDuration={60} />An event chip never renders shorter than one line of its own content, so at the default
48px hour a sub-22-minute event is drawn slightly taller than its true duration and
crosses into the following slot — the same compromise every calendar app makes once a slot
is too short to hold text. Only the bottom edge inflates: the chip's top edge stays exact
against the gutter, and drag/resize/create still commit to exact minutes, since those
measure the day column rather than the chip. Raise hourHeight if you want short events
to render at their true span.
The time-range subtext appears only when the chip is genuinely tall enough for a second line, measured from its real rendered height rather than its duration — so it also shows up correctly in a stretched, fill-height column, where a purely duration-based guess used to withhold it. The full range is always in the chip's accessible name and its details popover either way.
DayView / WeekView
Schedule.DayView and Schedule.WeekView are thin, preconfigured DaysViews for the two most
common window sizes — not separate implementations, just visibleDays fixed to 1 and 7
respectively:
<Schedule.DayView data={events} />
<Schedule.WeekView data={events} align="mon" />DayView drops both visibleDays and align from its props — a single day always anchors
on the date itself (see above), so align has nothing to do there. WeekView drops only
visibleDays and keeps align, since a week genuinely has a start-of-week to configure.
Reach for Schedule.DaysView directly instead when you need a window size other than 1 or 7.
Disabled slots
Two mechanisms, deliberately not merged into one "unavailable" state: a slot with an event on it needs no predicate at all (the chip explains itself), while a programmatic restriction goes through a range-shaped predicate:
import {
anyOf,
disableOutsideHours,
disablePast,
disableWeekends,
} from "@42-central/ui-react/schedule";
<Schedule
data={events}
slotDisabled={anyOf(
disablePast(),
disableOutsideHours(9, 17),
disableWeekends(),
)}
/>;type ScheduleSlotPredicate = (
range: { start: Date; end: Date },
timeZone?: string,
) => boolean;
declare function disablePast(now?: Date): ScheduleSlotPredicate;
declare function disableOutsideHours(
startHour: number,
endHour: number,
): ScheduleSlotPredicate;
declare function disableWeekends(): ScheduleSlotPredicate;
declare function disableDateRanges(
ranges: { start: Date; end: Date }[],
): ScheduleSlotPredicate;
declare function anyOf(...ps: ScheduleSlotPredicate[]): ScheduleSlotPredicate;
declare function allOf(...ps: ScheduleSlotPredicate[]): ScheduleSlotPredicate;disablePast() reads the live clock on every call rather than once when the predicate is built, so
a slot that was still in the future when the component mounted correctly becomes disabled once its
start time actually elapses — pass a fixed now to pin it for tests/Storybook.
The factories stay zero-arg (or value-only) so they read naturally at the call site; the zone every
calendar reading is interpreted in arrives as the predicate's second argument, which Schedule
fills in with the very timeZone it was given. A predicate therefore can't disagree with the grid
restricting by it — <Schedule timeZone="UTC" slotDisabled={disableOutsideHours(9, 17)} /> disables
9am–5pm UTC whatever the host zone is, instead of whichever hours happened to be 9–17 where the
code ran. disablePast and disableDateRanges ignore the argument on purpose: both compare
instants, never calendar fields, so they already give the same answer in every zone.
A multi-day range is judged one calendar day at a time. Click-drag-to-create and cross-day drag both produce ranges that span more than one day, so a range is disabled when any day it touches is disabled, each day measured against that day's own portion of the range. The end stays exclusive, so a range ending exactly at midnight does not touch the following day. Two consequences worth knowing:
- A cross-day draft can be refused for time you cannot see. With
startHour={9} endHour={17}, dragging from Thursday into Friday's column spans the unpainted 5pm–9am night between them, anddisableOutsideHours(9, 17)rejects it — correctly, since committing it would put an event through hours you declared off-limits. - Overnight events are unaffected:
disableOutsideHours(0, 24)disables nothing, and a range from 22:00 to 02:00 is allowed. A wrapping window (a 8pm–6am night shift) still isn't expressible here — reach fordisableDateRanges, or write the predicate by hand.
Enforcement, not just decoration: a drag/resize commit whose proposed range intersects a disabled
slot is rejected — the chip snaps back instead of firing onEventChange. A disabled time slot in
Days view carries a flat, low-contrast background tint plus cursor-not-allowed on hover — the
tint alone (not just the cursor) matters on touch/mobile, where nothing ever hovers. That tint is
probed one slotDuration step at a time through the same predicate, in the same timeZone, so the
shading and the commit-time rejection can never disagree about which time is off-limits.
Nothing is bookable before 08:45. Press-drag in the tinted band and the draft turns red and refuses; start at 08:45, where the tint stops, and it commits.
Code
const OPENS_AT = 8 * 60 + 45;
// The shipped factories are a convenience, not the contract — a predicate is
// just a function, and `disableOutsideHours` only takes whole hours anyway.
const beforeOpening = ({ start }, timeZone) => {
const [h, m] = new Intl.DateTimeFormat('en-GB', {
timeZone, hour: '2-digit', minute: '2-digit', hour12: false,
}).format(start).split(':');
return Number(h) * 60 + Number(m) < OPENS_AT;
};
<Schedule.DaysView
data={events}
visibleDays={3}
startHour={7}
endHour={13}
slotDuration={15}
slotDisabled={beforeOpening}
withNowIndicator={false}
editable={['create', 'drag']}
onEventCreate={(ranges) => addEvents(ranges)}
/>The boundary above is deliberately 08:45, not 09:00, because that is where tint and enforcement
have to agree. The overlay probes one slotDuration step at a time, so the tint stops at 08:45 with
the first bookable slot flush against it — a draft starting at 08:30 is refused and one starting at
08:45 commits, and the picture says exactly that. slotDuration is the right unit for this and not
just a finer one: it is what every gesture snaps to, so a boundary the shading could still round
away is a boundary no drag could land on anyway.
Schedule.MonthView / Schedule.YearView take a day-granularity dayDisabled?: DatePredicate
instead — literally the same type
Calendar/DatePicker use (disablePast(),
disableWeekends(), … from @42-central/ui-react/date-picker), and the view fills in its own timeZone as
the second argument exactly as they do. Same names, different module and shape: that one takes a
whole day, @42-central/ui-react/schedule's own is range-shaped for a specific time slot. Granularity is
the distinction, not the argument list — a month cell has no time axis to restrict within.
Click-drag-to-create & overlap prevention
Press-drag on empty Days-view grid space to draft a new event; releasing hands the new ranges to
onEventCreate — always an array, so one state update adds everything a gesture made — once editable includes "create" (see Edit permissions
below). Schedule never invents an id/title — you build the real event and add it to data
yourself, the same fully-consumer-controlled contract as onEventChange/onEventRemove. A plain
click with no discernible drag clears the selection instead — onSelect(null) — and separately
fires onGridClick with the instant you pressed on, snapped to the grid. Both fire, and they are
not synonyms: onGridClick reports the gesture whether or not anything was selected. Neither
depends on editable's "create" grant; it can never fire alongside a committed
onEventCreate, since they're mutually exclusive outcomes of the same click-vs-drag check. A drag
shorter than minDuration — one slotDuration unless you raise
it — grows to it. On touch, a short (~450ms) stationary hold
arms the gesture — a quick swipe still scrolls the grid natively.
<Schedule.DaysView data={events} selected={selectedId} onSelect={(event) => setSelectedId(event?.id ?? null)} /><Schedule.DaysView
data={events}
editable={["create"]}
onEventCreate={(ranges) => addEvents(ranges)}
/>onEventCreate={(ranges) =>
setEvents((events) => [...events, ...ranges.map((range) => ({ id: uid(), title: "New", ...range }))])
}Drag sideways to span days. The range runs between the two instants you pressed on and released on, so pressing Sunday at 22:00 and releasing on Monday at 02:00 drafts one four-hour event, not two. Dragging down past the bottom of a column does the same thing — it carries the draft into the next day rather than stopping at the edge.
A draft that spans days is validated one calendar day at a time, including the hours between two
columns that startHour/endHour never paint — so a slotDisabled rejection
can come from time you can't see on screen.
Either way the draft draws the way a real event does: one piece per day, each clipped to that day's own hours, squared off on the edge where it continues. A piece that runs past the last day in view is squared off too — the event is still perfectly valid, you just can't see all of it yet.
Because the day it ends on may not be on screen at all, the draft carries a live readout of the
range it would commit. It picks up the weekday as soon as the range crosses a date boundary:
10:00 – 11:00 stays as it is, while a crossing one reads Tue 10:00 PM – Wed 02:00 AM.
Right-drag to repeat across days. With the right mouse button, the same gesture drafts one
event per day instead, each over the same time of day: pressing Monday at 08:00 and releasing
Wednesday at 16:00 creates three 08:00–16:00 events. Days and times normalise independently, so
dragging backwards from Wednesday 16:00 to Monday 08:00 is the same gesture, and each event stays
within its own day's hours rather than rolling into the next. A day that would be rejected (a
slotDisabled slot, or an overlap under preventOverlap) is tinted red and
skipped — right-drag Monday → Sunday with the weekend disabled and you get five events; only a
gesture where every day is rejected fails. The browser's context menu is suppressed for the
gesture, and left alone entirely when "create" isn't granted. Touch has no secondary button, so
it always spans.
Minimum and maximum duration
minDuration and maxDuration bound what a create or resize can produce. Both are minutes,
and both must be a whole number of slotDurations — that is checked when you compile, against the
slotDuration written beside them:
<Schedule slotDuration={30} minDuration={60} maxDuration={240} /> // fine
<Schedule slotDuration={30} minDuration={45} />
// ^^^^^^^^^^^ Type '45' is not assignable to type 'SlotMultiple<30>'minDuration defaults to one slot. A create drag shorter than it grows to it from the point you
pressed on, and a resize grip stops that far from the event's other edge. maxDuration is the
mirror image: the draft stops growing at the cap — in a right-drag, each day's event is capped —
and a resize grip stops at it too. An existing event already longer than the cap can still be
resized shorter, never longer; moving an event never changes its length, so drag ignores both.
Pass the minimum here rather than enforcing it in onEventCreate. Stretching a range after the
fact works, but the draft still shows the shorter one while you drag, so what you see is not what
gets created.
The accepted values run up to 24 hours in slot steps, then whole days up to a week. Values read at
runtime — from an API, a settings form — are plain numbers, which the type does not accept.
isScheduleSlotProps checks the three together, against the slotDuration given with them, and
narrows them:
const slots = { slotDuration: rules.granularity, minDuration: rules.minimum }
<Schedule data={events} {...(isScheduleSlotProps(slots) ? slots : {})} />If an off-grid value reaches the schedule anyway — through a cast, or from plain JavaScript — it is
rounded onto the grid with a console warning in development: minDuration up, maxDuration down
and never below minDuration.
<Schedule.DaysView
data={events}
editable={["create", "resize"]}
slotDuration={30}
minDuration={60}
maxDuration={120}
onEventCreate={(ranges) => addEvents(ranges)}
onEventChange={(event, next) => updateEvent(event.id, next)}
/>While the drag is in progress, the draft box turns red the instant its current position would be
rejected on release (a disabled slot, or an overlap once preventOverlap is on) — not just after
you let go.
preventOverlap keeps a created, dragged, or resized event from landing on another one. A plain
boolean covers the common case — true enforces it for all three gestures, against both regular
and static events; false/undefined (the default) leaves existing side-by-side concurrent-event
layout (layoutEventColumns) completely unaffected. Pass an object instead to opt
in to just a subset of gestures (gestures, e.g. ["create"]) and/or scope which event kind counts
as a blocker (against, "event" | "static" | "both", default "both"):
preventOverlap={{ gestures: ["create"], against: "static" }}Code
<Schedule.DaysView
data={events}
editable={['create', 'drag', 'resize', 'remove']}
preventOverlap
maxDuration={capped ? 120 : undefined}
onEventCreate={(ranges) => addEvents(ranges)}
onEventChange={(event, next) => updateEvent(event.id, next)}
onEventRemove={(event) => removeEvent(event.id)}
/>Right-drag across a few days to create one event on each; turn "Cap at 2 hours" on and any draft stops at two hours. Try dragging in the empty space above with "Prevent overlap" on — the draft turns red as soon as it would land on an existing event, then snaps back with the same rejection pulse a disabled slot gets on release, since both are checked at the same commit-time gate. Turn it off and the same drag lands and lays out side by side with whatever it overlapped.
Only block against static events, leaving room to double-book real events with each other:
<Schedule.DaysView
data={events}
staticEvents={staticEvents}
editable={["create", "drag", "resize"]}
preventOverlap={{ against: "static" }}
onEventCreate={(ranges) => addEvents(ranges)}
onEventChange={(event, next) => updateEvent(event.id, next)}
/>Code
<Schedule.DaysView
data={events}
staticEvents={staticEvents}
editable={['create', 'drag', 'resize']}
preventOverlap={{ against: 'static' }}
onEventCreate={(ranges) => addEvents(ranges)}
onEventChange={(event, next) => updateEvent(event.id, next)}
/>Try dragging or creating over "Exam — Rank 02" (a static event) — blocked, same rejection pulse as
before. Now try the same over "Evaluation: libft" (a regular event) — allowed, since against
scopes the check to statics only.
Edit permissions
Nothing is editable by default. editable grants which gestures are possible at all —
"create", "drag", "resize", "remove" — either true for all four or an array for a subset.
It's the sole authority on whether an affordance renders; passing onEventChange/onEventRemove/
onEventCreate only decides what happens once a granted gesture actually commits, it no longer
enables the gesture by itself.
// Movable and resizable, but not removable or drag-to-create.
<Schedule.DaysView
data={events}
editable={["drag", "resize"]}
onEventChange={(event, next) => updateEvent(event.id, next)}
/>An individual event can narrow this further with its own editable field — never wider than the
schedule-level grant, only tighter, the same direction every other per-item disabled in the kit
composes in:
const events = [
{
id: "1",
kind: "event",
title: "Confirmed booking",
editable: false /* … */,
},
{ id: "2", kind: "event", title: "Draft slot" /* … */ }, // inherits the schedule-level grant
];Code
<Schedule.DaysView
data={events}
editable={['drag', 'resize', 'remove']}
onEventChange={(event, next) => updateEvent(event.id, next)}
onEventRemove={(event) => removeEvent(event.id)}
/>"Defense: Born2beroot" above always carries its own editable: false, so it stays locked — solid
fill, no grips, no remove button — no matter which schedule-level switches are on; it's still your
own event, not the read-only kind:"static" look, just not editable right now. In development, a
console warning fires once if a granted gesture's matching callback is missing (the affordance
would render but do nothing), or if an event's editable tries to grant something the
schedule-level editable doesn't already allow.
Now-cursor
A themeable line marks the current instant, drawn only across today's column, with a small clock
label (e.g. "09:30") next to its dot — a fainter, label-less guide line also spans the other
visible days so the hour it lines up with stays easy to trace even when today isn't one of them.
It ticks once a minute (never per-frame), and passing now freezes it — useful for tests/
Storybook, or for pinning a demo to a fixed instant. withNowIndicator={false} turns it off
entirely. The line's position and its label read the schedule's own timeZone, the same clock
the hour axis and the chips use (see Time zones).
The cursor is scoped to the window the grid actually paints: it only renders while the current
instant falls inside [startHour, endHour) — endHour exclusive, the same convention the rest of
the component uses. Outside it, the line, its clock label and the gutter's own decluttering all
drop together, rather than the line pinning itself to an edge and asserting "now is here" next to
an hour it isn't.
Dropping it silently would leave the grid unable to say why there is no line, though — a window
that simply has no cursor and one whose cursor is just off the top look identical. So the gutter
keeps a small chevron at the edge now lies past, pointing that way: up above startHour, down
below endHour. It carries no text, so there is nothing to translate and nothing for a screen
reader to announce or a font fallback to substitute.
now = 05:20 — before startHour, it points up
now = 09:30 — inside the window, it draws
now = 18:50 — past endHour, it points down
Code
// One window, three pinned instants — only the middle one is inside it.
<DaysView data={events} visibleDays={1} startHour={7} endHour={13} now={at(5, 20)} />
<DaysView data={events} visibleDays={1} startHour={7} endHour={13} now={at(9, 30)} />
<DaysView data={events} visibleDays={1} startHour={7} endHour={13} now={at(18, 50)} />Concurrent events & overflow
Overlapping events in Days view lay out side by side via layoutEventColumns — an interval-graph
greedy column-packer that also widens an event past any column it doesn't genuinely conflict with,
so two events that only briefly overlap don't both stay squeezed to a flat fraction for their whole
span. maxOverlapColumns (default 4) caps the total rendered column count for an overlapping
cluster — maxOverlapColumns={3} always means at most 3 columns, never 3 real columns plus a 4th
for "+N". Once a cluster's real concurrency would exceed the cap, it's laid out with one fewer real
column instead, reserving the freed-up one for a shared "+N" chip spanning that cluster's hidden
events. That one chip covers the whole cluster in a single click-through rather than fragmenting
into several time-sliced indicators, which would otherwise crowd a run of short, staggered events
into illegibly small, overlapping pieces. Clicking "+N" opens the same kind of popover Month
view's own day click does — a compact row per hidden event, click one to see its full details
(time, icon, color, description) with a "Back" button to return.
onOverflowClick reports the activation — the hidden events, plus a ctx naming the day and
which surface fired ("timed", "allDay" or "month"). It suppresses nothing: pass
withOverflowDetails={false} to take the disclosure over yourself, or renderOverflowDetails to
keep the popover and replace only what is inside it. Those used to be one prop — passing
onOverflowClick silently removed the built-in popover — which is the same two-jobs-in-one shape
onEventClick had before selected/withEventDetails replaced it.
Code
<Schedule.DaysView data={events} visibleDays={1} maxOverlapColumns={3} />Overlapping columns get a thin gap, not a divider, via --sch-column-gap (default 3px) — it's set
inline on the grid root, so overriding it on an ancestor needs !important:
[data-schedule-part="timed-grid"] { --sch-column-gap: 6px !important; }.
Columns also cascade — like most other calendar apps, a later, more-overlapping event is drawn as a
card slightly overlapping the one(s) behind it (with its own shadow and stacking order), rather than
each getting a flat, fully-separate sliver of space. Control how far each step cascades with
--sch-column-overlap (same override pattern as --sch-column-gap above); set it to 0px to go
back to a flat, non-overlapping side-by-side layout. visibleDays={1} (a single full-width
day-column, room to spare already) defaults to 10px; any wider window (visibleDays > 1)
defaults to 20px — narrower day-columns benefit from a deeper cascade to keep a busy cluster's
frontmost title/time text legible.
Each overlapping event also only ever claims --sch-column-fraction (default 0.7, same override
pattern as --sch-column-gap/--sch-column-overlap above) of whatever space remains after its own
cascade indent — never the full remainder — so there's always real room for the next, more-
overlapping card to intrude into and read as the front of the stack. The one exception: a cluster's
actual last (frontmost) card takes all of what's left, since nothing is stacked in front of it to
make room for. A solo, non-overlapping event is simultaneously its own first and last "column," so
it always gets the full-remainder treatment too. Set --sch-column-fraction: 1 to go back to a
flat, edge-to-edge layout for every non-last column.
Moving an event to another day
Drag an event sideways onto a different day column. Up/down still changes the time, and you can do both in the same gesture — a drag is one movement, not two.
Code
<Schedule.DaysView data={events} editable={['drag']} onEventChange={handleChange} />While you drag, a preview box shows where the event will land and the day column underneath it is highlighted. Moving never changes how long an event is — grab a piece of a multi-day event and the whole thing moves, keeping its duration exactly.
onEventChange reports the result, exactly as it already did for a same-day move; next.start and
next.end are absolute dates, so a day change needs nothing new from you:
<Schedule.DaysView
data={events}
editable={["drag"]}
onEventChange={(event, next) =>
setEvents((prev) => prev.map((e) => (e.id === event.id ? { ...e, ...next } : e)))
}
/>Turning the page
Prev and Next slide the day band in the direction you're travelling — day headers, all-day bars and day columns together — while the hour gutter, the gridlines and the now-cursor stay where they are. It's the days that moved, not the view.
Only a real page turn slides. Pressing Now, or setting date yourself to
somewhere else entirely, is a jump rather than a step, so it lands directly
instead of claiming a distance it didn't travel. A page turn you reach by
holding a drag at the edge doesn't slide either — the view moves under a
gesture that is still in flight, and animating it there would fight the drag.
Nothing to configure, and nothing animates for anyone who has asked for reduced motion.
Reaching a day that isn't on screen
Drag to the left or right edge of the grid and it scrolls. Keep holding there once it can't scroll any further and the view turns the page, so you can carry an event into the next week without letting go. The same works vertically for the time axis.
This works the same way for all three gestures — moving an event, resizing one by its grip, and drafting a new one on empty space. Whatever you are dragging survives the page turn.
Holding is deliberate — it takes about a second and a half, so brushing the edge on the way past doesn't move you anywhere.
Paging mid-drag moves the view's own anchor date. If you control date yourself, it arrives as an
onDateChange like any other navigation — ignore it and the drag simply won't page, while
everything else keeps working.
From the keyboard
Focus an event and press ← / → to move it a day at a time, ↑ / ↓ to move it by one slot (hold Shift for four). Every gesture here has a keyboard equivalent.
Reaching the other edge — resizeCollision
A resize grip eventually runs into the event's own other edge. By default it stops
minDuration short of it — one slotDuration unless you raise
it — so an event can never be shrunk below that. resizeCollision
offers the two other answers a two-thumb range control normally gives you:
| Value | What a grip does at the other edge |
|---|---|
"none" (default) | Stops minDuration short. Extra movement is ignored. |
"push" | Keeps going, carrying the other edge with it — the event holds its minimum length and slides. |
"swap" | Once the grip travels past the other edge, the two exchange roles: the old edge becomes the near one and your grip now drives the far one. |
Drag the top grip of “Rank 02 review” downwards, past the bottom of the event.
Code
<Schedule.DaysView data={events} resizeCollision="swap" editable={['resize']} />The names are Slider's own, one layer down — both sit on the same underlying
thumbCollisionBehavior, because this is the same problem on a time axis rather than a new one.
resizeCollision applies to resizing only. A move shifts both edges by the identical displacement,
so there is nothing for it to collide with; a create drag has no second stop either, since
both of its endpoints come from the one gesture and already swap simply by being read as
[earlier, later].
Whatever the mode, a commit can never invert a range or take it below the minimum duration —
"push" and "swap" change which edges move, never that invariant.
Events that cross midnight
An event whose end lands on a later calendar day than its start is still one event. The
grid just has day boundaries, so it is drawn in pieces — one per day it touches, each clipped
to that day's own hour window.
Code
<Schedule.DaysView data={events} visibleDays={4} editable={['drag', 'resize']} />Six rules cover every case, and the first two are simply what single-day events already do:
- Drag any piece to move the whole event. Grab the 00:00–02:00 tail of last night's shift, drag it down 30 minutes, and the whole thing moves — its 22:00 start becomes 22:30. Moving never changes how long an event is.
- Drag a grip to move just that edge. Resizing never touches the other end.
- Grips only appear where the event really starts and really ends. A piece that continues
past midnight has no grip on that side, and a middle piece of a three-day event has none at
all — there is no edge there to grab. The same is true of a piece clipped by
startHour/endHour: the grip is always on the edge you can actually see. - Dragging a grip through midnight grows the event into the next day, where it simply re-draws as one more piece. There is no separate "make this multi-day" action.
- Drag a grip sideways to land the edge on a particular day. While the edge is on its own day the chip stretches under your grip as usual; the moment it lands on another one the chip steps back and the draft pieces take over, since a stretch can't show a day change at all.
- Draft one directly by press-dragging empty space across days, or down past the bottom of a column — see Click-drag-to-create.
A grip can also run into the event's own other edge rather than a day boundary — see
resizeCollision for the three answers to that.
A piece that continues is squared off on the cut edge, so it visibly runs off the end of its day rather than reading as a self-contained event that happens to sit at 22:00. Every piece shows the whole event's real range, not its own clipped one — two pieces both reading "22:00 – 02:00" read as one event seen twice, which is what they are.
An event that falls entirely outside [startHour, endHour) renders nothing at all, rather
than being drawn off-grid. With a partial window this can hide data — Schedule logs a
development-mode warning when it happens.
Timed events are never auto-promoted into the all-day row, in either direction: crossing midnight
does not make something an all-day event. If you want a day-granularity event, say so explicitly
with allDay — see below.
All-day events
Set allDay: true on an event to pin it above the hour grid instead of positioning it by
time-of-day — a sticky row at the top of Days view, right under the day headers. Build its
start/end as day boundaries, the same exclusive-end convention every timed event already
uses: start is midnight of the first day, end is midnight of the day after the last day. A
multi-day all-day event draws one continuous bar spanning every day column it covers, rather than
an independent pill per day. The longest event always claims the top lane, regardless of
which one started first — the demo below's 3-day event stays visible in lane 0 even though a
same-day 1-day event was seeded first in the array.
Code
<Schedule.DaysView data={events} maxAllDayRows={2} />maxAllDayRows (default 2) caps the row's total row count — maxAllDayRows={2} always
means at most 2 rows, never 2 visible lanes plus a 3rd for "+N". Once more events are concurrent
than fit, the shortest one(s) collapse first (the same priority order that decides a bar's lane
also decides who overflows) into a shared per-day "+N", using up one of those maxAllDayRows
rows rather than an extra one on top. A lone overflowing event renders as a real bar instead of
a "+1" (it costs the same row space either way, so there's nothing worth hiding it behind) —
only 2+ collapses into a genuine "+N", opening the identical list↔detail popover — and taking the
same onOverflowClick/withOverflowDetails/renderOverflowDetails trio — the timed grid's own
"+N" uses. All-day events are display +
click only: no
drag-to-move, resize, or
click-drag-to-create, matching the same "no gesture without a real time axis to drag against"
rule the rest of Days view already follows. Month and Year need no special handling — an allDay
event there is just an ordinary member of data/staticEvents.
Month view
Fully read-only: a chip list per day, capped at maxEventsPerDay (default 3) with a +N chip
for the rest. That chip spans the cell and carries an event chip's hit target — same box, none of
its paint — and it discloses exactly the events it counts, the same rule both Days "+N" chips
follow. The visible chips stay on screen and each opens its own details popover, so nothing about
the day is out of reach; the list simply stops repeating what is already in front of you. It takes
the same onOverflowClick/withOverflowDetails/renderOverflowDetails trio Days does. Adjacent-month days render dimmed but still show
their real events. Tune maxEventsPerDay to how much
vertical room each cell actually has — a cap that's too high for a short container clips the
overflow pill along with everything else, rather than growing the row (each day's cell content is
clipped to its own grid row so the whole month stays visually uniform). It takes the same prop on
Schedule itself, where it reaches the Month view and nothing else — and where it usually wants to
be one lower than a standalone MonthView in the same box, since Schedule spends a row of that
height on its own toolbar. A disabled day (weekends in
the demo below, via dayDisabled) gets a muted background tint and a muted day-number —
existing events on it still render normally and stay clickable.
Code
<Schedule.MonthView
data={events}
staticEvents={staticEvents}
maxEventsPerDay={2}
dayDisabled={disableWeekends()}
onDayClick={(date) => openDay(date)}
/>Clicking a day — its number, or anywhere in the cell outside a chip — is the "go to that day"
gesture, and under Schedule it is wired for you: it switches to the days view anchored on that
date. Your own onDayClick replaces that rather than firing alongside it, and a standalone
Schedule.MonthView has no view to drill into, so there the callback is the only behavior.
Drilling in moves the anchor date, never the window size: a visibleDays={3} schedule stays a
three-day schedule with the clicked day inside it — and the grid scrolls that day's column into
view if the band is wider than its container, so you land on the day you picked rather than
wherever the scroll happened to be. See
Selection & navigating between views.
Code
<Schedule
data={events}
staticEvents={staticEvents}
defaultView="month"
maxEventsPerDay={2}
/>Year view
A pure overview — no per-event rendering, ever. A neutral dot marks exactly one event, a capped numeral ("9+") marks more; disabled days get a muted day-number only — no background tint here unlike Month, since a flat fill would read as a smudge at Year's much smaller cell size.
Both granularities drill down, and both are gated by presence: a day cell becomes a button when
onDayClick is set, a mini-month header when onMonthClick is — otherwise each stays inert text,
since a focusable control whose activation does nothing is worse for a keyboard user than no
control.
The standalone view below has no view to drill into, so its handlers just report the click —
that is the whole API surface Schedule.YearView has on its own:
Code
<Schedule.YearView
data={events}
dayDisabled={disableWeekends()}
onDayClick={(date) => openDay(date)}
onMonthClick={(date) => openMonth(date)}
/>Under Schedule both are wired for you: a day goes to the days view on that date, a month header
to Month view for that month. Clicking a day also brings its column into view, exactly as
drilling in from Month does — pick a day late in the week and watch the band land
on it rather than at the start of the week:
Code
<Schedule data={events} defaultView="year" visibleDays={14} />Agenda view
The same events as a chronological list rather than a grid, grouped by day then by exact start
time. Add "agenda" to views and it becomes an ordinary tab.
A list is read from its top, so the window starts at the anchor date itself and runs
visibleDays days (default 7), like a Days window with align="relative". Now puts today's
events first, Prev/Next step by the same span, and the title names the range on screen
("15 – 21 Sept 2026"). An entry can pin its own span — below, the agenda covers two weeks while
the week view beside it keeps seven days:
Code
<Schedule
data={events}
staticEvents={staticEvents}
views={['week', 'month', { id: 'agenda', visibleDays: 14 }] as const}
defaultView="agenda"
/>Rows are real buttons: clicking one selects it, driven by the same selected/onSelect triple
the grid views use — so Selection & navigating between views
applies here unchanged, including the anchor moving to reveal a selection outside the current
window. A click opens no details popover here by default: a row already prints the title,
time and description, which is all the popover would repeat. Pass withEventDetails (or
renderEventDetails, for different content) to bring it back; withSelection={false} turns
selection off as usual.
renderEvent makes a row inert. Supply it and the row stops being a button, and
withEventDetails stops applying with it. That isn't an oversight: an override is free to render
its own buttons and links, and interactive content inside a <button> is invalid HTML that
browsers silently reparse. Selection is yours to wire from inside the override — the row still
carries data-selected, so it can at least be styled.
An all-day event reads All day in place of a clock time and shows no duration, since an
all-day event's start/end are day boundaries rather than meaningful clock readings. A
multi-day event is listed once, under its own real start date — which can head a group dated just
before the range in the title.
Schedule.AgendaView also mounts on its own, with its own optional Prev/Next
(withPagination, default true standalone) and Now button (withNowButton, default false):
Code
<Schedule.AgendaView
data={events}
staticEvents={staticEvents}
onSelect={(event) => setCurrent(event)}
/>Time zones
timeZone (or an enclosing IntlProvider) sets the clock the whole
schedule reads, not just its day boundaries. Below, the events are 09:00, 11:00 and 14:30 in New
York: whatever zone you are reading from, the hour axis places them there, the chips and agenda
rows print those times, and the details popover — opened from a chip or from an agenda row —
agrees with the surface it came from.
Code
<Schedule
data={events}
timeZone="America/New_York"
views={['week', 'agenda'] as const}
withEventDetails
/>Next up
Schedule.NextUp renders exactly the same list as the agenda view over a
different window: everything from now on, unbounded, rather than a span of days from a date. It is
grouped the same way — by day (Today/Tomorrow, then a weekday+date for anything further out, all
Intl-derived, no translation key needed for any of it) then by exact start time.
It is deliberately not a switchable view, and the reason is the window: with no anchor date
there is nothing for Prev/Next to page and nothing for the title to name. That makes it the
standalone preset — a sidebar or dashboard "what's next" panel — rather than a fourth tab. Its
rows are plain text, not buttons, so a renderEvent override is free to put its own controls in
them; reach for Schedule.AgendaView when you want selection.
An all-day event reads All day in place of a clock time and shows no duration, the same as
in the agenda view — its start/end are day boundaries, not clock readings.
An event stays listed until pastEventsOffset minutes after its own end (default 30), and the
whole list is live-ticking — which events show, and their day grouping, re-derive automatically as
real time passes, the same clock primitive the now-cursor uses.
Events sharing an identical start time stack under one shared time label, each keeping its own
duration underneath it (dimmed, one per stacked event, row-aligned to that event's own content) so
they can still be told apart — below, "Evaluation: libft" and "Peer correction: push_swap" both
start at the same instant and share one time slot. Schedule.NextUp itself has no built-in
card/border — it's a bare list meant to sit inside whatever surface the consumer already has,
Card here.
Code
<Card>
<Schedule.NextUp data={events} />
</Card>renderEvent fully replaces one event's own row content for a typesafe custom payload — narrowed
against whatever generic payload type the data array itself carries, the same bare-generic
pattern renderEventDetails uses. The time label and each event's own duration both stay owned by
Schedule.NextUp itself and always render, the same as the built-in Day/Week grid's own event
time never depends on renderEventDetails — only what renders inside one event's row (next to
its now-untouched duration) is up to you. Its state argument's isNext flag marks the single
soonest event in the whole list.
Code
<Card>
<Schedule.NextUp
data={events}
renderEvent={(event, { isNext }) => (
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-gray-light-900 text-sm dark:text-gray-dark-25">
{event.title}
</p>
<p className="truncate text-gray-light-500 text-xs dark:text-gray-dark-400">
{event.payload?.room}
</p>
</div>
{isNext ? <Badge size="sm">Up next</Badge> : null}
<Button size="xs">Action</Button>
</div>
)}
/>
</Card>Prop
Type
Toolbar, composed by hand
Schedule.Toolbar is a separate, independently composable part — not baked into each view, so
an embedded DaysView with no chrome needs no withToolbar={false} escape hatch. Each part
(Title, PrevTrigger, NextTrigger, NowTrigger, ViewSwitcher) is fully prop-driven, no
shared context — the same explicit wiring Schedule itself does internally to build its own
chrome.
Code
function CustomSchedule() {
const [date, setDate] = useState(new Date());
return (
<>
<Schedule.Toolbar>
<Schedule.Toolbar.Title view="days" date={date} />
<div className="flex items-center gap-1">
<Schedule.Toolbar.NowTrigger onDateChange={setDate} />
<Schedule.Toolbar.PrevTrigger view="days" date={date} onDateChange={setDate} />
<Schedule.Toolbar.NextTrigger view="days" date={date} onDateChange={setDate} />
</div>
</Schedule.Toolbar>
<Schedule.DaysView data={events} date={date} onDateChange={setDate} withPagination={false} />
</>
);
}Every standalone view (DaysView/MonthView/YearView) also ships its own built-in
Prev/Next/Now chrome (withPagination/withNowButton) for the common case — reach for the
composed Schedule.Toolbar parts only when the default layout doesn't fit.
In Days view specifically, clicking Now also scrolls its timed grid so the now-line lands centered
in the viewport (not flush under the sticky header) — Schedule.DaysView's own gridRef prop
exposes that same scrollable root for a fully custom toolbar composed by hand, like the one above.
Translations
One translations prop, prop-only — no context, no bundled locale packs. Weekday/month names
and AM/PM are Intl-derivable from locale/timeZone and need no key at all.
Code
<Schedule
data={events}
locale="fr-FR"
translations={{ now: 'Maintenant', previous: 'Précédent', next: 'Suivant', days: 'Jours', month: 'Mois', year: 'Année', agenda: 'Agenda' }}
/>Touch & mobile
Mobile keeps the same grid and the same gestures as desktop — no separate read-only fallback.
Resize grips get a bigger invisible hit area under pointer: coarse (44×44px, per WCAG 2.5.5)
without becoming clumsily oversized for a mouse in a dense week grid — a CSS media query is the
only thing that ever changes hit-area size, never a JS pointer-type check.
The remove button works differently, because it has nowhere to grow into: it sits inside the chip, and a chip is only as tall as its event. Its hit area is a constant 24px wide and grows to 24×24 once the chip itself is tall enough to contain that (roughly a 33-minute event at the default density), but never further — the strips immediately above and below a chip already belong to its resize grips, and a remove target overhanging into them would take a destructive action's hit box across a neighbouring event. On a shorter chip the inline button stays under the 24×24 minimum, and the full-size control in the details popover is the accessible path instead (WCAG 2.2 SC 2.5.8's "bypass" exception: the same function, reachable from a control that does meet the minimum).
Dashboard example
A more realistic screen: Schedule as the main content of an AppShell, with
editable and preventOverlap fully on, and an Aside that swaps between Schedule.NextUp
(nothing selected — mixing in staticEvents, a companion agenda for both interactive and
read-only events at once) and a full edit form (an event selected) — title, description,
start/end time, room, a color swatch, and per-event permission toggles, all actually mutating the
grid live. visibleDays={5} align="relative" shows the rolling window covered in
Days view window above.
Code
<AppShell>
<AppShell.Main>
<Schedule
data={events}
staticEvents={staticEvents}
startHour={8}
endHour={18}
visibleDays={5}
align="relative"
editable={['create', 'drag', 'resize', 'remove']}
preventOverlap
selected={selected?.id ?? null}
onSelect={setSelected}
withEventDetails={false}
onEventChange={(event, next) => updateEvent(event.id, next)}
onEventRemove={(event) => removeEvent(event.id)}
onEventCreate={(ranges) => createEvents(ranges)}
/>
</AppShell.Main>
<AppShell.Aside>
{selected ? (
<EventEditForm event={selected} />
) : (
<Schedule.NextUp data={[...events, ...staticEvents]} />
)}
</AppShell.Aside>
</AppShell>Click an event to open its edit form in the Aside — change its title, description, time, room, or
color, and the grid updates live; toggle its drag/resize/remove permissions individually; remove
it entirely. Click empty grid space and drag to create a new event, which opens straight into the
same form. Deselect (the back arrow) to return to the Schedule.NextUp agenda.
View full-page example
API
Prop
Type
Accessibility
- Each resize handle is
role="separator"witharia-orientation="horizontal",aria-valuenow/min/maxin minutes-since-midnight, andaria-valuetextas a formatted clock time. ArrowUp/ArrowDownon a focused resize handle nudge byslotDuration(Shift×4,Alt= 1 minute); the same keys on the chip body itself move the whole event.ArrowLeft/ArrowRighton the chip body move the event one calendar day — the keyboard equivalent of dragging it sideways. No modifier variants: one press is one day. This is the only way to change an event's day without a pointer, so it is not optional polish.- Note that screen readers in browse mode (NVDA, JAWS) intercept plain arrow keys before they reach
a focused element, so the arrow-key move and resize are unreachable there. This is a documented
limitation rather than an oversight — the alternative,
role="application", would suppress browse mode across the whole grid and cost far more than it buys. - Removing an event is reachable two ways: the inline
(x)on the chip, and a full-size Remove event button in the event's details popover, which every chip opens from its whole body. The second exists because the inline button cannot meet WCAG 2.2 SC 2.5.8's 24×24 target minimum on a short chip — see Touch & mobile. Both appear only wheneditablegrants"remove", and both go throughonEventRemove. - A focused resize handle shows a visible focus indicator — its (otherwise invisible) hit area
itself stays unringed, and the small dash mark inside it recolors and thickens instead, the same
"ring the visible mark, not the oversized hit box" treatment
DataTable's column-resize handle uses. - Month's disabled day cells carry
data-disabled. The disabled-slot overlay itself isaria-hidden— its background tint andcursor-not-allowedare both visual only, the actual enforcement happens on commit, not from this element. Schedule.Toolbar'sViewSwitcheris built onTabs' machine — arrow keys move between whichever entriesviewsoffers, and the active one carriesaria-selected.- An agenda row's accessible name carries the time as well as the title ("Standup, 09:00 – 10:00", or the date for an all-day event), because the visible time sits in a sibling column and a same-start stack prints it only once for the whole slot. A row is a button only when it is actually selectable and its content isn't overridden — a focusable control whose activation does nothing is worse for a keyboard user than plain text, and an override may carry controls of its own that must not nest inside a button.
- Click-drag-to-create has no keyboard equivalent — a documented limitation, not an oversight. A
keyboard-only user creates events through their own "Add event" UI instead, calling the same
code a successful
onEventCreatewould.
Changelog
- #331
Event time ranges are formatted with
IntlformatRange, so the locale merges shared parts ("9:00 – 10:30 AM") and a range that crosses midnight shows its dates - #317
Calendar, DatePicker, DataTable's date filter and Schedule's event details follow
IntlProvider'stimeZone(prop, then provider, then host) instead of always reading the host zone - #328
With
timeZoneset, chip times, theiraria-labels, the details popover, the now-cursor and the resize handles all read the schedule's zone instead of the host's
- #303
The now-cursor label stays inside the time grid near
startHour/endHourinstead of being clipped and scrolling the view
- #294
asChildis a compile error onSchedule, its views andSchedule.NextUp(ScheduleToolbarand itsTitlekeep it) - #286
visibleDaysworks with an agenda renderer, and an agenda entry can pin its own span:{ id: "agenda", visibleDays: 14 } - #286
The agenda opens on its anchor date for
visibleDaysdays (default 7) instead of a calendar month; Prev/Next step by that span and the title shows the range - #286
Clicking an agenda row only selects it:
withEventDetailsnow defaults tofalsein the agenda — pass it (orrenderEventDetails) to bring the popover back - #288
Pill,Timeline.Item,AlertandScheduleToolbar.Titlerender their content inside your element underasChildinstead of rendering nothing
- #269
The column packer floors each event's duration
- #253
slotDurationaccepts only 15, 30 or 60, and the newminDurationjoinsmaxDuration, both checked against the slot size at compile time - #245
selectedcan name several chips, with the first as the anchor that opens the details popover - #244
Play a chip's entrance once per mount, and slide a moved chip instead of snapping it
- #216
Gate view-scoped props per property, not per object
- #212
Root props follow the configured
views: a prop no configured view reads is a compile error - #214
One "+N" overflow concept:
renderDayDetails→renderOverflowDetails,classNames.overflowChip→classNames.overflow - #206
An Agenda view, available in the view switcher
- #209
Popovers open beside the chip inside the view's own scroller and mount lazily; Popover accepts Ark's full positioning options
- #204
Forward
maxEventsPerDayfromSchedule.Rootto the Month view
- #202
Stop the column guard discarding valid RTL layouts, self-heal a bad measurement mid-gesture, and contain the page-turn and create-draft scroll overflow
- #158
Move an event to another day by pointer or keyboard, with a live drop time and auto-scroll at the edges
- #167
Drag-create an event, or resize one's edge, across days and past the visible window
- #170
collisionsets what a resize grip does at the event's other edge:none,pushorswap - #166
Month and Year drill down into the day or month you focus, and Month's "+N" chip opens its own popover
- #165
Remove an event from its details popover
- #157
Events that cross midnight render as one segment per day
- #168
Turning the page slides the day band
- #161
Palette-tinted focus rings draw from
--c-text - #169
The move preview sits on the day the event will land on, and covers every day it spans
- #158
Build the day band in the schedule's
timeZone, not the host's - #155
Chip text no longer paints through the chip's bottom border
- #165
The remove button stays inside short chips, and a chip's details popover no longer loses clicks to create
- #156
Month rows count each pill's border in their minimum height
- #110
Each gesture
editablegrants requires its callback at compile time:onEventChange,onEventRemoveoronEventCreate - #109
onEventDismissfires on a plain click on empty grid space in the days view - #109
Chips animate in on create and out on remove, and both are announced to screen readers
- #109
The timed grid stretches to fill its container, with
hourHeightas the minimum - #109
Chips slide into place when a sibling changes and lift on hover, and resize grips slide out on hover or focus
- #109
The view switcher labels the days view "Week" by default, not "Days"
- #109
The now-line's guide stays aligned with today's marker once the grid scrolls
- #112
Chips render and tab in visual order, not array order
- #113
All-day events no longer show through the "All day" label
- #40
New Schedule, with days, month and year views, a toolbar, all-day events and event details popovers
- #40
Click-drag to create, drag to move and edge-resize, with
preventOverlapand slot predicates likedisablePast() - #89
editablegrants gestures, read-only by default and narrowable per event, with live red feedback on a rejected one - #98
The now-cursor shows a clock-time label, and the toolbar's Now button scrolls to it
- #99
Schedule.NextUp: a list of upcoming events grouped by day