Skip to content
SuperScheduler

CustomizationApplies toSuperScheduler Pro

React render slots and hover cards

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.

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

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

PropArgumentsReplaces
renderEventcontrol, e, data, row, width, lodThe content of an event box
renderRowHeadercontrol, row, columnThe content of a row header cell (column is the column index, 0 without columns)
renderTimeHeadercontrol, header (start, end, level)The content of a time header cell
renderCornercontrolThe top-left corner
renderCellcontrol, cellThe content of a grid cell
renderAreacontrol, area, sourceAn 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

src/CampaignBoard.tsxtsx
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.

Headers, corner, cells and areas

src/TeamBoard.tsxtsx
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.

Hover cards

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

OptionDefaultEffect
render(args)requiredCard content; args has control, e, row, anchor (the event's box), pinned and close()
delay350Milliseconds the pointer rests before the card opens
leaveGrace180Milliseconds before closing after the pointer leaves the event or the card
placement'auto''auto', 'above', 'below', 'start' or 'end'
pinfalse'click' or 'dblclick' pins the card so users can interact with it
glidetrueMoving to another event moves the open card instead of reopening it
src/BookingsWithCards.tsxtsx
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.

Creative agency resource planningA designer is double-booked. Hand two tasks to a colleague in one drag, check what they unblock, and take it back. Lab instrument bookingBook an instrument and its calibration comes with it. Clear a session out of a service visit, then stretch your run.