|Kit

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 clickable

kind 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 narrow

That 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 views changes nothing. The default is ["days", "month", "year"], which configures three of the four renderers, so every prop those read stays available. Only renderEvent is out of reach, because the default list has no agenda entry.
  • A views array 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 scheme editable uses.

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 visibleDays shorter than 7 then slides forward in whole windows until it contains the anchor, so the window always shows the day it is anchored on: with visibleDays={5} and a Monday start, a Saturday anchor gives Sat–Wed rather than the Mon–Fri that would leave the anchor off screen entirely. A visibleDays of 7 or more never needs to slide, since the anchor is always inside the first window. The one exception is visibleDays={1}, which always shows the anchor date itself regardless of align — a single day has no "start of week" to snap to, so this always matches the old standalone DayView.
  • "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, and disableOutsideHours(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 for disableDateRanges, 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:

ValueWhat 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:

  1. 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.
  2. Drag a grip to move just that edge. Resizing never touches the other end.
  3. 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.
  4. 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.
  5. 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.
  6. 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" with aria-orientation="horizontal", aria-valuenow/min/max in minutes-since-midnight, and aria-valuetext as a formatted clock time.
  • ArrowUp/ArrowDown on a focused resize handle nudge by slotDuration (Shift ×4, Alt = 1 minute); the same keys on the chip body itself move the whole event.
  • ArrowLeft/ArrowRight on 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 when editable grants "remove", and both go through onEventRemove.
  • 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 is aria-hidden — its background tint and cursor-not-allowed are both visual only, the actual enforcement happens on commit, not from this element.
  • Schedule.Toolbar's ViewSwitcher is built on Tabs' machine — arrow keys move between whichever entries views offers, and the active one carries aria-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 onEventCreate would.

Changelog

v0.19.0
  • Event time ranges are formatted with Intl formatRange, so the locale merges shared parts ("9:00 – 10:30 AM") and a range that crosses midnight shows its dates

    #331
  • Calendar, DatePicker, DataTable's date filter and Schedule's event details follow IntlProvider's timeZone (prop, then provider, then host) instead of always reading the host zone

    #317
  • With timeZone set, chip times, their aria-labels, the details popover, the now-cursor and the resize handles all read the schedule's zone instead of the host's

    #328
v0.18.2
  • The now-cursor label stays inside the time grid near startHour/endHour instead of being clipped and scrolling the view

    #303
v0.18.0
  • asChild is a compile error on Schedule, its views and Schedule.NextUp (ScheduleToolbar and its Title keep it)

    #294
  • visibleDays works 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 visibleDays days (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: withEventDetails now defaults to false in the agenda — pass it (or renderEventDetails) to bring the popover back

    #286
  • Pill, Timeline.Item, Alert and ScheduleToolbar.Title render their content inside your element under asChild instead of rendering nothing

    #288
v0.17.1
  • While dragging, the ghost slides between day columns instead of jumping

    #276
  • Resolve the locale and time zone through IntlProvider

    #274
  • Mirror under RTL: Schedule's icons, spacing, page turns, chip day moves and edge auto-scroll, and Calendar's prev/next chevrons

    #274
v0.17.0
  • The column packer floors each event's duration

    #269
v0.16.0
  • slotDuration accepts only 15, 30 or 60, and the new minDuration joins maxDuration, both checked against the slot size at compile time

    #253
  • selected can name several chips, with the first as the anchor that opens the details popover

    #245
  • Play a chip's entrance once per mount, and slide a moved chip instead of snapping it

    #244
v0.15.0
  • onEventCreate receives an array of ranges, one per created event

    #224
  • Right-drag creates one event per day, and maxDuration caps what create and resize can produce

    #224
  • Static chips top-align their title like editable chips

    #239
v0.14.1
  • Gate view-scoped props per property, not per object

    #216
v0.14.0
  • Root props follow the configured views: a prop no configured view reads is a compile error

    #212
  • One "+N" overflow concept: renderDayDetails → renderOverflowDetails, classNames.overflowChip → classNames.overflow

    #214
  • An Agenda view, available in the view switcher

    #206
  • Popovers open beside the chip inside the view's own scroller and mount lazily; Popover accepts Ark's full positioning options

    #209
  • Forward maxEventsPerDay from Schedule.Root to the Month view

    #204
v0.13.1
  • 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

    #202
v0.13.0
  • Evaluate slotDisabled per calendar day, in the schedule's zone

    #196
  • Keep the days window containing its own anchor

    #195
  • Point to where the now-cursor went when it's off-window

    #197
  • Keep touch-action on the chip body, gated on canDrag

    #198
v0.12.0
  • onEventClick/onEventDismiss are replaced by selected, defaultSelected and onSelect; withEventDetails controls the popover

    #185
  • onEventRemove fires on the click; the exit animation is derived from data, so any removal animates

    #185
  • A configurable views list and a useSchedule hook

    #185
v0.11.0
  • Move an event to another day by pointer or keyboard, with a live drop time and auto-scroll at the edges

    #158
  • Drag-create an event, or resize one's edge, across days and past the visible window

    #167
  • collision sets what a resize grip does at the event's other edge: none, push or swap

    #170
  • Month and Year drill down into the day or month you focus, and Month's "+N" chip opens its own popover

    #166
  • Remove an event from its details popover

    #165
  • Events that cross midnight render as one segment per day

    #157
  • Turning the page slides the day band

    #168
  • Palette-tinted focus rings draw from --c-text

    #161
  • The move preview sits on the day the event will land on, and covers every day it spans

    #169
  • Build the day band in the schedule's timeZone, not the host's

    #158
  • Chip text no longer paints through the chip's bottom border

    #155
  • The remove button stays inside short chips, and a chip's details popover no longer loses clicks to create

    #165
  • Month rows count each pill's border in their minimum height

    #156
v0.7.0
  • Each gesture editable grants requires its callback at compile time: onEventChange, onEventRemove or onEventCreate

    #110
  • onEventDismiss fires 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 hourHeight as 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

    #109
  • Chips render and tab in visual order, not array order

    #112
  • All-day events no longer show through the "All day" label

    #113
v0.6.0
  • 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 preventOverlap and slot predicates like disablePast()

    #40
  • editable grants gestures, read-only by default and narrowable per event, with live red feedback on a rejected one

    #89
  • The now-cursor shows a clock-time label, and the toolbar's Now button scrolls to it

    #98
  • Schedule.NextUp: a list of upcoming events grouped by day

    #99

On this page