# Calendar

> A month grid for picking one date or a range, with keyboard moves, date limits, and names in the user's language.

- Name: `calendar`
- Status: beta
- Categories: dates, forms
- File: the install writes `calendar.tsx` to your `ui` alias, `@/components/ui` by default.
- Gallery: https://arbokui.com/components/calendar
- Registry item: https://arbokui.com/r/calendar.json

## Install

1. Add the registry to `components.json`. Do this first, once:

   ```bash
   npx shadcn@latest registry add "@arbokui=https://arbokui.com/r/{name}.json"
   ```

   The command writes this entry. You can also add it by hand:

   ```json
   {
     "registries": {
       "@arbokui": "https://arbokui.com/r/{name}.json"
     }
   }
   ```

   Without this step, the shadcn CLI looks up `@arbokui` in a public list that Arbokui does not control.

2. Add the theme once per app. It replaces your color tokens with ones checked for contrast:

   ```bash
   npx shadcn@latest add @arbokui/theme
   ```

3. Add the component:

   ```bash
   npx shadcn@latest add @arbokui/calendar
   ```

## Use it for

- Picking a date or a range when people need to see the days around it: a booking, a deadline, a trip.
- Give the calendar a name: aria-label, like "Check-in", or aria-labelledby with the id of a visible heading.
- Values are date strings: "2026-10-20" for one date, and { start, end } of them for a range. A date string has no time and no time zone, so it never moves a day on its way between the browser, the server, and the database. A Date object carries both, which causes off-by-one-day bugs.
- It works uncontrolled with defaultValue, even from a server component. Pass value with onValueChange to control it.
- In range mode, the first pick sends a one-day range, and the second pick sends the full range. The next pick starts over.
- A range people pick never covers a disabled date. After the first pick, days past the nearest one on each side cannot be picked. To start over, pick the first day again.
- Set name to send the value with a form: "2026-10-20", or "2026-10-12/2026-10-16" for a range.
- The server cannot know the user's date or language. It renders with no today mark, in en-US unless you pass locale, and the browser fills both in after hydration. Pass locale to skip that switch.
- With no value, defaultMonth, or month, the server sends a blank grid of the same size, and the browser shows the current month.

## Avoid it for

- Dates far from today, like a birth date. Typing is faster there, so use an input.
- Picking a time. This calendar picks days only.
- Ranges that may cover a disabled date, like a stay over a holiday. After the first pick, a range stops at the nearest one.
- Picking many separate dates. It picks one date or one range.

## Props

- `mode`: `"single" | "range"`. Default `"single"`. Pick one date, or a start and an end date.
- `defaultValue`: `string | { start: string; end: string } | null`. The picked date at first render, as "YYYY-MM-DD". A { start, end } range in range mode.
- `value`: `string | { start: string; end: string } | null`. Controlled value. Use it with onValueChange.
- `onValueChange`: `(value: string | { start: string; end: string }) => void`. Called with the new value on each pick.
- `min`: `string`. The earliest date people can pick, as "YYYY-MM-DD". Earlier days are disabled, and focus stops there.
- `max`: `string`. The latest date people can pick. Later days are disabled, and focus stops there.
- `disabledDates`: `string[] | ((date: string) => boolean)`. Days people cannot pick. They can still get focus, and a range people pick cannot cross one. A function only works from a client component.
- `disabled`: `boolean`. Default `false`. Blocks picking and moving, and takes the calendar out of the tab order.
- `weekStartsOn`: `0 | 1 | 2 | 3 | 4 | 5 | 6`. The first column: 0 is Sunday, 1 is Monday. Defaults to the locale's custom.
- `locale`: `string`. A language tag for month and day names, like "de-DE". Defaults to the browser's language.
- `defaultMonth`: `string`. The month on screen at first render, as "YYYY-MM". Defaults to the picked date's month, or today's.
- `month`: `string`. Controlled month, as "YYYY-MM". Use it with onMonthChange.
- `onMonthChange`: `(month: string) => void`. Called with the new "YYYY-MM" when the month on screen changes.
- `size`: `"sm" | "md" | "lg"`. Default `"md"`. Day size and text size. Days are as tall as controls of the same size. Density scales them.
- `tone`: `"neutral" | "brand" | "danger" | "success" | "warning"`. Default `"neutral"`. Color of the picked days.
- `name`: `string`. Form field name. Sends "YYYY-MM-DD", or "YYYY-MM-DD/YYYY-MM-DD" for a range.
- `aria-label`: `string`. Names the calendar, like "Check-in". TypeScript requires this or aria-labelledby. The grid is already named by the month.
- `aria-labelledby`: `string`. The id of a visible heading that names the calendar. Use it in place of aria-label.
- `className`: `string`. Classes for the outer box. Later classes win, so className='p-0' drops the padding.

