Skip to content
SuperScheduler

Pro modulesApplies toSuperScheduler Pro

Coordinated panes and saved views

Replace SuperSchedulerComponent with SchedulerPanes from super-scheduler/panes and describe each pane with an id plus resources or a rowFilter; the panes share horizontal scroll, zoom and row header width, scroll vertically on their own, and events can be dragged between them. For saved views, getViewState(control) returns a JSON-safe object with zoom, scroll position, density, collapsed rows and columns, and applyViewState restores it; where it is stored is up to your application.

Verified against v0.1.0 · reviewed October 7, 2026.md

Two needs come up in every large planning screen. The first is to keep part of the rows in view while the rest scrolls: an "unassigned" tray under the rooms, a team above its machines. The second is to come back to the same view later: the zoom, the date and the rows the user was looking at. super-scheduler/panes and super-scheduler/views cover them, and both need SuperScheduler Pro.

Split one timeline into panes

SchedulerPanes renders several schedulers stacked over one timeline. They share the horizontal scroll position, the zoom and the row header width; each pane scrolls vertically on its own and has its own height. Splitters between panes resize them.

It replaces SuperSchedulerComponent: you pass the same scheduler props once, plus a panes array and a total height.

src/RoomsWithTray.tsxtsx
import { useCallback, useMemo, useRef, useState } from 'react'
import type { SchedulerEventsChangeArgs, SchedulerProps, SuperScheduler } from 'super-scheduler'
import { SchedulerPanes } from 'super-scheduler/panes'
import type { SchedulerPane, SchedulerPanesHandle } from 'super-scheduler/panes'
import 'super-scheduler/styles.css'

const isTray = (resource: SuperScheduler.ResourceData) => resource.kind === 'tray'

// Module-level (or memoized): a new `panes` array resets the sizes the user dragged.
const PANES: SchedulerPane[] = [
  { id: 'rooms', rowFilter: (resource) => !isTray(resource), minSize: 200 },
  {
    id: 'tray',
    rowFilter: isTray,
    size: 140,
    minSize: 96,
    // Per-pane overrides: smaller events in the unassigned tray.
    props: { eventHeight: 28 },
  },
]

export function RoomsWithTray(props: {
  resources: SuperScheduler.ResourceData[]
  initial: SuperScheduler.EventData[]
}) {
  const [events, setEvents] = useState(props.initial)
  const panesRef = useRef<SchedulerPanesHandle>(null)

  // One list for every pane: each event appears in the pane that holds its resource.
  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => setEvents([...args.events]),
    [],
  )

  const shared = useMemo<Partial<SchedulerProps>>(
    () => ({
      onEventMove: (args) => {
        // `pane` is where the event lands; `sourcePane` is set only for a move between panes.
        if (args.sourcePane !== 'rooms' || args.pane !== 'tray') return
        // Unassigning a booking asks first; the drop waits for the answer.
        args.async = true
        void confirmWithUser(`Unassign ${args.e.text()}?`).then((ok) => {
          if (!ok) args.preventDefault()
          args.loaded()
        })
      },
    }),
    [],
  )

  return (
    <>
      <button type="button" onClick={() => panesRef.current?.scrollTo('2026-10-01', 'left')}>
        Go to 1 October
      </button>
      <SchedulerPanes
        {...shared}
        panesRef={panesRef}
        panes={PANES}
        // Total height of every pane, the splitter and the shared header.
        height={640}
        resources={props.resources}
        events={events}
        onEventsChange={onEventsChange}
        splitter={{ size: 6, step: 8 }}
        startDate="2026-10-01"
        days={60}
        scale="Day"
        cellWidth={44}
      />
    </>
  )
}

You should see the rooms on top and a 140 px "unassigned" tray below, with one time header at the top. Scroll either pane sideways and the other follows. Drag a booking from the tray into a room and it moves there; drag one from a room into the tray and the application asks first.

Pane options

FieldDefaultEffect
idrequiredIdentifies the pane in handlers (args.pane), panesRef and the DOM (data-pane)
resourcesThe rows of this pane
rowFilterPicks this pane's rows from the shared resources; use either this or resources
size'auto'Pixels, a percentage of the free height ('30%'), or 'auto' for a share of what is left
minSize48Smallest height in pixels; minimums win when the total is too small
hiddenfalseHides the pane but keeps it mounted, so showing it again costs nothing
propsProps for this pane only; handlers here replace the shared ones

