# Keyboard, accessibility and touch

> Enable full keyboard navigation, understand the focus model and announcements, offer alternatives to dragging, and adapt the timeline to touch and assistive tech.

Source: https://superscheduler.org/en/docs/keyboard-accessibility-touch/
Reviewed: 2026-10-07

In Pro, set keyboardEnabled (off by default), add keyboardMode: 'Full' for the complete key set and keyboardTarget: 'component' so keys act only while the grid has focus. The grid is a single tab stop with role="grid": focus moves through aria-activedescendant and changes are announced in nine languages. Lite has arrow-key navigation built in. On touch screens, hold an event to move it and drag its handles to resize it.

A resource scheduler is a large two-dimensional grid, which makes keyboard and screen reader support harder than in a list or a form. SuperScheduler gives the grid a single tab stop, a roving focus that survives virtualization, spoken announcements for focus and changes, and keyboard equivalents for moving and resizing events. This guide explains what each edition does, how to turn it on, and what your application still has to provide.

## Lite and Pro at a glance
| | Lite (`super-scheduler-lite`) | Pro (`super-scheduler`) |
|---|---|---|
| Keyboard | Always on: arrows move the active cell, Enter or Space activates it | Off by default; `keyboardEnabled`, plus `keyboardMode: 'Full'` for the complete model |
| Events | Native buttons: Tab reaches them, Enter or Space clicks them | Reached with the arrow keys inside the grid; Enter runs the click flow |
| Editing by keyboard | No (read-only edition) | Move with Alt+arrows, resize with Alt+Shift+Left/Right (Full mode) |
| Announcements | No | Focus, selection and committed changes, in nine languages |
| Grid name | `ariaLabel` option (default "Resource schedule") | Built-in "Scheduler" ("Planificador" for Spanish locales) |
| Touch | Native scrolling and taps | Hold to move, handles to resize, pinch to zoom |

## Enable the keyboard in Pro
Pro keeps the keyboard off until you set `keyboardEnabled: true`. The default `keyboardMode: 'SuperScheduler'` handles arrows, Enter and Shift+Left/Right. `keyboardMode: 'Full'` adds the rest of the model: Home/End, PageUp/PageDown, Space, moving and resizing events, the context menu key and announcements of help text. Full mode without `keyboardEnabled` does nothing and warns in development.

`keyboardTarget` decides where keys are heard. The default, `'document'`, reacts to keys pressed anywhere on the page outside text fields, which takes the arrow keys away from page scrolling. Use `'component'` so keys act only while the grid has focus; it is also the right choice with several schedulers on one page.

```tsx
// src/AccessiblePlanner.tsx
import { useMemo } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'
import 'super-scheduler/styles.css'

// Module-level: the same object on every render.
const KEYBOARD: SchedulerProps = {
  keyboardEnabled: true,
  // Keys act only while the grid has focus; the page keeps its own arrow-key scrolling.
  keyboardTarget: 'component',
  keyboardMode: 'Full',
  keyboardOptions: { pageRows: 10, zoomKeys: true },
}

const UNDER_MAINTENANCE = new Set<SuperScheduler.ResourceId>(['room-104'])

export function AccessiblePlanner(props: {
  resources: SuperScheduler.ResourceData[]
  events: SuperScheduler.EventData[]
  onOpen: (id: SuperScheduler.EventId) => void
}) {
  const { onOpen } = props
  const events = useMemo(() => props.events.slice(), [props.events])

  const config = useMemo<SchedulerProps>(
    () => ({
      ...KEYBOARD,
      // Enter on a focused event runs the same click flow as the pointer.
      onEventClick: (args) => onOpen(args.e.id()),
      // Alt + arrow moves go through the same rules as drags.
      onEventMoving: (args) => {
        if (UNDER_MAINTENANCE.has(args.resource)) {
          args.allowed = false
          args.message = 'Room under maintenance'
        }
      },
    }),
    [onOpen],
  )

  return (
    // The grid's own accessible name is generic: label the region around it.
    <section aria-labelledby="room-plan-title">
      <h2 id="room-plan-title">Room plan, October 2026</h2>
      <SuperSchedulerComponent
        {...config}
        startDate="2026-10-01"
        days={31}
        scale="Day"
        locale="en-us"
        resources={props.resources}
        events={events}
      />
    </section>
  )
}
```
You should be able to Tab into the grid, move with the arrows, press Enter on an event to open it, and press Alt+Down on an event to start moving it. Moving it onto room 104 and pressing Enter announces "Not allowed here", and the event stays where it was.

