# React render slots and hover cards

> Render events, rows, headers, cells and areas as React components with super-scheduler/react-render, add hover cards, and keep large boards fast while scrolling.

Source: https://superscheduler.org/en/docs/react-render-slots/
Reviewed: 2026-10-07

Import SuperSchedulerComponent from super-scheduler/react-render instead of the package root, then pass renderEvent, renderRowHeader, renderTimeHeader, renderCorner, renderCell or renderArea; each returns the React content of one kind of slot. The HTML or text fallback paints first and React replaces it in short idle batches, so scrolling never waits for React. Add eventHover for hover cards that users can pin.

SuperScheduler paints its grid with its own DOM code, which is what keeps scrolling smooth with thousands of rows and events. When the content of an event or a header should come from your React components (your design system, icons, avatars, formatted values), the `super-scheduler/react-render` entry mounts React content into the scheduler's slots without giving React control of the grid.

React render slots and hover cards need SuperScheduler Pro.

## Switch to the React-render component
`super-scheduler/react-render` exports its own `SuperSchedulerComponent`. It accepts every prop of the main component, exposes the same `ref.current.control` and `controlRef`, and adds the `render*` props, `eventHover`, `renderOptions` and the `onBefore*DomAdd` / `onBefore*DomRemove` handlers.

The component from the package root accepts these props too, but only warns once (`needs the component from "super-scheduler/react-render"`) and renders nothing from them. Keeping the React machinery in its own entry means pages that do not use it do not load it.

## The slots
| Prop | Arguments | Replaces |
|---|---|---|
| `renderEvent` | `control`, `e`, `data`, `row`, `width`, `lod` | The content of an event box |
| `renderRowHeader` | `control`, `row`, `column` | The content of a row header cell (`column` is the column index, 0 without columns) |
| `renderTimeHeader` | `control`, `header` (`start`, `end`, `level`) | The content of a time header cell |
| `renderCorner` | `control` | The top-left corner |
| `renderCell` | `control`, `cell` | The content of a grid cell |
| `renderArea` | `control`, `area`, `source` | An area declared with `render: true` |

The engine keeps the parts it owns: the event box and its position, the duration bar, resize handles, ordinary areas, the tree toggle and the grid lines. A slot is the content inside.

## Render event content
```tsx
// src/CampaignBoard.tsx
import { memo, useMemo } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerProps } from 'super-scheduler'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'
import 'super-scheduler/styles.css'

type Campaign = { client: string; channel: 'social' | 'print' | 'video'; progress: number }

const CHANNEL_LABEL: Record<Campaign['channel'], string> = {
  social: 'Social',
  print: 'Print',
  video: 'Video',
}

const CampaignContent = memo(function CampaignContent(props: {
  title: string
  campaign: Campaign
  compact: boolean
}) {
  const { title, campaign, compact } = props
  if (compact) return <strong className="campaign__title">{title}</strong>
  return (
    <span className="campaign">
      <strong className="campaign__title">{title}</strong>
      <span className="campaign__meta">
        {campaign.client} · {CHANNEL_LABEL[campaign.channel]} ·{' '}
        {Math.round(campaign.progress * 100)}%
      </span>
    </span>
  )
})

// Module-level functions keep their identity: a new function re-renders every slot.
const renderEvent: NonNullable<SchedulerProps['renderEvent']> = ({ e, data, width, lod }) => {
  // `data` is the event after onBeforeEventRender; custom fields need a cast.
  const campaign = data as SuperScheduler.EventRenderData<Campaign>
  // `width` comes in 8 px steps and changes only when a gesture ends.
  return (
    <CampaignContent title={e.text()} campaign={campaign} compact={width < 160 || lod !== 'full'} />
  )
}

// The HTML fallback paints first and stays if the React content fails.
const onBeforeEventRender: NonNullable<SchedulerProps['onBeforeEventRender']> = (args) => {
  args.data.html = SuperScheduler.Util.escapeHtml(args.data.text)
}

export function CampaignBoard(props: {
  resources: SuperScheduler.ResourceData[]
  campaigns: SuperScheduler.EventData<Campaign>[]
}) {
  const events = useMemo(() => props.campaigns.slice(), [props.campaigns])
  return (
    <SuperSchedulerComponent
      startDate="2026-10-01"
      days={61}
      scale="Day"
      cellWidth={36}
      eventHeight={44}
      resources={props.resources}
      events={events}
      onBeforeEventRender={onBeforeEventRender}
      renderEvent={renderEvent}
    />
  )
}
```
You should see each campaign with its client, channel and progress, and only the title when the event is narrower than 160 px or zoomed out.

The arguments, in detail:

