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:
viewis one of your view names;dateis a date or'today'.'today'follows the clock: a calendar left open over midnight moves on to the new day.onNavigate(change)reportsview,date(aTemporal.PlainDate) anddateIso,includesToday(the new period contains today) andreason:'today','prev','next','goto','view','nav-link'or'keyboard'. After Previous and Next,dateis 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:
'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:
'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 itsid: the grids are labelled by it, so keep it rendered (visually hidden, if your toolbar shows no title).getPrevButtonProps()andgetNextButtonProps()settitle("Previous Week"), which names an icon-only button; Today'stitlesays "This week". At the edges ofvalidRangethey setaria-disabled, neverdisabled, 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'sCalendarViewSwitcheris that, built with yourButton.