Rows are assigned by top-level resource: a parent takes its children into its pane. The first pane with neither resources nor rowFilter receives every top-level resource the other panes did not take.

Layout and splitter

PropDefaultEffect
heightrequiredTotal height in pixels: every pane, the splitters and the shared header
timeHeader'first''first' shows the time header on the first visible pane only; 'all' on every pane
scrollbar'last'Horizontal scrollbar on the last pane only, or 'all'
splittertrue{ size, step } sets its thickness (6 px) and keyboard step (8 px); false removes it
onPaneResize{ sizes } after a resize is committed, by pane id

The splitter is focusable with role="separator"; its value is the height of the pane below it. Up and Down move it by step, Shift+Up and Shift+Down by 40 px, Home and End go to the limits, and Enter or a double click restores the declared sizes. While dragging, the panes are previewed; their heights change on release. In 0.1.0 its accessible name is the English "Pane size", with no option to translate it.

Events in panes

Pass all events once. Each pane shows the events whose resource is one of its rows, and an event moves to another pane when its resource does.

  • Controlled: events plus onEventsChange. The handler receives the complete, merged list in args.events, plus args.pane for the pane where the change happened. Adopt it as in controlled state.
  • Uncontrolled: defaultEvents, and the panes keep the list themselves.

Changing a pane's control.events.list directly is not shared with the other panes; go through state or the control.events API.

Moves between panes

Dragging between panes is on by default (crossPaneMove: true); false keeps every event in its pane. With the default eventMoveHandling: 'Update', a move between panes is reported once, as a 'move' change in onEventsChange.

Every shared handler receives args.pane. On a move between panes, onEventMove and onEventMoved also receive args.sourcePane, so a rule can depend on the direction: the snippet confirms only moves from the rooms into the tray, using args.async and args.loaded(). A cancelled or refused move leaves the data unchanged.

Reach each pane's control

SchedulerPanes creates the schedulers, so it gives you their controls through panesRef:

  • controls: a map from pane id to control, and control(id) for one of them;
  • forEach(run) to call something on every pane;
  • scrollTo(date, position) to scroll them together;
  • update(options) to apply options to every pane.

To use the React render slots inside panes, pass that entry's component: component={SuperSchedulerComponent} imported from super-scheduler/react-render. The panes module does not import it unless you do.

When the schedulers are not stacked (a staff plan at the top of the page and a room plan further down), keep your own components and link their controls with linkPanes:

src/LinkedBoards.tsxtsx
import { useEffect, useMemo } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { linkPanes } from 'super-scheduler/panes'

// Two schedulers placed by your own layout (here, a page section apart) that move together.
export function LinkedBoards(props: {
  staff: SuperScheduler.ResourceData[]
  rooms: SuperScheduler.ResourceData[]
  shifts: SuperScheduler.EventData[]
  bookings: SuperScheduler.EventData[]
}) {
  const staff = useSchedulerControl()
  const rooms = useSchedulerControl()
  const shifts = useMemo(() => props.shifts.slice(), [props.shifts])
  const bookings = useMemo(() => props.bookings.slice(), [props.bookings])

  useEffect(() => {
    if (staff.control === null || rooms.control === null) return
    // Horizontal scroll always; zoom and row header width too unless turned off.
    const link = linkPanes([staff.control, rooms.control], { zoom: true, rowHeaderWidth: true })
    return () => link.dispose()
  }, [staff.control, rooms.control])

  return (
    <>
      <h2>Staff</h2>
      <SuperSchedulerComponent
        controlRef={staff.controlRef}
        startDate="2026-10-01"
        days={30}
        scale="Day"
        resources={props.staff}
        events={shifts}
      />
      <h2>Rooms</h2>
      <SuperSchedulerComponent
        controlRef={rooms.controlRef}
        startDate="2026-10-01"
        days={30}
        scale="Day"
        resources={props.rooms}
        events={bookings}
      />
    </>
  )
}

Horizontal scrolling is always shared. Zoom and row header width are shared unless you pass zoom: false or rowHeaderWidth: false. Call dispose() to unlink.

Save and restore a view

A view is how the user is looking at the data, not the data itself. getViewState(control, include?) captures it as a small JSON-safe object; applyViewState(control, state, options?) restores it.

Key in includeSaved fieldsNotes
'zoom'cellWidth, zoomLevelzoomLevel is the index of the active level in zoomLevels, so keep their order stable
'scroll'anchorDate, topRowId, topOffsetThe date at the left edge and the top row, by id, with the offset inside it
'density'densityOnly when you set the density prop
'collapsed'collapsedIds of tree parents that are collapsed
'columns'columnWidths, columnOrderRow header column widths and order