## Accessibility

- TypeScript requires aria-label or aria-labelledby, so screen readers say what the date is for.
- The days form a grid (role="grid") named by the month title. Each day is a gridcell named with its full date, like "Tuesday, October 20, 2026", in the calendar's language.
- Weekday headers show short names, or one letter in languages whose short names are long, like Arabic. Each header keeps the full day name in its abbr attribute.
- One day is in the tab order: the last focused day, else the picked day, else today, else the first day of the month.
- Arrow keys move by day and by week. Home and End go to the start and end of the week. Page Up and Page Down move by month, and with Shift by year. Enter or Space picks the day.
- Left and Right Arrow swap in right-to-left text, when a parent has dir="rtl".
- Picked days have aria-selected, today has aria-current="date", and days people cannot pick have aria-disabled.
- Disabled dates can still get focus, so people can hear them, but they cannot be picked. Days outside min and max cannot get focus.
- In range mode, after the first pick, days past the nearest disabled date get aria-disabled until the second pick. They can still get focus, but Enter and clicks do nothing there.
- The month buttons are named by the month they show, like "November 2026", so their names follow the locale. At min or max they set aria-disabled and keep focus.
- The month title is a polite live region, so screen readers hear the new month.
- Picked days use the tone's solid pair, and days inside a range use its soft pair. The theme tests hold both to 4.5:1.
- Hovered days use the accent pair, and weekday names use muted text. The theme tests hold both to 4.5:1. The focus ring uses --ring, held to 3:1 on the page and cards.
- Days people cannot pick are dimmed to half strength, and days from disabledDates are also struck through.
- On a screen narrower than the calendar, the days get narrower to fit, so the page never scrolls sideways.

## Examples

### Pick a date

```tsx
<Calendar aria-label="Due date" name="due" defaultValue="2026-10-20" />
```

### A range between two limits

```tsx
<Calendar
  aria-label="Trip"
  mode="range"
  tone="brand"
  min="2026-10-01"
  max="2026-12-31"
  defaultValue={{ start: "2026-10-12", end: "2026-10-16" }}
/>
```

### Days people cannot pick

```tsx
<Calendar
  aria-label="Delivery day"
  defaultMonth="2026-12"
  disabledDates={["2026-12-25", "2026-12-26"]}
/>
```

### Controlled, in one language

```tsx
"use client";
import { useState } from "react";

function DueDate() {
  const [date, setDate] = useState<string | null>(null);
  return <Calendar aria-label="Fällig am" locale="de-DE" value={date} onValueChange={setDate} />;
}
```

### No weekends, from a client component

```tsx
"use client";

// A "YYYY-MM-DD" string parses as midnight UTC, so getUTCDay gives its weekday.
const weekend = (date: string) => [0, 6].includes(new Date(date).getUTCDay());

function MeetingDay() {
  return <Calendar aria-label="Meeting day" disabledDates={weekend} />;
}
```

### Show a picked date

```tsx
// Format in UTC, the zone a date string parses in, so the day never moves.
const label = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" }).format(
  new Date("2026-10-20"),
);
// "Oct 20, 2026"
```
