Skip to Content
ComponentsCalendar

Calendar

A standalone date picker grid built without an external primitive. Supports controlled and uncontrolled selection, min/max bounds, and custom disabled-date predicates.

Import

import { Calendar } from "chunks-ui";

Basic Usage

<Calendar />
October 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday

Controlled

function ControlledCalendar() { const [date, setDate] = useState<Date | null>(null); return ( <Calendar value={date} onValueChange={setDate} /> ); }
October 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday

No date selected

When value changes to another day, the calendar shows that day’s month. A re-render with the same day, such as an inline value={new Date()}, keeps the month the user moved to. The same rule means that setting a new Date for the day that is already selected does not move the view back, so a “Today” button does nothing visible while today is selected and the user is on another month. To show the month again, control the month: see Shown Month.

Shown Month

The calendar opens on the month of the selected day, else today’s, and the arrows and keys move it. To control the shown month, pass month, any date in that month, and onMonthChange. onMonthChange gets the first day of the month the user moves to, at local midnight: by the arrows, by a key that moves focus out of the shown month, or by a click on a day from a neighbouring month. The view then waits for a new month. If you keep the month, a key that would leave it leaves focus where it is. When the new month comes later, for example from a transition, focus moves with the view to the day the key moved to. If you set another month instead, for example to keep the view inside a range, focus goes to that month’s Tab stop.

function MonthCalendar() { const [date, setDate] = useState<Date | null>(null); const [month, setMonth] = useState(() => new Date()); return ( <> <Button onClick={() => { const today = new Date(); setDate(today); setMonth(today); }} > Today </Button> <Calendar value={date} onValueChange={setDate} month={month} onMonthChange={setMonth} /> </> ); }
October 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday

With month, a new value does not move the view: set month too, as the Today button does. An Invalid Date, or a date before year 1, as month shows the selected day’s month, else today’s.

Without month, onMonthChange still tells you when the user moves to another month, for example to load that month’s data. A new value that moves the view does not call it.

function MonthChangeCalendar() { const [moved, setMoved] = useState<Date | null>(null); return ( <> <Calendar onMonthChange={setMoved} /> <p> {moved ? `Moved to ${moved.toLocaleDateString("en-US", { month: "long", year: "numeric" })}` : "No month change yet"} </p> </> ); }
October 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday

No month change yet

Disabled Dates

Use min/max to set a date range, or isDateDisabled to disable individual dates with a predicate:

const today = new Date(); const isWeekend = (date: Date) => date.getDay() === 0 || date.getDay() === 6; <Calendar min={today} isDateDisabled={isWeekend} />

A disabled day is dimmed and shows the not-allowed cursor.

October 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday

Week Start and Outside Days

weekStartsOn sets the first column of the week: 0 is Sunday (the default), 1 is Monday. showOutsideDays fills the empty cells before the 1st and after the last day of the month with dimmed days from the neighbouring months. Clicking one of them selects it and shows its month. They are never Tab stops. In forced-colors mode, such as a Windows contrast theme, they take the system colour for dimmed text (GrayText); there, the selected day takes the system highlight (Highlight), today is underlined in place of its ring, and a disabled day is struck through in GrayText at full opacity, so it differs from an outside day.

<Calendar weekStartsOn={1} showOutsideDays />
October 2026
MondayTuesdayWednesdayThursdayFridaySaturdaySunday

Keyboard

The day grid follows the keyboard pattern of the WAI-ARIA date picker example . It has one Tab stop: the selected day if it is in the shown month, else today, else the 1st of the month. After you move with the keys, the day you moved to becomes the Tab stop. Days from neighbouring months (showOutsideDays) are never Tab stops.

KeyMoves focus to
← / →The previous / next day
↑ / ↓The same weekday in the previous / next week
Home / EndThe first / last day of the week, following weekStartsOn
Page Up / Page DownThe same day in the previous / next month
Shift + Page Up / Page DownThe same day in the previous / next year

Enter or Space selects the focused day. When focus moves past the shown month, the calendar shows the new month. If the target month is shorter, focus goes to its last day: Page Up from March 31 goes to February 28.

Disabled days (min, max, isDateDisabled) can take focus, and screen readers announce them as unavailable. Arrow keys do not skip them, but Enter, Space and clicks do not select them.

The left and right arrows are not mirrored in right-to-left layouts.

Calendar Props

NameTypeDefault
valueDate | null

Controlled selected date. An Invalid Date counts as no date.

defaultValueDate | null

Initial selected date for uncontrolled usage. An Invalid Date counts as no date.

onValueChange(date: Date | null) => void

Called when the selected date changes.

monthDate

The shown month (controlled): any date in it. The arrows, the keys and a click on an outside day then call onMonthChange and wait for a new month, and a new value does not move the view. An Invalid Date, or one before year 1, shows the selected day’s month, else today’s. Without it, the calendar opens on the selected day’s month, else today’s, and moves by itself.

onMonthChange(month: Date) => void

Called with the first day of a month, at local midnight, when the user moves to that month: the previous or next month arrow, a key that moves focus out of the shown month, or a click on an outside day. A new value that moves the view does not call it.

isDateDisabled(date: Date) => boolean

Dates to disable — return true to disable a given date.

minDate

Minimum selectable date (inclusive).

maxDate

Maximum selectable date (inclusive).

weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 6

First day of the week: 0 = Sunday, 1 = Monday, up to 6 = Saturday.

0
showOutsideDaysboolean

Fill leading and trailing cells with dimmed days from the neighbouring months.

false
labelsPartial<CalendarLabels>

Replaces the built-in English strings, e.g. to translate them. Keys you leave out keep the default from DEFAULT_CALENDAR_LABELS.

Labels

labels replaces the built-in English text, for example to translate it. Keys you leave out keep their default from DEFAULT_CALENDAR_LABELS. The keys that depend on a date are functions of that date, so you can pass Intl formatters. They get the date at local midnight: format it in the local time zone, not with timeZone: "UTC". A server component can’t pass functions, so set them in a client component. See Labels.

Each column header shows weekday and screen readers read weekdayLong; set both when you translate them (see Labels).

NameTypeDefault
previousMonthstring

Name of the button that shows the previous month.

"Previous month"
nextMonthstring

Name of the button that shows the next month.

"Next month"
heading(date: Date) => string

The heading above the days, which also names the grid. Gets the first day of the shown month, at local midnight.

(date) => "March 2026"
weekday(date: Date) => string

The short weekday name that a column header shows. Gets a date that falls on that weekday, at local midnight.

(date) => "Su" … "Sa"
weekdayLong(date: Date) => string

The full weekday name that screen readers read for a column header, in place of the short one. Gets a date that falls on that weekday, at local midnight. If you set weekday and leave this out, screen readers read your weekday text.

(date) => "Sunday" … "Saturday"
day(date: Date) => string

Accessible name of a day button. Gets its day, at local midnight.

(date) => "Sunday, March 15, 2026"
Last updated on