- `e` is the event wrapper: `e.id()`, `e.text()`, `e.start()`, `e.end()`, and `e.data` for the stored object.
- `data` is the event as `onBeforeEventRender` left it, with `start` and `end` as `SuperScheduler.Date` values. Custom fields need a cast, as in the snippet.
- `width` is the rendered width in 8 px steps, updated when a gesture ends rather than on every frame.
- `lod` is the level of detail (`'full'`, `'compact'` or `'overview'`) when the content was rendered.

> **Behavior:**
> React content is visual. The event's accessible name still comes from `ariaLabel`, `text` or `html` (see [keyboard and accessibility](https://superscheduler.org/en/docs/keyboard-accessibility-touch/#focus-model)), so keep `text` meaningful. Pointer presses inside an event start the event's own click and drag handling: keep buttons and links out of event content and put actions in a hover card, a context menu or a detail panel.

## Headers, corner, cells and areas
```tsx
// src/TeamBoard.tsx
import { useMemo } from 'react'
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'

const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [{ groupBy: 'Month' }, { groupBy: 'Day' }]

// Scheduler dates are civil values: format their native Date in UTC to keep the wall clock.
const WEEKDAY = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: 'UTC' })

// Every slot of a kind gets the function's result: return content for each case
// (a null result leaves that slot empty rather than showing the fallback).
const SLOTS: SchedulerProps = {
  renderRowHeader: ({ row }) => {
    const role = typeof row.data.role === 'string' ? row.data.role : ''
    return (
      <span className="person">
        <span className="person__initials" aria-hidden="true">
          {row.name.slice(0, 1)}
        </span>
        <span className="person__name">{row.name}</span>
        {role !== '' && <span className="person__role">{role}</span>}
      </span>
    )
  },
  renderTimeHeader: ({ header }) =>
    header.level === 0 ? (
      <span>{header.start.toString('MMMM yyyy')}</span>
    ) : (
      <span className="day">
        <small>{WEEKDAY.format(header.start.toDate())}</small> {header.start.toString('d')}
      </span>
    ),
  renderCorner: () => <span className="corner">Team</span>,
  // Only areas declared with `render: true` reach renderArea.
  renderArea: ({ area }) =>
    area.id === 'approval' ? <span className="badge">Needs approval</span> : null,
  onBeforeEventRender: (args) => {
    if (args.data.status === 'draft') {
      args.data.areas = [
        { id: 'approval', render: true, right: 4, top: 4, width: 96, height: 16, action: 'None' },
      ]
    }
  },
}

export function TeamBoard(props: {
  resources: SuperScheduler.ResourceData[]
  events: SuperScheduler.EventData[]
}) {
  const events = useMemo(() => props.events.slice(), [props.events])
  return (
    <SuperSchedulerComponent
      {...SLOTS}
      startDate="2026-10-01"
      days={31}
      scale="Day"
      timeHeaders={TIME_HEADERS}
      rowHeaderWidth={200}
      resources={props.resources}
      events={events}
      // Keeps React work bounded on large boards (defaults shown).
      renderOptions={{ sliceMs: 8 }}
    />
  )
}
```
A render function owns every slot of its kind. Return content for each case: a `null` result leaves that slot empty instead of showing the fallback. `renderArea` is the exception in practice, because only areas you declared with `render: true` reach it.

Notes per slot:

- **Row headers.** The tree toggle stays in place. With `rowHeaderColumns`, the function runs once per column and receives its index in `column`.
- **Time headers.** `header.level` is the index in `timeHeaders` (0 is the top row). Scheduler dates are civil values: to format them with `Intl`, pass `date.toDate()` and `timeZone: 'UTC'`, as the snippet does.
- **Cells.** `renderCell` mounts one React root per mounted cell, and none while cells are narrower than 24 px. A view showing 40 rows by 30 days already mounts 1,200 of them: for availability, prices or shading, set `html`, `cssClass` or `backColor` in `onBeforeCellRender` instead.
- **Areas.** Declare the area on the event (or row, cell, header) with `render: true` and its position; `source` tells you which item the area belongs to.

## Fallbacks, batching and lifecycle
React content never blocks painting:

1. The scheduler paints the HTML or text fallback first: the `html` or `text` your data and `onBefore*Render` hooks produce.
2. When the browser is idle, React content is committed in batches that target `renderOptions.sliceMs` (8 ms by default). Each slot hides its fallback once its content is ready.
3. During scrolling, zooming and dragging, existing content moves with the grid. New slots and renderer changes wait until the gesture ends.
4. If a render function throws, that slot keeps its fallback and the error is reported once per slot through the browser's `reportError` (a global `error` event your error tracking can catch).

Content that scrolls out of view is kept detached so it can come back without re-rendering: up to `renderOptions.retain` items, by default twice the mounted count with a maximum of 2,000. Local state inside a retained item survives; an evicted item starts over. `retain: 0` turns retention off.