`keyboardOptions` tunes Full mode:

| Option | Default | Effect |
|---|---|---|
| `pageRows` | rows in view minus one | Rows moved by PageUp and PageDown |
| `contextMenuKey` | `true` in Full mode | Menu key and Shift+F10 open the focused item's menu |
| `bubbleOnFocus` | `false` | Shows the event's bubble while it has keyboard focus |
| `selectAll` | `true` in Full mode | Ctrl/Cmd+A selects every event in view (needs `allowMultiSelect`) |
| `zoomKeys` | `false` | Ctrl/Cmd with `=` or `+`, `-` and `0` zoom in, out and back |

`zoomKeys` is off by default so the browser's own page zoom shortcuts keep working.

## Keys
| Keys | Mode | What happens |
|---|---|---|
| Arrow keys | both | Move the focus. Left and Right stop at each event and each empty cell of the row; Up and Down change rows. |
| Enter | both | On an event: the click flow (`onEventClick`, then `eventClickHandling`). On a cell: selects it as a time range. |
| Shift+Left / Shift+Right | both | Extends a time range from the focused cell; releasing Shift selects it. |
| Space | Full | On a cell: adds it to or removes it from the selection. On an event: same as Enter. |
| Home / End | Full | First or last cell of the row; with Ctrl/Cmd, the first or last row. |
| PageUp / PageDown | Full | Moves the focus by `pageRows`. |
| Alt+arrow keys | Full | Starts moving the focused event; arrows move it, Enter or Space drops it. |
| Alt+Shift+Left / Right | Full | Starts resizing the focused event's end; Left and Right change it, Enter confirms. On a column title, moves the column. |
| Escape | both | Cancels a keyboard move, resize or range. During a keyboard move or resize, Tab also cancels it and leaves the grid. |
| Menu key, Shift+F10 | Full | Opens the menu of the focused event, row header or cell. |
| Ctrl/Cmd+A | Full | Selects every event in view. |

> **Behavior:**
> Keyboard moves and resizes go through the same rules as the pointer: `onEventMoving` and `onEventResizing` can refuse a position, `onEventMove` can cancel or confirm asynchronously, and `allowEventOverlap`, locked events and disabled cells apply. Keys typed in inputs, text areas and editable elements keep their normal behavior, and a key your `onKeyDown` handler cancels with `args.preventDefault()` is not handled by the grid.

## Focus model and screen readers
The Pro grid has `role="grid"` with `aria-rowcount` and `aria-colcount`; row headers are `rowheader`, time header cells `columnheader`, and cells and events `gridcell`. Rows and cells outside the viewport are not in the DOM, so focus does not move from element to element. Instead:

- the grid root is the single tab stop (`tabindex="0"`) while the keyboard is enabled;
- the focused cell or event is a focus node that the root points at with `aria-activedescendant`, and it survives scrolling and virtualization;
- the focus node's label reads like "Room 101, Oct 1" for a cell and "Ana, Room 101, Oct 2 – 4" for an event.

An event's name comes from its `ariaLabel`, then its `text`, then its `html` as plain text, then its id. A row's name is its resource `name`. When `html` shows something different from `text`, set `ariaLabel` in `onBeforeEventRender`:

```tsx
// src/labelVisit.ts
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerProps } from 'super-scheduler'

type Visit = { patient: string; kind: 'checkup' | 'surgery'; color: string }

const KIND_LABEL: Record<Visit['kind'], string> = { checkup: 'check-up', surgery: 'surgery' }

/** Pass as `onBeforeEventRender` (module-level, so its identity never changes). */
export const labelVisit: NonNullable<SchedulerProps['onBeforeEventRender']> = (args) => {
  const visit = args.data as SuperScheduler.EventRenderData<Visit>
  args.data.backColor = visit.color
  // Dark text on light fills and light text on dark ones (WCAG contrast).
  args.data.fontColor = SuperScheduler.ColorUtil.contrasting(visit.color)
  // `html` is trusted markup: escape what users typed.
  args.data.html = `<strong>${SuperScheduler.Util.escapeHtml(visit.patient)}</strong>`
  // The accessible name of the event. Focus labels and announcements append
  // the row name and the dates, so they are not repeated here.
  args.data.ariaLabel = `${visit.patient}, ${KIND_LABEL[visit.kind]}`
}
```
Because focus labels and announcements append the row and the dates, `ariaLabel` should hold only what identifies the event. `SuperScheduler.ColorUtil.contrasting(color)` returns dark text for light fills and light text for dark ones.

