Skip to content
SuperScheduler

InteractionApplies toSuperScheduler Pro

Hours, minutes, days and zoom

Choose the cell size with scale ('Hour', 'Day', 'Week', 'Month', 'Year', or 'CellDuration' with cellDuration in minutes), set cellWidth in pixels per cell and days for the length, and describe the header rows with timeHeaders. Hide nights and weekends with businessBeginsHour, businessEndsHour and showNonBusiness={false}. For zoom, list zoomLevels and switch between them with control.zoom.setActive, animateTo or step; pinch and Ctrl/Cmd+wheel gestures are on by default.

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

The time axis of SuperScheduler Pro is defined by a handful of options: what one cell represents (scale), how wide it is (cellWidth), where the timeline starts and how long it is (startDate, days), and how the header rows label it (timeHeaders). Zoom is a list of such configurations, zoomLevels, that users reach with gestures and your code reaches through control.zoom.

This guide goes from fixed scales to continuous zoom. Lite has a fixed day axis; everything else here requires Pro.

Scale, cell duration and width

scaleOne cell isTypical use
'Minute'1 minuteBroadcast rundowns, lab runs
'CellDuration'cellDuration minutes (default 60)5, 15 or 30-minute slots; 240-minute shifts
'Hour'1 hourWorkshops, meeting rooms, crews
'Day'1 calendar dayHotels, rentals, staffing
'Week'1 calendar week, starting on weekStartsProjects, campaigns
'Month'1 calendar monthLong assignments, capacity plans
'Year'1 calendar yearMulti-year overviews
'Manual'The cells you list in timelineIrregular periods

cellWidth is in pixels per cell of the current scale (default 40): 44 means 44 pixels per day on a day axis but 44 pixels per hour on an hour axis. startDate (default today, truncated to midnight) and days set the length of the timeline.

Some typical configurations:

src/scales.tsts
import type { SchedulerProps } from 'super-scheduler'

// A month of day cells: the classic booking chart.
export const monthOfDays = {
  scale: 'Day',
  startDate: '2026-10-01',
  days: 31,
  cellWidth: 44,
  timeHeaders: [{ groupBy: 'Month' }, { groupBy: 'Day', format: 'ddd d' }],
} satisfies SchedulerProps

// One working day in 15-minute cells; nights are removed from the axis.
export const quarterHours = {
  scale: 'CellDuration',
  cellDuration: 15,
  startDate: '2026-10-12',
  days: 1,
  cellWidth: 36,
  businessBeginsHour: 7,
  businessEndsHour: 19,
  showNonBusiness: false,
  timeHeaders: [
    { groupBy: 'Hour', format: 'HH:mm' },
    { groupBy: 'Cell', format: 'mm' },
  ],
} satisfies SchedulerProps

// A work week of hours, Monday to Friday, with 12-hour labels.
export const workWeekOfHours = {
  scale: 'Hour',
  startDate: '2026-10-12',
  days: 5,
  cellWidth: 48,
  timeFormat: 'Clock12Hours',
  businessBeginsHour: 8,
  businessEndsHour: 18,
  showNonBusiness: false,
  timeHeaders: [{ groupBy: 'Day', format: 'dddd d MMMM' }, { groupBy: 'Hour' }],
} satisfies SchedulerProps

// A year in month cells, for long-running assignments.
export const yearOfMonths = {
  scale: 'Month',
  startDate: '2026-01-01',
  days: 365,
  cellWidth: 90,
  timeHeaders: [{ groupBy: 'Year' }, { groupBy: 'Month', format: 'MMM' }],
} satisfies SchedulerProps

cellDuration also sets the default snapping: with 15-minute cells, moves, resizes and selections snap to quarter hours. The snapToGrid family of options turns snapping off per gesture. On a day axis, events are drawn as whole cells by default (useEventBoxes: 'Always'); set useEventBoxes="Never" to draw them at their exact times, so a 14:00 to 11:00 stay starts and ends inside its day cells.

Time headers

timeHeaders lists the header rows from top to bottom. Each row groups time by a unit and may set a label format and a height:

groupByGroups by
'Year', 'Quarter', 'Month', 'Week', 'Day', 'Hour', 'Minute'That calendar unit
'Cell'One label per cell
'Default'cellGroupBy (default 'Day')
'None'One label for the whole row

