RoadieRoadie
  • Home
  • Brand
  • Foundations
  • Content
  • Tokens
  • Components
  • Charts
  • Widgets
  • Appearance
HomeBrandFoundations
Appearance

Components

  • Navigation

    • Breadcrumb
    • LinkBeta
    • NavigatorAlpha
    • Steps
    • Tabs
  • Layout

    • Accordion
    • CollapsibleBeta
    • PaneAlpha
    • ScrollArea
    • Separator
  • Actions

    • Button
    • IconButton
    • ToggleBeta
    • Toggle groupBeta
    • ToolbarBeta
  • Fields

    • EditorAlpha
    • Field
    • Fieldset
    • Input
    • Label
    • Number fieldBeta
    • OTP fieldBeta
    • Query fieldAlpha
    • Textarea
  • Choices

    • Autocomplete
    • CheckboxBeta
    • Combobox
    • Radio group
    • Select
    • SliderBeta
    • SwitchBeta
  • Date and time

    • CalendarBeta
    • CalendarTile
    • Countdown
    • Dashboard periodBeta
    • Date fieldBeta
    • Date pickerBeta
    • Date range pickerBeta
    • DateTime
    • Duration
    • Time fieldBeta
  • Collections

    • Card
    • Carousel
    • DataTableBeta
    • ListAlpha
    • Marquee
    • RecordGridAlpha
    • RecordTableAlpha
    • SortableAlpha
    • TableBeta
  • Overlays

    • Dialog
    • Drawer
    • MenuBeta
    • Popover
    • ToastBeta
    • Tooltip
  • Status

    • Badge
    • CalloutBeta
    • DeltaBeta
    • Empty state
    • MeterBeta
    • ProgressBeta
    • SkeletonBeta
    • SparklineBeta
    • StatTileBeta
  • Media & brand

    • AvatarBeta
    • Icon Tile
    • Image
    • Logo
    • PatternBeta
    • QR codeBeta
    • SpotIllustration
  • Text

    • Code
    • Highlight
    • KbdBeta
    • Mark
    • Prose

Calendar

Status
Beta
Added
Components 2.16.020 Oct 2026
Docs updated
18 Oct 2026

A month or week grid for choosing a date, several dates or a range.

Import

import { Calendar } from '@oztix/roadie-components/calendar'

Examples

Default

Calendar opens on the selected month, or on today's. Every value is a plain ISO date string such as '2026-11-27', never a Date. Today moves on at midnight in timeZone. Server HTML may be read on a later day, so the server leaves today unmarked, and a calendar with nothing else to place its month renders an empty six-week frame until the browser fills it in. Pass today, month, defaultMonth or a selection to render it in full on the server.

The month's name sits at the start of the header and both arrows sit at the end. On a touch screen, swipe the days sideways to turn the month, or drag them with a mouse or pen; a press that barely moves still chooses the day. The days follow the finger with the next month's days coming in beside them, while the weekday row and the month's name stay still, and the swipe runs the other way in a right-to-left page. With several months in a row, their days move as one strip. On a vertical calendar the next month's name comes up with its days, in the gap above them. The arrows and Page Up and Page Down slide the days the same way. The shown and incoming pages move together in one motion, and the month and its name change as the slide ends. With Shift, or in week view, Page Up and Page Down skip past the page beside, so they turn at once. A month picked from the selects shows at once and slides in. Arrow keys that cross into the next month turn it straight away, so focus never sits on a day that is sliding. When the reader prefers reduced motion, every turn is instant.

