# Calendar

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

Status: beta. Added in Components 2.16.0.

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

## Import

```tsx
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.

```tsx
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>
    </div>
  )
}

render(<Default />)
```

### 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.

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

### States

```tsx
{/* 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](https://ticketsolutionsptyltd.github.io/roadie/components/date-range-picker.md).

```tsx
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} />
      <p className='text-sm text-subtle'>
        {range.start ?? '…'} to {range.end ?? '…'}
      </p>
    </div>
  )
}

render(<Range />)
```

### With several dates

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

```tsx
<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.

```tsx
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.

```tsx
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
      defaultMonth='2026-11-01'
      modifiers={{ hasSession: sessions, soldOut }}
      disabled={[(date) => !sessions.includes(date), ...soldOut]}
      className='**:data-has-session:font-bold **:data-sold-out:line-through'
    />
    <p className='text-sm text-subtle'>Bold nights have a show. Struck-through nights are sold out.</p>
  </div>
)
```

### 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.

```tsx
<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.

```tsx
<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.

```tsx
<ScrollArea className='h-96 rounded-xl emphasis-raised'>
  <ScrollArea.Viewport>
    <Calendar
      layout='scroll'
      mode='range'
      defaultSelected={{ start: '2026-11-06', end: '2026-11-12' }}
      className='px-4 **:data-[slot=calendar-weekdays]:-mx-4 **:data-[slot=calendar-weekdays]:px-4'
    />
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
</ScrollArea>
```

### 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.

```tsx
<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.

```tsx
function Week() {
  const [date, setDate] = useState(null)
  return (
    <div className='grid gap-4'>
      <Calendar
        defaultView='week'
        views={['week', 'month']}
        defaultMonth='2026-11-01'
        selected={date}
        onSelect={setDate}
      />
      <p className='text-sm text-subtle'>Selected: {date ?? 'none'}</p>
    </div>
  )
}

render(<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.

```tsx
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 },
  '2026-11-04': { price: 29, left: 4 },
  '2026-11-05': { price: 29, left: 2 },
  '2026-11-06': { price: 35, left: 1 },
  '2026-11-07': { soldOut: true },
  '2026-11-08': { price: 35, left: 4 },
  '2026-11-11': { price: 29, left: 4 },
  '2026-11-12': { price: 29, left: 3 },
  '2026-11-13': { price: 39, left: 4 },
  '2026-11-14': { price: 39, left: 4 },
  '2026-11-15': { price: 39, left: 2 }
}

function nightContent(date) {
  const night = nights[date]
  if (!night) return null
  if (night.soldOut) return 'Sold out'
  return (
    <>
      <span>${night.price}</span>
      <span aria-hidden='true' className='flex gap-0.5'>
        {Array.from({ length: night.left }, (_, i) => (
          <span key={i} className='size-1 rounded-full bg-current' />
        ))}
      </span>
      <span className='sr-only'>
        {night.left === 1 ? '1 session left' : `${night.left} sessions left`}
      </span>
    </>
  )
}

function Sessions() {
  const [date, setDate] = useState(null)
  return (
    <div className='grid max-w-md gap-4'>
      <Calendar
        views={['week', 'month']}
        defaultMonth='2026-11-01'
        selected={date}
        onSelect={setDate}
        disabled={(date) => !nights[date] || nights[date].soldOut}
        getDayContent={nightContent}
      />
      <p className='text-sm text-subtle'>
        Ember Galah Ball at Kazoo Hollow Room. Dots show sessions left.
      </p>
    </div>
  )
}

render(<Sessions />)
```

## 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

**Do**

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

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

**Don’t**

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

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

**Do**

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

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

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

- `autoFocus`: `boolean`. Focus the day holding the tab stop when the calendar mounts.
- `disabled`: `boolean | CalendarMatchers`. Days that can be focused but not chosen. `true` disables every day and the month navigation.
- `modifiers`: `Record<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.
- `numberOfMonths`: `number`. Defaults to `1`. Months shown at once, side by side where they fit and stacked where they don't. Paged only.
- `layout`: `"paged" | "scroll"`. Defaults to `'paged'`. `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.
- `direction`: `"horizontal" | "vertical"`. Defaults to `'horizontal'`. 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.
- `view`: `"month" | "week"`. Defaults to `'month'`. `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`.
- `defaultView`: `"month" | "week"`. Defaults to `'month', or the first of `views` when it leaves month out`. The view shown first when uncontrolled.
- `onViewChange`: `((view: "month" | "week") => void)`. Called with the new view when the reader switches it.
- `views`: `readonly ("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"`. Defaults to `'label'`. `dropdown` swaps the month name for month and year selects. Paged only.
- `fixedWeeks`: `boolean`. Always show six weeks, so the calendar keeps its height.
- `showOutsideDays`: `boolean`. Show the days of the months either side that fill the first and last weeks.
- `weekStart`: `number`. Defaults to `1`. First day of the week: 1 is Monday and 7 is Sunday.
- `month`: `string`. The first month shown, as any ISO date in it. Pair with `onMonthChange`.
- `defaultMonth`: `string`. 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.
- `startMonth`: `string`. The earliest month navigation reaches, as any ISO date in it.
- `endMonth`: `string`. The latest month navigation reaches, as any ISO date in it.
- `today`: `string`. Today as an ISO date. Defaults to today in `timeZone`.
- `timeZone`: `string`. IANA zone used only to work out today. Defaults to the viewer's zone.
- `locale`: `string`. Defaults to `'en-AU'`.
- `mode`: `"single" | "multiple" | "range"`. Defaults to `'single'`.
- `selected`: `string | 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`.
- `defaultSelected`: `string | 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.
- `required`: `boolean`. Keep the day, or the last of several days, chosen when it is pressed again.
- `min`: `number`. The fewest days a range can span, both ends counted.
- `max`: `number`. The most days a range can span, both ends counted.

Docs updated 18 Oct 2026.
