Hapio Calendar

Navigation

Uncontrolled or controlled view and date, the URL, valid ranges, commands and a toolbar of your own.

The calendar keeps its view and date itself unless you control them. Either way, navigating is an ordinary React update: nothing remounts, and transitions work as with any state.

Uncontrolled

defaultView and defaultDate (a 'YYYY-MM-DD' date, a Temporal.PlainDate or 'today', the default) set where the calendar starts. They are read once, when it is created; later changes have no effect. Users then navigate with the toolbar, nav links (navLinks), and the keyboard: PageUp and PageDown move to the previous or next period from an event, a day cell or the slot cursor, and focus follows. Every navigation is announced ("Week of 19–25 October 2026").

validRange keeps navigation inside a range: { start: 'today' } allows no past periods, and { start: '2026-01-01', end: '2027-01-01' } one year. At its edges Previous, Next or Today say aria-disabled (they stay focusable), and the cells outside it in a month are empty.

Controlled

Pass view and date and the calendar shows them; each navigation calls onNavigate (and onViewChange or onDateChange) instead of changing them itself:

  • view is one of your view names; date is a date or 'today'. 'today' follows the clock: a calendar left open over midnight moves on to the new day.
  • onNavigate(change) reports view, date (a Temporal.PlainDate) and dateIso, includesToday (the new period contains today) and reason: 'today', 'prev', 'next', 'goto', 'view', 'nav-link' or 'keyboard'. After Previous and Next, date is the new period's first day.
  • Commands called on a controlled calendar (commands.next(), commands.changeView('day')) report through the same callbacks.

The view and date in the URL

With nuqs (or your router's search params), the URL is the state:

components/url-calendar.tsx
'use client'

import {
  isPlainDateString,
  type CalendarEventInput,
  type DefaultViews,
  type NavigateChange,
  type ViewName,
} from '@hapio/calendar-react'
import { parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'

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

type View = ViewName<DefaultViews>

const VIEWS = ['week', 'day', 'month', 'list'] as const satisfies readonly View[]
const URL_STATE = { view: parseAsStringLiteral(VIEWS), date: parseAsString }

export function UrlCalendar({ events }: { events: readonly CalendarEventInput[] }) {
  const [nav, setNav] = useQueryStates(URL_STATE, { history: 'replace' })

  // A hand-edited `?date=2026-02-30` is ignored: the calendar shows today.
  const date = nav.date !== null && isPlainDateString(nav.date) ? nav.date : 'today'

  const onNavigate = ({ view, dateIso, includesToday }: NavigateChange<View>) => {
    // The default view and a period with today in it keep the URL clean.
    void setNav({ view: view === 'week' ? null : view, date: includesToday ? null : dateIso })
  }

  return (
    <HapioCalendar
      view={nav.view ?? 'week'}
      date={date}
      onNavigate={onNavigate}
      events={events}
      timeZone="Europe/Stockholm"
      className="h-[max(36rem,calc(100svh-17rem))]"
    />
  )
}

Links, Back and Forward change the URL, and the calendar follows without remounting. isPlainDateString accepts exactly YYYY-MM-DD dates that exist, so a hand-edited URL cannot break the calendar. See it running in the URL sync example.

onNavigate or onRangeChange?

onNavigate tells you where the user went; onRangeChange tells you what the calendar shows, after it renders: the visible instants (start, end, startIso, endIso), the navigation period (currentStart, currentEnd), the view, the zone and the title. It fires once per range (deduplicated, StrictMode included) and again when a timeZone change makes a new range, so it is the one to fetch data with (Events and data). On the server and before the clock is known it waits until the range is.

Commands

commands are the calendar's actions, stable for its lifetime, safe to pass as handlers: today(), prev(), next(), goTo(date), changeView(view, date?), scrollToTime(time, { behavior }) (the active time grid), refetch(sourceId?), select(span | null), cancelInteraction() and announce(message, politeness?). Get them from useCalendarContext() inside the calendar (a toolbar node renders inside it), or from useCalendar when you compose the calendar yourself.

A toolbar of your own

toolbar={false} hides the toolbar (the block keeps the period's title as a visually hidden heading, so the grids stay named); a node replaces it. useToolbar() gives the title, the labels and getters that make the buttons behave and sound right:

components/bookings-toolbar.tsx
'use client'

import { useCalendarContext, useToolbar } from '@hapio/calendar-react'
import { ChevronLeft, ChevronRight } from 'lucide-react'

import { CalendarViewSwitcher } from '@/components/hapio-calendar/calendar-view-switcher'
import { Button } from '@/components/ui/button'

/** Rendered inside the calendar (`toolbar={<BookingsToolbar />}`), so it reads its context. */
export function BookingsToolbar() {
  const toolbar = useToolbar()
  const { commands } = useCalendarContext()
  return (
    <div className="flex flex-wrap items-center gap-2 print:hidden">
      {/* Icon buttons: the getters' `title` ("Previous Week") is their name. */}
      <Button {...toolbar.getPrevButtonProps()} type="button" variant="ghost" size="icon-sm">
        <ChevronLeft aria-hidden className="rtl:rotate-180" />
      </Button>
      <h2 {...toolbar.getTitleProps({ className: 'text-lg font-semibold' })}>{toolbar.title}</h2>
      <Button {...toolbar.getNextButtonProps()} type="button" variant="ghost" size="icon-sm">
        <ChevronRight aria-hidden className="rtl:rotate-180" />
      </Button>
      <Button {...toolbar.getTodayButtonProps()} type="button" variant="outline" size="sm">
        {toolbar.todayText}
      </Button>
      <Button type="button" variant="outline" size="sm" onClick={() => commands.refetch()}>
        Refresh
      </Button>
      <CalendarViewSwitcher className="ms-auto" />
    </div>
  )
}
  • getTitleProps() gives the title its id: the grids are labelled by it, so keep it rendered (visually hidden, if your toolbar shows no title).
  • getPrevButtonProps() and getNextButtonProps() set title ("Previous Week"), which names an icon-only button; Today's title says "This week". At the edges of validRange they set aria-disabled, never disabled, so focus is never lost.
  • The view switcher (getViewSwitcherProps, getViewOptionProps) is a radio group: one tab stop, and the arrow keys, Home and End switch views. The registry's CalendarViewSwitcher is that, built with your Button.