The grid's own accessible name is "Scheduler" ("Planificador" when the locale starts with `es`), and 0.1.0 has no option to change it. Put the scheduler inside a region labelled by a visible heading, as the setup snippet does.

From code, `control.keyboard` offers `focusEvent(e or id)`, `focusCell(date, resource)`, `getFocus()`, `move(direction)`, `clearFocus()` and `resetFocus()`. `onKeyboardFocusChange` (cancelable) and `onKeyboardFocusChanged` report focus changes with `previous` and `focus` (`{ e }` or `{ cell }`); use them to sync a detail panel with the keyboard.

### Announcements
A polite live region inside the grid announces:

- in Full mode, a short help text the first time the grid takes focus;
- selections ("Selected: Room 101, Oct 4"), deselections and "N events selected";
- the start of a keyboard move or resize, with instructions;
- committed moves and resizes ("Event moved to Room 101, Oct 3 – 5"), whether they came from the keyboard or the pointer;
- "Cancelled", "Not allowed here" and column moves.

The texts follow the first segment of the scheduler's `locale`: English, Spanish, Catalan, Basque, Galician, German, French, Italian and Portuguese. Other languages fall back to English. Language packs other than English and Spanish load on demand.

> **Limitation:**
> In 0.1.0 the announcement texts are built in: `keyboardOptions` does not let you replace them. Dates in announcements follow the scheduler's locale.

### The context menu key
In Full mode, the Menu key and Shift+F10 open the menu of the focused item when it is a `SuperScheduler.Menu`: the event's `contextMenu` (or the control's `contextMenu`), `contextMenuResource` on a row header, and `contextMenuSelection` on a cell. If your application draws its own menu from `onEventRightClick`, handle the key yourself in `onKeyDown`:

```tsx
// src/menuHandlers.ts
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'

export interface MenuRequest {
  readonly eventId: SuperScheduler.EventId
  /** Viewport coordinates where the application opens its own menu. */
  readonly x: number
  readonly y: number
}

/**
 * Opens the application's menu from the pointer (right click) and from the keyboard
 * (Menu key, Shift+F10). The library's Menu key support covers `SuperScheduler.Menu`
 * objects only, so a custom menu handles the key in `onKeyDown`.
 */
export function menuHandlers(open: (request: MenuRequest) => void): SchedulerProps {
  return {
    onEventRightClick: (args) => {
      args.preventDefault()
      open({ eventId: args.e.id(), x: args.originalEvent.clientX, y: args.originalEvent.clientY })
    },
    onKeyDown(args) {
      const key = args.originalEvent
      if (key.key !== 'ContextMenu' && !(key.key === 'F10' && key.shiftKey)) return
      const focused = this.keyboard.getFocus().e
      const root = key.target
      if (focused === undefined || !(root instanceof HTMLElement)) return
      // Skips the library's own handling of the key.
      args.preventDefault()
      // The grid points at the focused item with aria-activedescendant.
      const ring = root.ownerDocument.getElementById(
        root.getAttribute('aria-activedescendant') ?? '',
      )
      const box = (ring ?? root).getBoundingClientRect()
      open({ eventId: focused.id(), x: box.left, y: box.bottom })
    },
  }
}
```
Spread `menuHandlers(open)` into the scheduler props, memoized. Your menu is then responsible for its own focus: move focus into it when it opens, and return it to the grid when it closes.

## Accessible alternatives to dragging
[WCAG 2.2 success criterion 2.5.7](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) asks for a way to do with single pointer actions what dragging does. Full keyboard mode covers keyboard users, but not someone using a single pointer, a switch or voice control. Give every event a non-drag path, for example a detail panel or a context menu entry with start, end and resource fields that updates your state (or calls `control.events.update` with a new object). Run the same validation you use in `onEventMoving` before saving, so both paths enforce the same rules.

## Touch
On touch screens, one finger scrolls the timeline. The rest of the touch model:

