# Hours, minutes, days and zoom

> Configure scales, cell durations and time headers, show hours or 15-minute cells, hide non-business time, and zoom from months to minutes with gestures, code and widgets.

Source: https://superscheduler.org/en/docs/time-scales-zoom/
Reviewed: 2026-10-07

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.

> **Behavior:**
> The defaults are `scale: 'CellDuration'` with `cellDuration: 60` and `days: 1`: a component with only `resources` and `events` shows a single day in hourly cells. Always set `scale` and `days` explicitly.

Some typical configurations:

```ts
// src/scales.ts
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`:

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

> **Limitation:**
> Hiding time only changes the axis. It does not move, shorten or validate events that fall into hidden periods; if users must not plan there, refuse those positions in your rules or [disable the cells](https://superscheduler.org/en/docs/drag-resize-rules/#disabled-cells) instead of hiding them.

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

```ts
// src/zoom-levels.ts
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.

```tsx
// src/ZoomablePlanning.tsx
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.

> **Behavior:**
> `step()` and the zoom keys follow the order of your `zoomLevels` array. With a ladder ordered from most detailed to widest, as above, `step(1)` and Ctrl/Cmd `+` move to a wider view. If you enable `zoomKeys` with `zoomLevels`, order the levels from the widest view to the most detailed so that `+` zooms in.

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

```tsx
// src/PlanningWithZoomWidgets.tsx
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.

→ https://superscheduler.org/en/examples/clinic-appointments/
→ https://superscheduler.org/en/examples/festival-stages/
→ https://superscheduler.org/en/examples/fleet-rentals/
## Next steps
- Show where the viewport is on a long timeline: [Minimap and metrics](https://superscheduler.org/en/docs/minimap-metrics/).
- Save the zoom and scroll position per user: [Panes and saved views](https://superscheduler.org/en/docs/panes-saved-views/).
- Keep scrolling and zooming fast with large data: [Performance and virtualization](https://superscheduler.org/en/docs/performance-virtualization/).
