ChronousDocumentation

Guides

Navigation

Next, previous, today and a view switch — a pure reducer, a hook over it and a toolbar around both.

The hook

useCalendarNavigation(range) returns the ranges to move to, and never sets state itself. Where the range lives is your decision — a router, a query string or useState all work the same.

tsx
import { useCalendarNavigation } from '@midstem/chronous-react'
import type { CalendarRange } from '@midstem/chronous-react'
import { useState } from 'react'

const INITIAL: CalendarRange = {
  view: 'week',
  currentDate: '2026-03-18',
  timeZone: 'Europe/Kyiv',
}

export const Controls = () => {
  const [range, setRange] = useState<CalendarRange>(INITIAL)
  const { next, prev, today, withView } = useCalendarNavigation(range)

  return (
    <nav>
      <button disabled={!prev} onClick={() => prev && setRange(prev)}>
        Back
      </button>
      <button disabled={!today} onClick={() => today && setRange(today())}>
        Today
      </button>
      <button disabled={!next} onClick={() => next && setRange(next)}>
        Forward
      </button>
      <button onClick={() => setRange(withView('month'))}>Month</button>

      <output>
        {range.view} · {range.currentDate}
      </output>
    </nav>
  )
}
  • A step moves by the period the range asks for: a day by one day, a week by seven, a span by its own length, and a month by one month anchored on the first — so a long month never drags the anchor backwards.
  • The weekday of the anchor survives a week step, which is what makes switching to day afterwards land where the reader was looking.
  • today is a function rather than a value because it depends on the wall clock: it is read at the click, in the calendar’s own zone.

The toolbar

Calendar.Toolbar wraps the hook and reports where to move to through onNavigate. The same function reaches its render prop as goTo, alongside a formatted title, so a toolbar of your own is a few buttons and nothing else.

tsx
import { Calendar } from '@midstem/chronous-react'
import type { CalendarRange, EventInput } from '@midstem/chronous-react'
import { useState } from 'react'

const INITIAL: CalendarRange = {
  view: 'week',
  currentDate: '2026-03-18',
  timeZone: 'Europe/Kyiv',
}

const events: EventInput[] = [
  { id: 'standup', start: '2026-03-18T09:00', duration: 'PT30M' },
]

export const Board = () => {
  const [range, setRange] = useState<CalendarRange>(INITIAL)

  return (
    <Calendar.Root range={range} events={events} locale="en-GB">
      <Calendar.Toolbar
        className="toolbar"
        onNavigate={setRange}
        views={['week', 'month']}
      >
        {({ title, navigation, goTo }) => (
          <>
            <button
              disabled={!navigation.prev}
              onClick={() => navigation.prev && goTo(navigation.prev)}
            >
              ←
            </button>
            <strong>{title}</strong>
            <button
              disabled={!navigation.next}
              onClick={() => navigation.next && goTo(navigation.next)}
            >
              →
            </button>
          </>
        )}
      </Calendar.Toolbar>

      <Calendar.TimeGrid hourHeight={48}>
        <Calendar.DayColumns className="column">
          <Calendar.TimeSlots className="line" />
          <Calendar.TimedEvents className="event" />
        </Calendar.DayColumns>
      </Calendar.TimeGrid>
    </Calendar.Root>
  )
}

Without React

Moving around a calendar is a reducer over the range, so next and previous can be tested without rendering anything. The state is a range and a selection and nothing else, and every action returns a new state — or the state it was handed when the move changes nothing, so a consumer can compare by identity.

typescript
import { calendarReducer, initialCalendarState } from '@midstem/chronous'

const state = initialCalendarState({
  view: 'week',
  currentDate: '2026-08-25',
  timeZone: 'Europe/Kyiv',
})

console.log(calendarReducer(state, { type: 'next' }).range.currentDate)
// '2026-09-01'
console.log(calendarReducer(state, { type: 'prev' }).range.currentDate)
// '2026-08-18'
console.log(calendarReducer(state, { type: 'view', view: 'month' }).range.view)
// 'month'
console.log(
  calendarReducer(state, { type: 'today', now: '2026-09-03T10:00:00Z' })
    .range.currentDate,
)
// '2026-09-03' — read in Europe/Kyiv, never off the host clock

console.log(
  calendarReducer(state, {
    type: 'select',
    selection: { kind: 'event', id: 'standup' },
  }).selection,
)
// { kind: 'event', id: 'standup' }
Actions
type CalendarAction =
  | { type: 'next' }
  | { type: 'prev' }
  | { type: 'today'; now: IsoDateTime }
  | { type: 'goto'; date: IsoDate }
  | { type: 'view'; view: ViewKind }
  | { type: 'select'; selection: CalendarSelection }
  | { type: 'clear' }

today carries the moment rather than reading the clock, which is what keeps the reducer pure. Everything buildCalendar would refuse, the reducer refuses the same way.