The default is [{ groupBy: 'Default' }, { groupBy: 'Cell' }]: days above cells. Each header row is headerHeight pixels tall (default 30) unless it sets its own height.

Format tokens

Formats use these tokens; any other character is printed as is. The examples format 2026-10-05T14:30:00 with the en-us locale.

TokenOutputTokenOutput
yyyy2026HH14
yy26H14
MMMMOctoberhh02
MMMOcth2
MM10mm30
M10m30
ddddMondayss, s00, 0
dddMottPM
dd, d05, 5%d5

Names follow the scheduler's locale (default 'en-us'): 'dddd d MMMM' gives "lunes 5 octubre" with locale="es-es". In several locales ddd is a one or two-letter abbreviation ("Mo", "L"); use dddd, or write your own label in onBeforeTimeHeaderRender, when you want three letters. Header labels, styles, tooltips and areas can all be customized in that hook.

12-hour or 24-hour labels

timeFormat controls the default hour labels: 'Auto' (default) follows the locale (12-hour for en-us, 24-hour for most European locales), 'Clock12Hours' and 'Clock24Hours' force one. An explicit format on a header row always wins: 'h:mm tt' for 12-hour labels, 'HH:mm' for 24-hour labels. Changing the clock format only changes labels; event times never move.

Business hours and hidden time

Business time is defined by businessBeginsHour (default 9), businessEndsHour (default 18; 0 means midnight at the end of the day) and businessWeekends (default false). With showNonBusiness at its default true, non-business cells are shaded. With showNonBusiness={false} they are removed from the axis:

  • on a day axis, weekend days disappear (14 days become 10 columns);
  • on an intraday axis, hours outside the business range disappear, so a working week in hours shows only 08:00 to 18:00 each day.

For anything more specific, onIncludeTimeCell is called for every candidate cell while the timeline is built: set args.cell.visible = false to drop a cell, or args.cell.width to resize it. scale: 'Manual' with a timeline array of { start, end, width } cells gives full control.

Zoom levels

A zoom level is a named set of options applied together: typically scale, cellDuration, cellWidth and timeHeaders. Define the ladder once, at module level:

src/zoom-levels.tsts
import type { SuperScheduler } from 'super-scheduler'

/**
 * From the most detailed view to the widest. Each level is a set of options applied together;
 * cellWidth is in px per cell of that level (per 15 minutes, per hour, per day, per week).
 */
export const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'quarter-hours',
    properties: {
      scale: 'CellDuration',
      cellDuration: 15,
      cellWidth: 40,
      timeHeaders: [
        { groupBy: 'Day', format: 'dddd d MMMM' },
        { groupBy: 'Cell', format: 'HH:mm' },
      ],
    },
  },
  {
    id: 'hours',
    properties: {
      scale: 'Hour',
      cellWidth: 56,
      timeHeaders: [
        { groupBy: 'Day', format: 'dddd d MMMM' },
        { groupBy: 'Hour', format: 'HH:mm' },
      ],
    },
  },
  {
    id: 'days',
    properties: {
      scale: 'Day',
      cellWidth: 80,
      timeHeaders: [{ groupBy: 'Month' }, { groupBy: 'Day', format: 'ddd d' }],
    },
  },
  {
    id: 'weeks',
    properties: {
      scale: 'Week',
      cellWidth: 120,
      timeHeaders: [{ groupBy: 'Month' }, { groupBy: 'Week' }],
    },
  },
]

export type ZoomLevelId = 'quarter-hours' | 'hours' | 'days' | 'weeks'
export const ZOOM_LEVEL_IDS: readonly ZoomLevelId[] = ['quarter-hours', 'hours', 'days', 'weeks']

Pass it as zoomLevels, and pick the initial level with zoom (an index or an id). zoomPosition ('left' by default, or 'middle', 'right') decides which part of the viewport stays in place when the level changes.

A property may also be a function of the anchor date, ({ date, level }) => value, for example to show the year in the month header only around New Year.

During a continuous gesture, the scheduler picks the level nearest to the current time-per-pixel and applies its axis and headers as the user crosses into it. Properties that would reset the window, such as days and startDate, apply only when your code selects a level explicitly. The order of the array does not matter to gestures, which measure every level.