Every state has v: 1. Without include, all five keys are captured.

src/savedView.tsts
import type { SuperScheduler } from 'super-scheduler'
import { applyViewState, getViewState } from 'super-scheduler/views'
import type { SchedulerViewState, ViewStateKey } from 'super-scheduler/views'

// What this application restores from the view. Density and columns stay in React state here.
const KEYS: readonly ViewStateKey[] = ['zoom', 'scroll', 'collapsed']

const storageKey = (user: string, view: string) => `planning-view:${user}:${view}`

/** Saves the current view. The application owns storage: here localStorage, per user. */
export function saveView(control: SuperScheduler.Scheduler, user: string, view: string): void {
  const state = getViewState(control, KEYS)
  try {
    localStorage.setItem(storageKey(user, view), JSON.stringify(state))
  } catch {
    // Storage can be full or disabled; a view is a convenience, not data.
  }
}

/** Stored values are untrusted input: check the shape before using them. */
function isViewState(value: unknown): value is SchedulerViewState {
  return typeof value === 'object' && value !== null && (value as { v?: unknown }).v === 1
}

/** Restores a saved view. Resolves false when nothing was saved or the rows never appeared. */
export async function restoreView(
  control: SuperScheduler.Scheduler,
  user: string,
  view: string,
): Promise<boolean> {
  let saved: unknown = null
  try {
    saved = JSON.parse(localStorage.getItem(storageKey(user, view)) ?? 'null')
  } catch {
    return false
  }
  if (!isViewState(saved)) return false
  // Waits (up to 5 s) for the rows and the saved top row, for data that loads after mount.
  return applyViewState(control, saved, { when: 'rows', timeout: 5000 })
}
src/PlannerWithViews.tsxtsx
import { useEffect, useMemo } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { restoreView, saveView } from './saved-view'

const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  { id: 'weeks', properties: { scale: 'Week', cellWidth: 120 } },
  { id: 'days', properties: { scale: 'Day', cellWidth: 44 } },
]

export function PlannerWithViews(props: {
  user: string
  resources: SuperScheduler.ResourceData[]
  events: SuperScheduler.EventData[]
}) {
  const { controlRef, control } = useSchedulerControl()
  const events = useMemo(() => props.events.slice(), [props.events])

  // Restore once the control exists; keep row ids stable so the top row can be found again.
  useEffect(() => {
    if (control !== null) void restoreView(control, props.user, 'default')
  }, [control, props.user])

  return (
    <>
      <button
        type="button"
        disabled={control === null}
        onClick={() => control && saveView(control, props.user, 'default')}
      >
        Save this view
      </button>
      <SuperSchedulerComponent
        controlRef={controlRef}
        startDate="2026-01-01"
        days={365}
        zoomLevels={ZOOM_LEVELS}
        zoom="days"
        treeEnabled
        resources={props.resources}
        events={events}
      />
    </>
  )
}

Scroll to a date, collapse a floor, press "Save this view" and reload: the planner returns to the same date and row with the floor collapsed.

How restoring works:

  • when: 'rows' (default) waits until the rows, and the saved top row, exist; that covers data that arrives after mount. If they do not appear within timeout (5,000 ms by default), the promise resolves false.
  • when: 'now' applies at once; if the saved top row is gone, the saved offset is used as an absolute scroll position.
  • animate: true animates the zoom change.
  • Parents listed in collapsed are collapsed and every other parent is expanded.
  • If the saved columns no longer match rowHeaderColumns (another number of columns), nothing is applied and the promise resolves false. A state with a version other than 1 also resolves false.
  • Restoring does not move keyboard focus.

With panes, save and restore through one pane's control (panesRef.current?.control('rooms')): zoom and horizontal scroll are shared, while vertical scroll and collapsed rows belong to that pane.

What your application owns

  • Storage. localStorage for one browser, or your backend to follow the user across devices. The library never stores anything.
  • Naming and sharing. Named views, defaults per team, links that open a view.
  • Validation. Stored views are untrusted input: check the shape and v before applying, and drop views that fail.
  • Stable ids. Row ids must mean the same rows from one session to the next for topRowId and collapsed to work.
  • Pane sizes and selections. Neither is part of a view; store pane sizes from onPaneResize if you want them back.

Training room schedulingEnrolment outgrew the room. Select both sittings, see what is free for both, move them together and keep the view.