function Default() {
const [date, setDate] = useState(null)
return (
<div className='grid gap-4'>
<Calendar selected={date} onSelect={setDate} />
<p className='text-sm text-subtle'>Selected: {date ?? 'none'}</p>

Variants

captionLayout='dropdown' swaps the month name for month and year selects, for dates far from today such as a date of birth. startMonth and endMonth limit how far either way they reach.

<Calendar
captionLayout='dropdown'
defaultMonth='1990-06-01'
startMonth='1920-01-01'
endMonth='2010-12-31'
/>

States

{/* Selected */}
<Calendar defaultMonth='2026-11-01' defaultSelected='2026-11-27' />
{/* Disabled */}
<Calendar defaultMonth='2026-11-01' disabled />

With a range

mode='range' takes { start, end }. The first press sets start and leaves end null, and the days between preview as the pointer or focus moves. min and max limit the length in days, both ends counted. Days that would break them dim. Pressing one keeps the start and announces the rule, such as "Ranges can be up to 14 days", so say the rule in visible text near the calendar too. Pressing the start again makes a one-day range, or drops the start when min is more than 1, and Escape drops it. For a range in a form or a toolbar, with presets and typed dates, use DateRangePicker.

function Range() {
const [range, setRange] = useState({ start: null, end: null })
return (
<div className='grid gap-4'>
<p className='text-sm text-subtle'>Choose up to 14 days.</p>
<Calendar mode='range' max={14} selected={range} onSelect={setRange} />

With several dates

mode='multiple' takes a list of dates and keeps it in order.

<Calendar
mode='multiple'
defaultMonth='2026-11-01'
defaultSelected={['2026-11-06', '2026-11-13', '2026-11-20']}
/>

With past dates disabled

disabled takes matchers: a date, a list of dates, { start, end }, { before }, { after }, { dayOfWeek } with 1 for Monday, or a function of the date. Disabled days can still be focused, so keyboard users can move past them.

const today = plainDateOf(new Date(), viewerTimeZone())
render(<Calendar disabled={{ before: today }} showOutsideDays />)

With modifiers

modifiers names sets of days with the same matchers. Each name becomes a data attribute on its days, so hasSession renders data-has-session to style. Here Lampshade Disco at Kazoo Hollow Room runs on the bold nights, and the struck-through night is sold out.

const sessions = ['2026-11-05', '2026-11-06', '2026-11-07', '2026-11-12', '2026-11-13', '2026-11-14']
const soldOut = ['2026-11-07']
render(
<div className='grid gap-4'>
<Calendar

With a preview range

previewRange draws days as a neutral band, shaped like a chosen range but not chosen. Use it for dates someone has typed but not yet taken, as the records search does. A chosen range draws over it.

<Calendar
mode='range'
defaultMonth='2026-11-01'
previewRange={{ start: '2026-11-09', end: '2026-11-18' }}
/>

With two months

numberOfMonths shows months side by side where they fit and stacks them where they don't. Both arrows sit together at the top end, and each moves the set one month. Side by side, the months turn sideways and their days move as one strip. Stacked, they turn up and down as one column, as with direction='vertical': the arrows point up and down, and a finger swipes up for the next month. The first month's name sits by the arrows, one weekday row holds still beneath them, and the months below it slide with their names. The calendar switches back as soon as the months fit side by side again.

<Calendar mode='range' numberOfMonths={2} />

With scrolling months

layout='scroll' stacks the months in a list that scrolls, as on a phone, under one row of weekdays that stays pinned. There are no arrows: it opens on month, the selection or today, and adds months as it nears either end or the keyboard moves past them. A new month scrolls the list to it, and in a box that scrolls onMonthChange reports the month at the top as it scrolls. Put it in a ScrollArea, as here, or in a Drawer or Pane body, which already scroll, rather than a box with its own native scrollbar. To run the pinned row to the edges, pad the calendar and pull its calendar-weekdays slot out by the same amount, as here. Pickers use it in their phone drawer.

<ScrollArea className='h-96 rounded-xl emphasis-raised'>
<ScrollArea.Viewport>
<Calendar
layout='scroll'
mode='range'
defaultSelected={{ start: '2026-11-06', end: '2026-11-12' }}

With vertical paging

direction='vertical' turns the months up and down: the arrows point up and down, and a finger swipes up for the next month. Over the days, a finger no longer scrolls the page up and down, so use it only where the calendar has the screen to itself. For one continuous list of months, use layout='scroll' instead.

<Calendar direction='vertical' defaultMonth='2026-11-01' />

With a week view

view='week' shows one week as a row of larger days, and the arrows and a swipe turn a week at a time. It suits choosing a session in the coming days. views lists the views the reader can switch between. With both, a toggle group of "Week" and "Month" sits beside the title, or above the months when there are several. Where the calendar is narrow, as on a phone, it shows only their icons and the week's title shortens its month names, such as "Oct to Nov 2026". Switching eases between the two: the week's days glide between their places, the other weeks grow out of it or fold into it as they fade, and the height follows. With reduced motion it switches at once. Month view opens on the month of the selected day (or a range's start) in the week, then the focused day's, then the month holding most of the week. Week view opens on the week of the selected or focused day in the month, today's, or else its first week. Use defaultView to open on one, or view and onViewChange to control it.

function Week() {
const [date, setDate] = useState(null)
return (
<div className='grid gap-4'>
<Calendar
defaultView='week'

With day content

getDayContent adds content under a day's number, such as a price or how many sessions are left, in the month and the week alike. Return null for a plain day, not an empty element. Every day becomes a tile so the columns line up, days with content are filled, and tiles grow to fit, so a long line wraps rather than spills. Tiles stay up to 64px wide, or 80px in a week. A disabled day with content is struck through, so here sold-out nights read as unavailable while nights outside the run stay plain. The content describes the day to screen readers, so give a mark like a dot some words too.

const nights = {
'2026-10-28': { price: 29, left: 2 },
'2026-10-29': { price: 29, left: 4 },
'2026-10-30': { soldOut: true },
'2026-10-31': { price: 35, left: 1 },
'2026-11-01': { price: 35, left: 3 },

Guidelines

  • It fills its container. The seven columns share the width, each day stays a circle up to 48px across, or a tile up to 64px with getDayContent, in the middle of its column, and a range's band runs from edge to edge. In a popover, it takes 280px a month. Set the width on the container, not the calendar.

Pass plain dates

<Calendar selected='2026-11-27' />

Do

Pass ISO date strings. A plain date names the same day in every time zone.

<Calendar selected={new Date('2026-11-27')} />

Don’t

Don't pass a Date. It is an instant, so it can land on the day before or after in another zone.

Disable days, rather than hide them

<Calendar disabled={{ before: today }} />

Do

Disable days that can't be chosen, so the grid keeps its shape and keyboard users can move past them.

Don’t

Don't style a day to look unavailable without disabling it. It still selects.

Give day content words

Do

Pair a mark, such as a dot for each session left, with hidden text like "2 sessions left". The content describes the day to screen readers.

Don’t

Don't put content in every day to make the calendar look full. Without getDayContent, a month keeps compact circles, and filled tiles are for days that have something to say.

Say what a modifier means

Do

Explain a styled modifier in text near the calendar, such as "Bold nights have a show". Modifiers are styling only, and screen readers don't hear them.

Don’t

Don't rely on colour alone to mark a modifier. Change the weight, add a mark or strike the day through.

Accessibility

  • Each month is a grid named by its caption, such as "November 2026", and is aria-multiselectable in multiple and range modes. Each day is a button inside a gridcell, named by its full date with today and selection added, such as "Today, Friday, 27 November 2026, selected". Selected days also set aria-selected on the cell, and today has aria-current='date'.
  • One day holds the tab stop: the focused day, then the selected day, then today. Focus moves separately from selection, so moving through the grid selects nothing.
  • Arrow keys move by a day or a week. Page Up and Page Down move by a month, and with Shift by a year. Home and End move to the start and end of the week. Focus crosses into the next or previous month and the calendar follows, so no day is skipped.
  • Enter and Space select the focused day. While a range is half chosen, Escape drops its start.
  • Disabled days are focusable with aria-disabled, so they can be read and passed, but not selected.
  • A polite live region announces the month when it changes and the selection when it changes, such as "Selected Wednesday, 3 to Sunday, 7 March 2027".
  • The month arrows are labelled "Previous month" and "Next month", and in week view "Previous week" and "Next week". In the dropdown layout the selects are labelled "Month" and "Year". The views are a toggle group labelled "View", with "Week" and "Month" buttons that keep their names when they show only icons. The arrow keys move between them and Space or Enter switches.
  • Swiping or dragging the days turns the page, and the arrows and Page Up and Page Down do the same for everyone else, with the same slide. Arrow keys across a month edge turn it without a slide, so focus stays on a still day. With reduced motion nothing slides.
  • In week view the grid is named by its dates, such as "Monday, 26 October to Sunday, 1 November 2026", and turning a week announces them.
  • getDayContent describes its day: a day with a price is read as its date, then the price.
  • With layout='scroll', each month keeps its own column headers for screen readers, and the pinned weekday row is hidden from them.

API reference

Calendar

disabledboolean | CalendarMatchers

Days that can be focused but not chosen. `true` disables every day and the month navigation.

modifiersRecord<string, CalendarMatchers>

Named sets of days. Each name becomes a data attribute on its days, so `hasSession` renders `data-has-session` for styling.

previewRange{ start: string; end: string; } | null

Days to draw as a neutral band, shaped like a chosen range but not chosen, such as dates typed but not yet taken. One day for a single day.

numberOfMonthsnumber

Months shown at once, side by side where they fit and stacked where they don't. Paged only.

Defaults to 1.

layout"paged" | "scroll"

`paged` turns the months with arrows. `scroll` stacks them in a list that scrolls, under one pinned row of weekdays, adding months as it nears either end. It opens on `month`, the selection or today, scrolls to `month` when it changes, and in a box that scrolls calls `onMonthChange` with the month at the top as it scrolls. Put it in a `ScrollArea`, or a `Drawer` or `Pane` body, which already scroll.

Defaults to 'paged'.

direction"horizontal" | "vertical"

The way a paged calendar turns: its arrows point this way and a finger swipes this way. Vertical takes up and down swipes over the days, so the page can't be scrolled from there. `layout='scroll'` always runs down the page.

Defaults to 'horizontal'.

view"month" | "week"

`month` shows whole months. `week` shows one week in a row of larger days that turns a week at a time. Paged only. Pair with `onViewChange`.

Defaults to 'month'.

defaultView"month" | "week"

The view shown first when uncontrolled.

Defaults to 'month', or the first of `views` when it leaves month out.

onViewChange((view: "month" | "week") => void)

Called with the new view when the reader switches it.

viewsreadonly ("month" | "week")[]

The views the reader can switch between. With both, a "Week" and "Month" toggle group sits beside the title, or above several months, showing only its icons where the calendar is narrow. A `defaultView` outside the list gives way to its first view. Paged only.

getDayContent((date: string) => ReactNode)

Extra content under a day's number, such as a price or a status mark. Return null, not an empty element, for a plain day. Every day becomes a tile so the columns line up, and tiles grow to fit. Content fills its tile and describes the day to screen readers. A disabled day with content is struck through.

captionLayout"label" | "dropdown"

`dropdown` swaps the month name for month and year selects. Paged only.

Defaults to 'label'.

fixedWeeksboolean

Always show six weeks, so the calendar keeps its height.

showOutsideDaysboolean

Show the days of the months either side that fill the first and last weeks.

weekStartnumber

First day of the week: 1 is Monday and 7 is Sunday.

Defaults to 1.

monthstring

The first month shown, as any ISO date in it. Pair with `onMonthChange`.

defaultMonthstring

The month shown first when uncontrolled. Defaults to the selection, then today.

onMonthChange((month: string) => void)

Called with the first of the month when the shown month changes.

startMonthstring

The earliest month navigation reaches, as any ISO date in it.

endMonthstring

The latest month navigation reaches, as any ISO date in it.

todaystring

Today as an ISO date. Defaults to today in `timeZone`.

timeZonestring

IANA zone used only to work out today. Defaults to the viewer's zone.

localestring

Defaults to 'en-AU'.

mode"single" | "multiple" | "range"

Defaults to 'single'.

selectedstring | readonly string[] | CalendarDateRange | null

The chosen day, as an ISO date. In `multiple` mode a list of dates, and in `range` mode `{ start, end }`, with `end` null until the second press. Pair with `onSelect`.

defaultSelectedstring | readonly string[] | CalendarDateRange | null

The selection to start from when uncontrolled.

onSelect((selected: string | null) => void) | ((selected: string[]) => void) | ((selected: CalendarDateRange) => void)

Called with the new selection, in the shape `selected` takes.

requiredboolean

Keep the day, or the last of several days, chosen when it is pressed again.

minnumber

The fewest days a range can span, both ends counted.

maxnumber

The most days a range can span, both ends counted.

Inherited from HTMLAttributes

autoFocusboolean

Focus the day holding the tab stop when the calendar mounts.

Previous page← SwitchNext pageCalendarTile →