Hapio Calendar

Customization

Which path fits a need, from a token to markup of your own, and what each path keeps working for you.

Hapio Calendar is built in layers so that each change costs as little as it can: the props and tokens change nothing you copied, the copied registry files are yours to edit, and the hooks let you write every element yourself. Pick the first row that covers your need.

Needs and paths

You need to change…PathWhere
a color, a size, the slot heighttokens: the CSS variables calendar-theme installsTheming
the classes of a part (an event, a day cell, a lane, the toolbar)classNames: a string or a function of the part's statebelow
what an event showsrenderEvent, with Default for the built-in content (and renderBackgroundEvent, renderDayHeader, renderEmpty)below
the event element's structurethe compound parts in your copied calendar-event.tsxbelow
a view's layoutedit the view's file (calendar-time-grid.tsx, calendar-day-grid.tsx, calendar-list.tsx), or renderersbelow
the toolbartoolbar: false, or a node built on useToolbarNavigation
markup of your own, any styling systemthe hooks (useCalendar, createCalendarKit), checked by runConformancebelow
selection, editing, actions, drops, recurrencetheir wrappers and items: <CalendarSelection>, <CalendarEditing>, <CalendarExternalDrops>, @hapio/calendar-recurrenceSelection, Editing
the options of the calendar you are migrating fromcompat: the event-calendar item and @hapio/calendar-compatMigration

Whatever the path, the accessibility stays: the getters carry every role, name, key handler and state, and the paths above never remove them unless you replace the element, in which case runConformance tells you what your element must keep.

Class names

classNames adds classes to twelve parts: root, toolbar, view, dayHeader, dayCell, dayLane, event, backgroundEvent, moreLink, nowIndicator, listDay and empty. A function receives the part's state (an event's event, isPast, isToday, isDragging; a day lane's dateIso and isToday; a day cell's isOtherMonth and isDisabled, outside validRange or an unnumbered day of another month; …) and returns a class string or nothing. The classes are added after the registry's own.

The parts also carry data-* attributes for their state (data-today, data-past, data-pending, data-dragging, …), which Tailwind's data-*: variants and plain CSS can target, and every element has a data-slot naming it (calendar-event, calendar-lane).

Render functions

components/styled-bookings.tsx
'use client'

import type {
  CalendarClassNames,
  CalendarEventInputBase,
  EventRenderArgs,
} from '@hapio/calendar-react'

import { CalendarEventTime, EVENT_TITLE_CLASS } from '@/components/hapio-calendar/calendar-event'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
import type { Booking } from '@/lib/bookings'

const STATUS: Readonly<Record<Booking['status'], string | null>> = {
  confirmed: null,
  tentative: 'Tentative',
  canceled: 'Canceled',
}

/** The default content, with the status in the name for screen readers ("Canceled: …"). */
function renderBooking({ event, title, timeText, Default }: EventRenderArgs<Booking>) {
  const status = STATUS[event.data.status]
  if (status === null) return <Default />
  return (
    <>
      <CalendarEventTime />
      {timeText === '' ? null : ' '}
      {/* The title part's slot, direction and classes, as `CalendarEventTitle` renders them. */}
      <span data-slot="calendar-event-title" dir="auto" className={EVENT_TITLE_CLASS}>
        <span className="sr-only">{status}: </span>
        {title}
      </span>
    </>
  )
}

/** Classes per part: a string, or a function of the part's state. */
const classNames: CalendarClassNames<Booking> = {
  event: ({ event }) => event.data.status === 'canceled' && 'line-through opacity-70',
  dayLane: ({ isToday }) => isToday && 'bg-primary/5',
  moreLink: 'font-semibold',
}

function toCalendarEvent(booking: Booking): CalendarEventInputBase {
  return { id: booking.id, title: booking.title, start: booking.startsAt, end: booking.endsAt }
}

