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… | Path | Where |
|---|---|---|
| a color, a size, the slot height | tokens: the CSS variables calendar-theme installs | Theming |
| the classes of a part (an event, a day cell, a lane, the toolbar) | classNames: a string or a function of the part's state | below |
| what an event shows | renderEvent, with Default for the built-in content (and renderBackgroundEvent, renderDayHeader, renderEmpty) | below |
| the event element's structure | the compound parts in your copied calendar-event.tsx | below |
| a view's layout | edit the view's file (calendar-time-grid.tsx, calendar-day-grid.tsx, calendar-list.tsx), or renderers | below |
| the toolbar | toolbar: false, or a node built on useToolbar | Navigation |
| markup of your own, any styling system | the hooks (useCalendar, createCalendarKit), checked by runConformance | below |
| selection, editing, actions, drops, recurrence | their wrappers and items: <CalendarSelection>, <CalendarEditing>, <CalendarExternalDrops>, @hapio/calendar-recurrence | Selection, Editing |
| the options of the calendar you are migrating from | compat: the event-calendar item and @hapio/calendar-compat | Migration |
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
'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, itsvariant('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 activeview, andDefault, 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:
CalendarEventTimeandCalendarEventTitle. Each takes its direction from its text (dir="auto"), so a time range keeps its order in a right-to-left calendar, andcalendar-themealigns both by the calendar's direction. For a part of your own, give the elementdata-slot="calendar-event-title"(or-time),dir="auto"and the exported classesEVENT_TITLE_CLASS(orEVENT_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
timeZonethe 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-onlyspans, as above. isMirroristruewhere 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:CalendarEventItemrenders 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. renderersmaps a view kind to a component:renderers={{ list: MyAgenda }}renders your component (built onuseList) 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:
'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,idsandgetRootProps.CalendarProvidershares it with the hooks below.createCalendarKit<TData, TRes>()(views)returns the same hooks bound to your data and view types, sosegment.event.datastays 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 asinteraction),useSlotCursor,useInteractionMirror,useDragPreview,useEventActions,useExternalDraggableanduseRecurrenceScopePrompt(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
runConformancetests it (Testing). - With a sticky header of your own, keep focused elements from scrolling under it (Testing).