Change zoom from code

control.zoom has three methods and one property:

MemberWhat it does
setActive(level, position?, anchorDate?)Applies a level (index or id) immediately, including days and startDate
animateTo(target, options?)Animates to { level } or to a free { cellWidth }; returns a promise that resolves when it settles
step(delta, options?)Moves delta positions through zoomLevels, in array order and clamped; without zoomLevels, multiplies the cell width by 1.6 per step
activeIndex of the active level, -1 before any level is applied

animateTo and step accept { duration, position, anchorDate }: duration in milliseconds (default 300, 0 for no animation), and anchorDate as a date, 'center' or 'today' to keep that moment in place. Animations are instant when the user prefers reduced motion, and do nothing while a zoom gesture is running. An unknown level id throws.

src/ZoomablePlanning.tsxtsx
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { ZOOM_LEVELS, ZOOM_LEVEL_IDS, type ZoomLevelId } from './zoom-levels'

// Stable objects: a new one per render would be re-applied on every render.
const ZOOM_GESTURE: SuperScheduler.ZoomGestureOptions = {
  // The default maximum (400 px per cell) is too narrow to cross from days into hours.
  max: 1024,
}

interface Props {
  readonly rooms: SuperScheduler.ResourceData[]
  readonly bookings: SuperScheduler.EventData[]
}

export function ZoomablePlanning({ rooms, bookings }: Props) {
  const { controlRef, control } = useSchedulerControl()
  const [level, setLevel] = useState<ZoomLevelId>('days')
  const owned = useMemo(() => bookings.slice(), [bookings])

  // One React update when a gesture or an animation settles, never one per frame.
  const onZoom = useCallback((args: SuperScheduler.ZoomArgs) => {
    if (args.phase !== 'end') return
    const id = ZOOM_LEVEL_IDS[args.level]
    if (id !== undefined) setLevel(id)
  }, [])

  const show = (id: ZoomLevelId) =>
    void control?.zoom.animateTo({ level: id }, { anchorDate: 'center' })
  // step() walks the zoomLevels array in its order: here -1 is more detail, +1 a wider view.
  const zoomIn = () => void control?.zoom.step(-1)
  const zoomOut = () => void control?.zoom.step(1)

  return (
    <>
      <div role="toolbar" aria-label="Zoom">
        {ZOOM_LEVEL_IDS.map((id) => (
          <button key={id} type="button" aria-pressed={level === id} onClick={() => show(id)}>
            {id}
          </button>
        ))}
        <button type="button" aria-label="Zoom in" onClick={zoomIn}>
          +
        </button>
        <button type="button" aria-label="Zoom out" onClick={zoomOut}>
          −
        </button>
      </div>
      <SuperSchedulerComponent
        controlRef={controlRef}
        startDate="2026-10-12"
        days={14}
        zoomLevels={ZOOM_LEVELS}
        zoom="days"
        zoomPosition="middle"
        zoomGesture={ZOOM_GESTURE}
        onZoom={onZoom}
        resources={rooms}
        events={owned}
      />
    </>
  )
}

You should see four level buttons and plus and minus buttons above the planning. Pressing "hours" animates the axis from days to hours around the centre of the view, and the pressed state follows pinch gestures too, because onZoom reports the level when each zoom ends.

onZoom receives phase ('start', 'change', 'end'), origin ('gesture' or 'api'), level, cellWidth, scale, cellDuration, the anchor date, the viewport start and the level of detail. Gestures and animateTo report every frame. Update React state only when phase === 'end'; work that must follow every frame should write to the DOM directly.

Gestures

Zoom gestures are on by default: Ctrl or Cmd with the mouse wheel (which also covers trackpad pinch in Chrome, Edge and Firefox), trackpad pinch in Safari, and two-finger pinch on touch screens. Zooming is continuous and anchored under the pointer. Tune it with zoomGesture:

OptionDefaultMeaning
mincellWidthMin (at least 1)Smallest cell width in pixels
max400Largest cell width in pixels
wheel'ctrl''always' zooms on every vertical wheel (Shift+wheel scrolls); false never zooms with the wheel
pinchtrueSafari trackpad and touch pinch
sensitivity1Speed multiplier
scales'zoomLevels'Cross between your levels; 'auto' uses an hour, day, week, month ladder; false keeps the current scale
linknoneSchedulers with the same link id zoom together