Slot content is rendered through portals, so it sees your providers: theme, translations, router, data clients. CSS can target `[data-super-scheduler-slot]`, `[data-super-scheduler-slot-ready]` and `[data-super-scheduler-fallback]`.

On the server, the component renders an empty `<div>`; slots appear after the scheduler mounts on the client. See [server rendering and prerendering](https://superscheduler.org/en/docs/ssr-prerender/).

> **Tip:**
> Code ported from callback-based schedulers can use `onBeforeEventDomAdd` and its siblings (cell, row header, time header, corner): set `args.element` to a DOM node or a React element, and the matching `DomRemove` handler receives the same element. When both exist, the `render*` prop wins and logs one warning.

## Hover cards
`eventHover` shows a React card next to an event after the pointer rests on it. Without `eventHover`, no card appears.

| Option | Default | Effect |
|---|---|---|
| `render(args)` | required | Card content; `args` has `control`, `e`, `row`, `anchor` (the event's box), `pinned` and `close()` |
| `delay` | `350` | Milliseconds the pointer rests before the card opens |
| `leaveGrace` | `180` | Milliseconds before closing after the pointer leaves the event or the card |
| `placement` | `'auto'` | `'auto'`, `'above'`, `'below'`, `'start'` or `'end'` |
| `pin` | `false` | `'click'` or `'dblclick'` pins the card so users can interact with it |
| `glide` | `true` | Moving to another event moves the open card instead of reopening it |

```tsx
// src/BookingsWithCards.tsx
import { useMemo } from 'react'
import type { SchedulerEventHoverOptions, SuperScheduler } from 'super-scheduler'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'

// Module-level: the options object keeps its identity across renders.
const BOOKING_CARD: SchedulerEventHoverOptions = {
  delay: 350,
  leaveGrace: 180,
  placement: 'auto',
  // A click pins the card as a non-modal dialog; on touch screens a tap does it.
  pin: 'click',
  render: ({ e, row, pinned, close }) => (
    <article className="booking-card">
      <h3>{e.text()}</h3>
      <p>{row.name}</p>
      <p>
        {e.start().toString('d MMM, HH:mm')} to {e.end().toString('d MMM, HH:mm')}
      </p>
      {pinned && (
        <button type="button" onClick={close}>
          Close
        </button>
      )}
    </article>
  ),
}

export function BookingsWithCards(props: {
  resources: SuperScheduler.ResourceData[]
  events: SuperScheduler.EventData[]
}) {
  const events = useMemo(() => props.events.slice(), [props.events])
  return (
    <SuperSchedulerComponent
      startDate="2026-10-01"
      days={14}
      scale="Day"
      resources={props.resources}
      events={events}
      eventHover={BOOKING_CARD}
    />
  )
}
```
How the card behaves:

- An unpinned card is a `role="tooltip"`; a pinned card is a non-modal `role="dialog"` that takes focus. Escape or a click outside closes a pinned card and returns focus to the event.
- Moving the pointer into the card keeps it open. Scrolling, zooming, dragging and selecting hide it at once.
- The card is placed when it opens, flips or shrinks to fit the viewport, and respects reduced motion.
- It lives in `document.body` and carries the scheduler's theme. Style it with `--super-scheduler-hover-padding`, `-hover-border`, `-hover-radius`, `-hover-bg`, `-hover-color`, `-hover-shadow` and `--super-scheduler-z-hover`.
- Touch screens have no hover: with `pin: 'click'`, a tap opens a pinned card.

Hover cards are independent of the HTML bubbles (`bubble`, `bubbleHtml`). Bubbles cannot host React content; use `eventHover` for that.

## Performance
- **Stable functions.** Define render functions and option objects at module level, or memoize them. A new function identity re-renders every slot of that kind.
- **Cheap renders.** `sliceMs` is a target for batches, not a limit on your code: one slow render function delays its batch. Do not read layout or measure DOM inside render functions; use `width` and `lod`.
- **Memoized components.** Wrap slot components in `memo` and pass primitive props, as in the event snippet.
- **Context.** A context value that changes often re-renders every slot that reads it. Keep fast-changing state (pointer position, timers) out of contexts that slots consume.
- **Cells.** Prefer `onBeforeCellRender` strings to `renderCell` on large grids.
- **No per-frame state.** Do not set React state from `onScroll` or drag handlers; the library does its per-frame work without React renders.

Measure your own content with the React Profiler: the library cannot make an expensive component cheap.

## Related
→ https://superscheduler.org/en/examples/agency-campaigns/
→ https://superscheduler.org/en/examples/lab-instruments/
- [Themes, tokens, Tailwind and dark mode](https://superscheduler.org/en/docs/theming/) for styling slot content with the scheduler's tokens.
- [Performance and virtualization](https://superscheduler.org/en/docs/performance-virtualization/) for the rendering model behind slots.