export function StyledBookings({ bookings }: { bookings: readonly Booking[] }) {
  return (
    <HapioCalendar
      eventItems={bookings}
      eventAccessor={toCalendarEvent}
      renderEvent={renderBooking}
      classNames={classNames}
      timeZone="Europe/Stockholm"
      className="h-[36rem]"
    />
  )
}
  • renderEvent(args) receives the event, its variant ('column' in a time grid, 'row' in a day grid or the all-day row, 'list'), timeText, title, its state (isStart, isEnd, isShort, isPast, isToday, isSelected, isDragging, isPending, isMirror), the active view, and Default, the built-in content. Return content only: the element around it, its link, focus and keys stay the calendar's.
  • The registry's parts work inside: CalendarEventTime and CalendarEventTitle. Each takes its direction from its text (dir="auto"), so a time range keeps its order in a right-to-left calendar, and calendar-theme aligns both by the calendar's direction. For a part of your own, give the element data-slot="calendar-event-title" (or -time), dir="auto" and the exported classes EVENT_TITLE_CLASS (or EVENT_TIME_CLASS), as above.
  • Render functions are called as functions on every render of the event: they may be inline, and must not call hooks. A component you render inside may suspend; with an explicit timeZone the block has no <Suspense> boundary of its own, so the nearest one is your app's: wrap such content in a <Suspense> of yours.
  • Keep the visible text the start of the name: the event's accessible name is its content, time first. Text for screen readers only (a status) goes in sr-only spans, as above.
  • isMirror is true where the content draws an edit's mirror or drag preview: render no links or buttons inside.

