Time zones and daylight saving time
Every calendar has its own zone; changing it re-renders without remounting, and DST days are drawn and edited to the minute.
A calendar shows one time zone, timeZone: an IANA name ('Europe/Stockholm'), 'UTC', or
'local', the browser's zone (the default). Events from anywhere are drawn at their wall time in
that zone, two calendars on a page can show two zones, and nothing reads or changes a global
zone.
Choosing the zone
- An explicit zone renders the same on the server and in every browser. Use it when your
data belongs to a place: a salon in Stockholm shows Stockholm time to a visitor in New York.
The server renders the whole calendar when it also has a date (
defaultDateordate) ornow; see SSR and RSC. 'local'is known only in the browser, so the server renders the block's skeleton (at the root's size) and the calendar appears after hydration. See SSR and RSC.
Changing timeZone is a plain prop update. Nothing remounts and there is no key to change:
'use client'
import type { CalendarEventInput } from '@hapio/calendar-react'
import { useId, useState } from 'react'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
const LOCATIONS = [
{ id: 'stockholm', name: 'Stockholm', timeZone: 'Europe/Stockholm' },
{ id: 'new-york', name: 'New York', timeZone: 'America/New_York' },
] as const
type LocationId = (typeof LOCATIONS)[number]['id']
export function LocationCalendar({ events }: { events: readonly CalendarEventInput[] }) {
const [locationId, setLocationId] = useState<LocationId>('stockholm')
const location = LOCATIONS.find((item) => item.id === locationId) ?? LOCATIONS[0]
const id = useId()
return (
<>
<label htmlFor={id}>Location</label>
<select
id={id}
value={location.id}
onChange={(event) => {
const next = LOCATIONS.find((item) => item.id === event.target.value)
if (next) setLocationId(next.id)
}}
>
{LOCATIONS.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
{/* A new zone is a plain prop update: no `key`, nothing remounts. */}
<HapioCalendar events={events} timeZone={location.timeZone} className="h-[36rem]" />
</>
)
}On a zone change the calendar reports the new range (onRangeChange, with the new timeZone),
event sources fetch it, and what belonged to the old zone ends: the selection is cleared, an open
"+N more" popover or actions menu closes, and a drag or keyboard move in progress is cancelled.
Zone names are normalized: legacy aliases map to current names (Asia/Calcutta is
Asia/Kolkata, Europe/Kiev is Europe/Kyiv) and case is fixed. Compare zones with
sameTimeZone(a, b), never ===; normalizeTimeZone(id) returns the current name (and throws a
RangeError for an unknown zone).
How dates are read
| Input | Meaning |
|---|---|
Date, epoch ms, Temporal.Instant, ISO with Z or an offset | an exact instant: drawn at its wall time in the calendar's zone |
Temporal.ZonedDateTime, ISO with [Zone] | an exact instant (its zone is kept for recurring series) |
Temporal.PlainDate, 'YYYY-MM-DD' | an all-day date: the same date in every zone, never converted |
Temporal.PlainDateTime, ISO without an offset | floating: that wall time in the calendar's zone, whatever the zone is |
A floating wall time that falls in a gap (it does not exist that day) or in a repeated hour (it
happens twice) is resolved as Temporal does by default, 'compatible': a skipped time moves
forward by the gap's length, a repeated one takes its first occurrence.
What the calendar gives back is always exact: callback spans and changes carry start and end
as Temporal.ZonedDateTime in the calendar's zone and startIso and endIso with each end's own
offset ('2026-10-25T01:30:00+02:00' to '2026-10-25T03:00:00+01:00'), and all-day ranges as
dates with an exclusive end.
Daylight saving time
The time grids are a wall-clock axis: 00:00 at the top, 24:00 at the bottom, every day. On the days the clocks change, the axis stays and the lane marks what is special:
- Spring forward (a 23-hour day): the hour that does not exist is a band (
data-gap) in that day's lane. A time inside it resolves to the instant after it, and the slot cursor and keyboard moves step over it. - Fall back (a 25-hour day): the hour that happens twice is a band (
data-repeated). Events of both passes are drawn in it as overlapping events (02:30 summer time and 02:30 winter time are an hour apart, at the same wall time). An event that ends at an earlier wall time than it starts (02:45 summer time to 02:15 winter time) is drawn one snap high and markeddata-dst-overlap. - Names say which pass: in the hour that happens twice, the slot cursor and the "Change time…" form name times with their offset, "02:30 (UTC+2)" and "02:30 (UTC+1)", so a screen reader user can tell them apart.
- Pointer and keyboard differ on purpose. A drop with the pointer lands on a wall time, which
in the repeated hour means its first pass (
'compatible'). Keyboard steps are elapsed time: ↓ from 02:30 summer time reaches 02:00 winter time, the second pass, so every instant can be reached from the keyboard.EventChange.deltareports both:ms(elapsed) anddays(wall days). - The reschedule form asks. "Change time…" offers a choice of offset for a wall time that happens twice, and says when a wall time does not exist that day and where the event will be instead.
- Floating events save wall times.
apply()writes a floating event's new times without an offset, as its input had them, and a wall time in the repeated hour reads as its first pass: a floating one-off event moved to the second 02:30 comes back from your data at the first. Write events whose place in that hour matters with an offset or a zone. (A floating recurring series writes such a time with its offset; see Recurrence.) - Days do not always start at 00:00. Where midnight is skipped (Santiago, Havana, Beirut, Cairo on their DST days) a day starts at its first instant, and a day a zone skipped entirely (Samoa's 30 December 2011) is a lane that is all gap.
See the week Europe leaves daylight saving time, with bookings in both 02:30s, in the daylight saving time example.
Recurring events and zones
A recurring series has its own zone (09:00 Stockholm stays 09:00 Stockholm when the calendar shows New York), so its occurrences can move against the calendar's hours around DST changes. See Recurrence.