# Calendar (/docs/calendar)





`Calendar` is the inline month grid — Ark's `date-picker` machine rendered `inline` (no
trigger/popover), so it's always visible rather than living behind a field. It renders the same
grid module [`DatePicker`](/docs/date-picker) will embed in its popover once that lands. The
value is a native `Date | null` (or `{ start, end }` under `range`) — Ark's internal `DateValue`
representation never leaks out.

```tsx
import { Calendar } from '@42-central/ui-react/calendar';

<Calendar value={date} onChange={setDate} />
```

`Calendar` is a bare widget, like [`Table`](/docs/table) — there's no built-in `label` /
`description` / `error`. A [`DatePicker`](/docs/date-picker) popover (trigger + field wrapper) is
where that belongs.

## Playground [#playground]

<story.Playground />

## Range & numOfMonths [#range--numofmonths]

`range` selects a `{ start, end }` pair instead of a single date — click once for the start,
again for the end. `numOfMonths` shows that many months side by side; it defaults to `1` regardless
of `range` — pass `numOfMonths={2}` to show a full range without paging.

<story.Preview variant="Range" />

```tsx
import { useState } from 'react';
import { Calendar, type DateRangeValue } from '@42-central/ui-react/calendar';

const [range, setRange] = useState<DateRangeValue>({ start: null, end: null });

<Calendar range numOfMonths={2} value={range} onChange={setRange} />
```

## Restricting selection [#restricting-selection]

`isDateUnavailable` takes a plain predicate over a native `Date` — greyed-out, `aria-disabled`
days the user can't pick. `min` / `max` bound the selectable range the same way.

<story.Preview variant="Restricted" />

```tsx
<Calendar isDateUnavailable={(date) => date.getDay() === 0 || date.getDay() === 6} />
```

`@42-central/ui-react/date-picker` re-exports a set of composable predicate helpers — `disablePast()`,
`disableFuture()`, `disableWeekends()`, `beforeDate(d)`, `afterDate(d)`, `disableDates([...])`,
and `anyOf(...)` / `allOf(...)` to combine them — built on the exact same conversion boundary
`Calendar` uses internally.

The predicate's second argument is the [time zone](#locale--time-zone) this `Calendar` resolved,
so a day is read in the same zone the grid labels it in — `timeZone="UTC"` disables UTC
Saturdays wherever the code runs, not whichever days were weekends on the machine that rendered
it. The helpers above take it from there automatically; reach for it in your own predicate too
rather than `date.getDay()` whenever you set `timeZone`:

```tsx
import { anyOf, disablePast, disableWeekends } from '@42-central/ui-react/date-picker';

<Calendar timeZone="UTC" isDateUnavailable={anyOf(disablePast(), disableWeekends())} />
```

It's optional — omitted (a predicate called on its own, outside a `Calendar`/`DatePicker`), it
falls back to the host zone.

## Event dots [#event-dots]

`renderDay(date, { isUnavailable, isSelected })` decorates a specific day's cell — draw a dot,
badge, or any marker from your own event data. It receives a native `Date`, converted from Ark's
internal value at the same `timeZone` boundary the rest of the component uses.

<story.Preview variant="Event dots" />

```tsx
<Calendar
  renderDay={(date) =>
    hasEvent(date) ? <span className="mt-0.5 block size-1 rounded-full bg-(--c-solid)" /> : null
  }
/>
```

## Month/year navigation [#monthyear-navigation]

Click the header label to cycle the view — day → month → year — then pick a year, a month, and
land back on a day grid. `MonthSelect` / `YearSelect` dropdowns in the day view offer a
quicker direct jump (shown only when a single month is visible). `minView` / `maxView` bound
the levels reachable this way, e.g. `minView="month"` for a picker that never drills into
individual days.

```tsx
<Calendar minView="month" maxView="year" />
```

## Week numbers [#week-numbers]

<story.Preview variant="Week numbers" />

```tsx
<Calendar withWeekNumbers />
```

## Fixed height [#fixed-height]