renderBackgroundEvent, renderDayHeader (dateIso, text, isToday, isPast) and renderEmpty (loading) work the same way, and linkComponent renders every event link (your router's Link).

The copied files

The registry files in components/hapio-calendar/ are yours, as any shadcn component is. Common edits:

  • calendar-event.tsx: CalendarEventItem renders an event element with all of the calendar's behavior around your children, so its structure is yours: <CalendarEventItem segment={segment}><CalendarEventTitle /> <CalendarEventTime /></CalendarEventItem> puts the title first. The looks per variant are plain class strings at the top of the file.
  • A view's file (calendar-time-grid.tsx, calendar-day-grid.tsx, calendar-list.tsx): layout, sizes, headers. Keep the getters spread on the elements they belong to; the comment at the top of each file draws its markup tree.
  • renderers maps a view kind to a component: renderers={{ list: MyAgenda }} renders your component (built on useList) for list views and keeps the others.

Each file starts with its version stamp. When you upgrade, shadcn add … --diff shows what changed against your edits; see Upgrading.

Markup of your own

@hapio/calendar-react's hooks return prop-getters: spread getRootProps(), getListProps(), getRootProps() of an event and the rest on elements of your own, in any styling system. A complete agenda in plain elements:

components/bookings-agenda.tsx
'use client'

import {
  CalendarProvider,
  createCalendarKit,
  defineViews,
  list,
  useAnnouncer,
  useDescriptions,
  type CalendarEventInputBase,
  type ListSegment,
} from '@hapio/calendar-react'

import type { Booking } from '@/lib/bookings'

const views = defineViews({ week: list({ unit: 'week' }), month: list({ unit: 'month' }) })

// Hooks typed for bookings and these views: module scope, once.
const kit = createCalendarKit<Booking>()(views)

function toCalendarEvent(booking: Booking): CalendarEventInputBase {
  return { id: booking.id, title: booking.title, start: booking.startsAt, end: booking.endsAt }
}

export function BookingsAgenda({ bookings }: { bookings: readonly Booking[] }) {
  const calendar = kit.useCalendar({
    eventItems: bookings,
    eventAccessor: toCalendarEvent,
    timeZone: 'Europe/Stockholm',
  })
  return (
    <CalendarProvider value={calendar}>
      <div {...calendar.getRootProps({ className: 'agenda' })}>
        <Toolbar />
        {calendar.model.status === 'ready' ? <Agenda /> : <p>Loading…</p>}
        <Descriptions />
        <LiveRegion />
      </div>
    </CalendarProvider>
  )
}

function Toolbar() {
  const toolbar = kit.useToolbar()
  return (
    <div className="agenda-toolbar">
      <button {...toolbar.getPrevButtonProps()}>{toolbar.prevLabel}</button>
      <h2 {...toolbar.getTitleProps()}>{toolbar.title}</h2>
      <button {...toolbar.getNextButtonProps()}>{toolbar.nextLabel}</button>
      <button {...toolbar.getTodayButtonProps()}>{toolbar.todayText}</button>
      <div {...toolbar.getViewSwitcherProps()}>
        {toolbar.views.map((view) => (
          <button key={view.name} {...toolbar.getViewOptionProps(view.name)}>
            {view.label}
          </button>
        ))}
      </div>
    </div>
  )
}

function Agenda() {
  const agenda = kit.useList()
  if (agenda.isEmpty) return <div {...agenda.getEmptyProps()}>{agenda.emptyText}</div>
  return (
    <ul {...agenda.getListProps()}>
      {agenda.days.map((day) => (
        <li key={day.key} {...agenda.getDayProps(day)}>
          <h3 {...agenda.getDayHeadingProps(day)}>
            {day.heading} <small>{day.headingAlt}</small>
          </h3>
          <ul {...agenda.getDayListProps(day)}>
            {day.segments.map((segment) => (
              <li key={segment.key} {...agenda.getItemProps(segment)}>
                <AgendaEvent segment={segment} />
              </li>
            ))}
          </ul>
        </li>
      ))}
    </ul>
  )
}

function AgendaEvent({ segment }: { segment: ListSegment<Booking> }) {
  const event = kit.useCalendarEvent(segment)
  const content = (
    <>
      <span className="agenda-time">{event.content.timeText}</span> {event.content.title}
    </>
  )
  // A link for an event with `href`, else a button: the hook says which.
  return event.as === 'a' ? (
    <a {...event.getRootProps()}>{content}</a>
  ) : (
    <button {...event.getRootProps()}>{content}</button>
  )
}

/** Each event's description, referenced by its `aria-describedby`. */
function Descriptions() {
  const descriptions = useDescriptions()
  return (
    <div {...descriptions.getContainerProps()}>
      {descriptions.items.map((item) => (
        <span key={item.id} id={item.id}>
          {item.text}
        </span>
      ))}
    </div>
  )
}

/** Announcements (navigation, selections, edits): visually hidden, never `display: none`. */
function LiveRegion() {
  const announcer = useAnnouncer()
  return <div {...announcer.getLiveRegionProps({ className: 'sr-only' })}>{announcer.message}</div>
}
  • useCalendar(options) (or the kit's) creates the calendar: model, commands, store, ids and getRootProps. CalendarProvider shares it with the hooks below.
  • createCalendarKit<TData, TRes>()(views) returns the same hooks bound to your data and view types, so segment.event.data stays typed through React context.
  • The view hooks: useToolbar, useList, useTimeGrid, useDayGrid, useDayCell, useCalendarEvent, useMoreLink, useBackgroundEvent, useNowIndicator, useDescriptions, useAnnouncer, useView, useCalendarSelector. Their results document the markup each expects (the API reference lists them).
  • Interactions on your markup come from @hapio/calendar-react/interaction: the engines (selection(), editing(), externalDrops(), passed as interaction), useSlotCursor, useInteractionMirror, useDragPreview, useEventActions, useExternalDraggable and useRecurrenceScopePrompt (API). Create the engines once, at module scope of a client module.
  • The accessibility contract says what your markup must keep; the development checker warns when it does not, and runConformance tests it (Testing).
  • With a sticky header of your own, keep focused elements from scrolling under it (Testing).