# Coordinated panes and saved views

> Split one timeline into synchronized panes with super-scheduler/panes, move events between them, and save and restore zoom, scroll and rows with super-scheduler/views.

Source: https://superscheduler.org/en/docs/panes-saved-views/
Reviewed: 2026-10-07

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.

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`.

```tsx
// src/RoomsWithTray.tsx
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
| Field | Default | Effect |
|---|---|---|
| `id` | required | Identifies the pane in handlers (`args.pane`), `panesRef` and the DOM (`data-pane`) |
| `resources` | | The rows of this pane |
| `rowFilter` | | Picks 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 |
| `minSize` | `48` | Smallest height in pixels; minimums win when the total is too small |
| `hidden` | `false` | Hides the pane but keeps it mounted, so showing it again costs nothing |
| `props` | | Props 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
| Prop | Default | Effect |
|---|---|---|
| `height` | required | Total 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'` |
| `splitter` | `true` | `{ 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.

> **Behavior:**
> User-resized heights last until `panes` or `height` change. Define `panes` at module level or memoize it, as the snippet does; a new array on every render would reset the splitter. To remember sizes across sessions, store them from `onPaneResize` and pass them back as each pane's `size`.

## 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](https://superscheduler.org/en/docs/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](https://superscheduler.org/en/docs/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.

### Link schedulers you place yourself
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`:

```tsx
// src/LinkedBoards.tsx
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 `include` | Saved fields | Notes |
|---|---|---|
| `'zoom'` | `cellWidth`, `zoomLevel` | `zoomLevel` is the index of the active level in `zoomLevels`, so keep their order stable |
| `'scroll'` | `anchorDate`, `topRowId`, `topOffset` | The date at the left edge and the top row, by id, with the offset inside it |
| `'density'` | `density` | Only when you set the `density` prop |
| `'collapsed'` | `collapsed` | Ids of tree parents that are collapsed |
| `'columns'` | `columnWidths`, `columnOrder` | Row header column widths and order |

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

```ts
// src/savedView.ts
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 })
}
```
```tsx
// src/PlannerWithViews.tsx
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.

> **Tip:**
> If your application keeps `density` or `rowHeaderColumns` in React state, restore those through your state and leave them out of `include`, as the snippet does. `applyViewState` changes them on the control, and a later prop change from React would override it.

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.

## Related
→ https://superscheduler.org/en/examples/training-rooms/
- [Resource trees, columns and selection](https://superscheduler.org/en/docs/trees-columns-selection/) for the collapsed rows and columns a view saves.
- [Time scales and zoom](https://superscheduler.org/en/docs/time-scales-zoom/) for the zoom levels a view restores.