Months naturally span 4–6 week rows depending on how the days line up, so the grid's height can
shift while paging between months. `fixedWeeks` always renders 6 rows (padding with the
adjacent month's days) to keep the height constant.

<story.Preview variant="Fixed weeks" />

```tsx
<Calendar fixedWeeks />
```

## Sizes [#sizes]

Five sizes — `xs` through `xl` — matching the rest of the family.

<Preview align="start" code="&#x22;<Calendar size=\&#x22;xs\&#x22; defaultValue={new Date()} />\n<Calendar size=\&#x22;sm\&#x22; defaultValue={new Date()} />\n<Calendar size=\&#x22;md\&#x22; defaultValue={new Date()} />\n<Calendar size=\&#x22;lg\&#x22; defaultValue={new Date()} />\n<Calendar size=\&#x22;xl\&#x22; defaultValue={new Date()} />&#x22;">
  <div className="flex flex-col items-start gap-4">
    <Calendar size="xs" defaultValue="new Date()" />

    <Calendar size="sm" defaultValue="new Date()" />

    <Calendar size="md" defaultValue="new Date()" />

    <Calendar size="lg" defaultValue="new Date()" />

    <Calendar size="xl" defaultValue="new Date()" />
  </div>
</Preview>

## Controlled [#controlled]

Drive the value from state. `onChange` hands you the next `Date | null` (or `DateRangeValue`
under `range`).

```tsx
'use client';
import { useState } from 'react';
import { Calendar } from '@42-central/ui-react/calendar';

export function EvaluationDate() {
  const [date, setDate] = useState<Date | null>(null);
  return <Calendar value={date} onChange={setDate} />;
}
```

## Locale & time zone [#locale--time-zone]

`locale` overrides the active locale context (month/day names, first-day-of-week); `timeZone`
overrides the time zone used for every `Date ↔ DateValue` conversion at the boundary. Both fall
back to an [`IntlProvider`](/docs/intl) above the calendar, so one provider at the app root sets
them for every date component at once. With neither, the time zone follows the host — not Ark's
own machine default of `"UTC"`.

```tsx
<IntlProvider timeZone="Pacific/Auckland">
  <Calendar /> {/* days resolve in Auckland */}
  <Calendar timeZone="UTC" /> {/* the prop wins over the provider */}
</IntlProvider>
```

When the zone changes while the calendar is mounted, the selection keeps its instant: the same
`Date`, shown in the new zone, so near midnight it can move to the next or previous day. That is
not an edit, so `onChange` doesn't fire, and focus stays where it was. `DatePicker` behaves the
same way.

## Translations [#translations]

`locale` drives month/day names and first-day-of-week automatically, but Ark's own built-in
accessible names (day cells, prev/next, the view-cycling header, week numbers) are English by
default — `translations` overrides them. It's partial: unset keys keep Ark's own English text.

```tsx
<Calendar
  locale="fr-FR"
  translations={{
    clearTrigger: 'Effacer la sélection',
    trigger: (open) => (open ? 'Fermer le calendrier' : 'Ouvrir le calendrier'),
  }}
/>
```

## API [#api]

<TypeTable
  type="{
  range: { type: 'boolean', default: 'false', description: 'Select a { start, end } range instead of a single date.' },
  value: { type: 'Date | null | { start, end }', description: 'Controlled value.' },
  defaultValue: { type: 'Date | null | { start, end }', description: 'Uncontrolled initial value.' },
  onChange: { type: '(value) => void', description: 'Fires with the next value.' },
  numOfMonths: { type: 'number', default: '1', description: 'Months shown side by side.' },
  fixedWeeks: { type: 'boolean', default: 'false', description: 'Always render 6 week rows, keeping the grid height constant.' },
  isDateUnavailable: { type: '(date: Date, timeZone?: string) => boolean', description: &#x22;Restriction predicate over a native Date, called with the component's own resolved timeZone.&#x22; },
  min: { type: 'Date', description: 'Earliest selectable date.' },
  max: { type: 'Date', description: 'Latest selectable date.' },
  minView: { type: '&#x22;day&#x22; | &#x22;month&#x22; | &#x22;year&#x22;', default: '&#x22;day&#x22;', description: 'Lowest view level reachable via navigation.' },
  maxView: { type: '&#x22;day&#x22; | &#x22;month&#x22; | &#x22;year&#x22;', default: '&#x22;year&#x22;', description: 'Highest view level reachable via navigation.' },
  renderDay: { type: '(date, { isUnavailable, isSelected }) => ReactNode', description: 'Per-day decoration slot (event dots/badges).' },
  withWeekNumbers: { type: 'boolean', default: 'false', description: 'Show an ISO week-number column.' },
  locale: { type: 'string', description: 'Overrides the active locale context.' },
  timeZone: { type: 'string', description: &#x22;Overrides the IntlProvider time zone for the Date <-> DateValue boundary (default: the browser's own).&#x22; },
  translations: { type: 'Partial<CalendarTranslations>', description: &#x22;Overrides Ark's own accessible names (day cells, prev/next, view-cycling header, week numbers). English by default.&#x22; },
  size: { type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;', default: '&#x22;md&#x22;', description: 'Grid density.' },
  disabled: { type: 'boolean', default: 'false', description: 'Disable the whole grid.' },
  readOnly: { type: 'boolean', default: 'false', description: 'Full contrast, but rejects selection.' },
  invalid: { type: 'boolean', default: 'false', description: 'Red state.' },
  name: { type: 'string', description: 'Name for native form submission.' },
  classNames: { type: '{ root?, header?, table?, day? }', description: 'Per-part class overrides.' },
  testId: { type: 'string', description: &#x22;data-testid on the calendar's root only — see the Testing guide and the note below about per-day selectors.&#x22; },
}"
/>

`testId` deliberately isn't wired down to individual day cells. Ark's own `date-picker` machine
already stamps every day cell with `data-part="table-cell-trigger"` plus its own date-derived
attributes (`data-value`, `data-today`, `data-selected`, …) for free, so a per-day `data-testid`
would just duplicate a selector you already have:

```ts
// No testId needed — Ark's own attributes already identify a specific day.
page.locator('[data-part="table-cell-trigger"][data-value="2026-08-28"]');
```

See the [Testing](/docs/testing) guide for the full `testId` convention.

## Accessibility [#accessibility]

* `role="grid"` with full keyboard navigation from Ark — arrows move between days,
  `PageUp` / `PageDown` step a month, `Home` / `End` jump to the start/end of the week.
* Unavailable and out-of-bounds days carry `aria-disabled`; the selected day (or range) carries
  `aria-selected`; today carries `aria-current="date"`.
* The header's view-cycling label and prev/next buttons are real `<button>`s with accessible
  names from Ark's built-in translations — override them with `translations` (see above).
* Range selection announces the in-progress start/end via Ark's live region.
