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.
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
scale | One cell is | Typical use |
|---|---|---|
'Minute' | 1 minute | Broadcast rundowns, lab runs |
'CellDuration' | cellDuration minutes (default 60) | 5, 15 or 30-minute slots; 240-minute shifts |
'Hour' | 1 hour | Workshops, meeting rooms, crews |
'Day' | 1 calendar day | Hotels, rentals, staffing |
'Week' | 1 calendar week, starting on weekStarts | Projects, campaigns |
'Month' | 1 calendar month | Long assignments, capacity plans |
'Year' | 1 calendar year | Multi-year overviews |
'Manual' | The cells you list in timeline | Irregular 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:
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 SchedulerPropscellDuration 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:
groupBy | Groups 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.
| Token | Output | Token | Output |
|---|---|---|---|
yyyy | 2026 | HH | 14 |
yy | 26 | H | 14 |
MMMM | October | hh | 02 |
MMM | Oct | h | 2 |
MM | 10 | mm | 30 |
M | 10 | m | 30 |
dddd | Monday | ss, s | 00, 0 |
ddd | Mo | tt | PM |
dd, d | 05, 5 | %d | 5 |
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:
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:
| Member | What 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 |
active | Index 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.
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:
| Option | Default | Meaning |
|---|---|---|
min | cellWidthMin (at least 1) | Smallest cell width in pixels |
max | 400 | Largest cell width in pixels |
wheel | 'ctrl' | 'always' zooms on every vertical wheel (Shift+wheel scrolls); false never zooms with the wheel |
pinch | true | Safari trackpad and touch pinch |
sensitivity | 1 | Speed multiplier |
scales | 'zoomLevels' | Cross between your levels; 'auto' uses an hour, day, week, month ladder; false keeps the current scale |
link | none | Schedulers 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;formatsets 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.
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
onBeforeCellRenderis 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
- Show where the viewport is on a long timeline: Minimap and metrics.
- Save the zoom and scroll position per user: Panes and saved views.
- Keep scrolling and zooming fast with large data: Performance and virtualization.
Related examples
- FormaPhysiotherapy clinic appointmentsA patient cannot make 10:00 AM. Find the next slot that respects breaks and room cleaning.
- Aurora LiveFestival 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.
- FleetlineFleet 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.
- Court ClubSports club court bookingA padel net snaps mid-morning. Move the clinic, close the court and keep every coach where the rota says.