- **Moving an event.** Hold the event still for `tapAndHoldTimeout` (300 ms) and then drag. A finger that moves more than about 8 px before that scrolls instead. `eventTapAndHoldHandling` decides what a hold does: `'Move'` (default), `'ContextMenu'` (opens the event's `SuperScheduler.Menu`) or `'Disabled'`. Holding a row header opens `contextMenuResource`.
- **Resizing.** A tap on an event shows its handles, with 44 px touch targets (`--super-scheduler-handle-target`); drag a handle to resize. The edge of an event under a finger moves it rather than resizing it.
- **Selecting time.** A tap on an empty cell selects it (`origin: 'click'`); a hold and drag selects a range.
- **Zoom.** Two fingers pinch to zoom (`zoomGesture.pinch`, on by default). A second finger cancels any drag in progress.
- **Hover.** Touch has no hover. Hover cards from `eventHover` can be pinned with a tap (`pin: 'click'`); [areas](https://superscheduler.org/en/docs/react-render-slots/) with `visibility: 'TouchVisible'` stay visible on touch devices, while `'Hover'` areas do not appear.

Lite is read-only: it scrolls natively and reports taps through `onEventClick` and `onTimeRangeClick`.

## Reduced motion, contrast and forced colors
Pro reads the user's preferences through CSS and media queries, with no option to set:

- `prefers-reduced-motion: reduce` sets `--super-scheduler-duration` to `0s`, makes `control.zoom.animateTo()` instant and removes hover card transitions;
- `prefers-contrast: more` strengthens borders, grid lines, row lines and the selection outline;
- `forced-colors: active` switches the theme to system colors (`Highlight`, `CanvasText`, `GrayText`) and drops decorative shading such as weekends and today.

Lite also adapts its borders and focus outline to forced colors. If you replace colors with your own tokens or CSS, test these modes again: your overrides can undo them.

## Lite keyboard support
Lite needs no configuration. The grid is focusable, `aria-readonly`, and named by the `ariaLabel` option. Arrow keys move the active cell (announced through `aria-activedescendant`), Enter or Space calls `onTimeRangeClick` for it, and each event is a native button.

```tsx
// src/OccupancyBoard.tsx
import { SuperSchedulerComponent } from 'super-scheduler-lite'
import type { SuperScheduler } from 'super-scheduler-lite'
import 'super-scheduler-lite/styles.css'

const ROOMS: SuperScheduler.ResourceData[] = [
  { id: 'r101', name: 'Room 101' },
  { id: 'r102', name: 'Room 102' },
]

const STAYS: SuperScheduler.EventData[] = [
  { id: 'b1', resource: 'r101', start: '2026-10-02', end: '2026-10-05', text: 'Lena Fischer' },
]

export function OccupancyBoard(props: {
  onOpenBooking: (id: SuperScheduler.EventData['id']) => void
  onOpenDay: (resource: SuperScheduler.ResourceData['id'], day: string) => void
}) {
  return (
    <SuperSchedulerComponent
      // The grid's accessible name (default "Resource schedule").
      ariaLabel="Room occupancy, October 2026"
      startDate="2026-10-01"
      days={31}
      scale="Day"
      resources={ROOMS}
      events={STAYS}
      // Events are native buttons: Tab reaches them, Enter and Space click them.
      onEventClick={({ e }) => props.onOpenBooking(e.data.id)}
      // Arrow keys move the active cell; Enter or Space activates it.
      onTimeRangeClick={({ start, resource }) =>
        props.onOpenDay(resource, start.toString('yyyy-MM-dd'))
      }
    />
  )
}
```
## What your application must still ensure
The library handles the grid. These parts belong to your application:

- **Contrast.** Event colors you set with `backColor`, `fontColor`, CSS or custom content must meet contrast requirements in light and dark mode.
- **Names in custom content.** HTML from `onBeforeEventRender`, React slots and row header markup are yours: keep `text` meaningful or set `ariaLabel`, give icons text alternatives, and avoid interactive controls inside event content.
- **Menus, dialogs and panels.** Focus management, labels and Escape handling for anything you open from the grid.
- **A non-drag path** for every drag action, as described above.
- **Page structure.** A heading or label for the region around the grid, and a sensible place for it in the tab order.
- **Testing.** Check your configuration with a screen reader and an automated checker; overrides and custom content can change what users hear.

## Related
→ https://superscheduler.org/en/examples/clinic-appointments/
- [Resource trees, columns and selection](https://superscheduler.org/en/docs/trees-columns-selection/) covers what Space, Enter and Ctrl/Cmd+A select.
- [Drag, resize and business rules](https://superscheduler.org/en/docs/drag-resize-rules/) explains the rules keyboard moves go through.
- [Themes, tokens, Tailwind and dark mode](https://superscheduler.org/en/docs/theming/) covers focus and selection colors.
