Hapio Calendar

Your first calendar

Events, a time zone and a height are all HapioCalendar needs; the views, the toolbar and the keyboard come with it.

After installing the block, render it with your events:

components/team-calendar.tsx
import type { CalendarEventInput } from '@hapio/calendar-react'

import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'

const events: CalendarEventInput[] = [
  {
    id: 'haircut-anna',
    title: 'Haircut · Anna',
    start: '2026-10-22T09:00:00+02:00',
    end: '2026-10-22T10:00:00+02:00',
  },
  // All-day: dates, and the end is exclusive (19 and 20 October).
  { id: 'training', title: 'Staff training', start: '2026-10-19', end: '2026-10-21' },
]

export function TeamCalendar() {
  return (
    <HapioCalendar
      events={events}
      timeZone="Europe/Stockholm"
      nowIndicator
      className="h-[max(36rem,calc(100svh-17rem))]"
    />
  )
}

That is a complete calendar:

  • A toolbar with Today, Previous and Next (named "Previous Week", "Next Week" and so on by the view), the range's title, and a view switcher: a radio group named "Calendar view" with the radios "Week view", "Day view", "Month view" and "List view".
  • Four views (defaultViews): a week and a day time grid, a month grid and a week list. It opens on the week of today. Choose others with views and defaultView; see Views.
  • A height: the root has the height you give it (here with Tailwind's h-[…]; style works too), the toolbar included, and the view scrolls inside it. Without one the calendar grows to its content, and a time grid shows the whole day at full size.
  • A time zone: events are drawn in Europe/Stockholm wherever the browser is. Without timeZone the calendar uses the browser's zone, which only the browser knows: the server then renders a skeleton of the same size and the calendar appears after hydration. The zone alone does not make the server render complete: it takes an IANA timeZone and a date (defaultDate, or a controlled date) or now, because which week is "today" is the browser's to say too. Without one, as here, the toolbar renders on the server and the week appears after hydration. See Time zones and SSR and RSC.
  • Accessibility: Tab reaches the events as one stop (with navLinks, the day headers first), the arrow keys move between them in time order, and every event has a name (its visible time and title) and a description (the full date, time and resources). Navigation is announced. See Accessibility.

Events

An event needs an id and a start. Its end is exclusive and defaults to one hour later for a timed event and one day later for an all-day event. How a date is written decides what it means:

start / endMeaning
'2026-10-22T09:00:00+02:00', a Date, epoch msan exact instant
'2026-10-22T09:00[Europe/Stockholm]'an instant in a zone
'2026-10-22'an all-day date (never converted through a time zone)
'2026-10-22T09:00' (no offset)a wall time in the calendar's zone ("floating")

Temporal objects work as well (Instant, ZonedDateTime, PlainDate, PlainDateTime). Give an event href and it renders as a real link; give it color (any CSS color) and it is filled with it (contrastColor sets its text color; lists show the color as a dot). Events and data lists every field, accessor mode for your own objects, and event sources that load by range.

Props you will reach for

components/bookings-calendar.tsx
'use client'

import type { CalendarEvent, CalendarEventInput, EventClickArgs } from '@hapio/calendar-react'
import Link from 'next/link'
import { useState } from 'react'

import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'

const H24 = { hour: '2-digit', minute: '2-digit', hour12: false } as const

export function BookingsCalendar({ bookings }: { bookings: readonly CalendarEventInput[] }) {
  const [open, setOpen] = useState<CalendarEvent | null>(null)

  const onEventClick = (args: EventClickArgs<undefined>) => {
    // Ctrl/Cmd-click, Shift-click and the middle button open the link as the browser does.
    if (args.isModified) return
    args.preventDefault() // stay on the page instead of following `href`
    setOpen(args.event)
  }

  return (
    <>
      <HapioCalendar
        events={bookings}
        timeZone="Europe/Stockholm"
        locale="en-GB"
        weekStartsOn={1}
        formats={{ eventTime: H24, slotLabel: H24 }}
        navLinks
        nowIndicator
        scrollTime="07:00"
        businessHours
        linkComponent={Link}
        onEventClick={onEventClick}
        className="h-[max(36rem,calc(100svh-17rem))]"
      />
      {open === null ? null : <p>Selected: {open.title}</p>}
    </>
  )
}
  • locale (default 'en-US') formats dates and names; weekStartsOn (ISO: Monday is 1) defaults to the locale's week; formats takes Intl.DateTimeFormat options per part (here 24-hour times).
  • navLinks makes day headers link to the day view; nowIndicator draws the current time; scrollTime is where the time grid starts scrolled; businessHours shades the time outside Monday to Friday 09:00–17:00 (or the hours you give).
  • linkComponent renders event links with your router's link (Next's Link here).
  • onEventClick hears clicks and Enter on events. For an event with href, args.preventDefault() keeps the browser on the page; a modified click (isModified) is always left to the browser, so "open in a new tab" keeps working.

Every prop is listed in the HapioCalendar reference.

Next steps