zoomGesture={false} removes every gesture listener. Because cellWidth is per cell, crossing from a day level to an hour level needs room: a day at 400 pixels is only about 17 pixels per hour, so raise max (the example uses 1024) when your ladder goes from days into hours.

keyboardOptions={{ zoomKeys: true }}, with keyboardEnabled, adds Ctrl/Cmd with = or + (step(1)), - (step(-1)) and 0 (back to the initial level) while the focus is inside the scheduler.

Zoom widgets

super-scheduler/zoom-ui provides three optional DOM widgets that update without React renders:

  • createZoomHud(control, options): a readout inside the grid, shown during gestures and for 700 ms after an API zoom; format sets its text.
  • createZoomSlider(control, container, options): a native range input with keyboard support, a logarithmic or linear scale, and detents at your zoom levels or at explicit widths. Its value is pixels per cell, so it suits a single-scale axis best.
  • createLodBadge(control, container, labels): shows whether the view is in detail, compact or overview mode.
src/PlanningWithZoomWidgets.tsxtsx
import { useEffect, useRef } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { createLodBadge, createZoomHud, createZoomSlider } from 'super-scheduler/zoom-ui'

export function PlanningWithZoomWidgets({ rooms }: { rooms: SuperScheduler.ResourceData[] }) {
  const { controlRef, control } = useSchedulerControl()
  const toolbar = useRef<HTMLDivElement>(null)

  // Widgets need a mounted control. The cleanup matters: Strict Mode mounts twice in development.
  useEffect(() => {
    const host = toolbar.current
    if (control === null || host === null) return
    // A pill inside the grid while zooming (no container needed).
    const hud = createZoomHud(control, {
      format: ({ cellWidth }) => `${Math.round(cellWidth)} px per day`,
    })
    // A native range input; its value is px per cell, so it suits a single-scale axis like this one.
    const slider = createZoomSlider(control, host, {
      min: 4,
      max: 160,
      scale: 'log',
      label: 'Day width',
    })
    // Detail, Compact or Overview, following the level of detail.
    const badge = createLodBadge(control, host)
    return () => {
      hud.dispose()
      slider.dispose()
      badge.dispose()
    }
  }, [control])

  return (
    <>
      <div ref={toolbar} role="toolbar" aria-label="Zoom" />
      <SuperSchedulerComponent
        controlRef={controlRef}
        startDate="2026-10-01"
        days={120}
        scale="Day"
        cellWidth={44}
        resources={rooms}
      />
    </>
  )
}

Each widget has element and dispose(). Create them in an effect keyed on the control and dispose them in its cleanup; disposing the control also removes them.

Level of detail

When users zoom far out, drawing every label at full size would be unreadable and slow. The level of detail (lod, on by default) adapts the rendering to the space on screen and changes nothing at 40 pixels per day or more:

  • Events, below 40 pixels per day, adapt to their own width: full content from 80 pixels, one line of text from 66 pixels, a plain block below that. Under 8 pixels per day, blocks become solid fills and narrow events thin bars.
  • Cells show their content (HTML, text, areas) from 24 pixels per cell. Below 2 pixels per cell there are no cell elements at all, and onBeforeCellRender is not called.
  • Grid lines keep at least 8 pixels apart, weekend and non-business shading needs 6 pixels per day, and header labels shorten or coarsen when they no longer fit.

Every threshold can be changed through lod={{ ... }} (zoomedOut, eventFull, eventText, eventSolid, cellContent, cellBackground, gridLines, shading, dayLabel, dayNumber, weekLabel, hysteresis), and lod={false} renders literally at every zoom. The current state is control.levelOfDetail (level is 'full', 'compact' or 'overview') and is written as data-lod attributes on the root, for your CSS.

Physiotherapy clinic appointmentsA patient cannot make 10:00 AM. Find the next slot that respects breaks and room cleaning. Festival stage schedulingA line check runs into a set’s buffer. Zoom to five minutes, trim it, and see every room and crew the act depends on. Fleet rental planningA compact is grounded on pickup day. Hand its rental to another car, keep the cleaning slot and see where the fleet runs out.

Next steps