Skip to content

Calendar

A month-grid date picker (single, multiple, or range) that constrains in the grid — min/max/unavailable days clamp navigation rather than validating after the fact.

Stablev0.4.2added in v0.1.0@garn/ui/calendar
On this page

Default

An inline single-date calendar (controlled).

Range

Two-month range selection with the hover/focus preview band and a 2-night minimum.

Sizes

The size axis — sm (dense) / md / lg (airy) via --garn-calendar-cell.

Constrained

minDate/maxDate clamping with weekends marked unavailable via isDateUnavailable.

Indicators

Per-day indicator dots from getDayIndicators — up to four tones below the day number.

Grid Navigation

Drill-down fast nav: the caption opens a year grid, then a month grid, clamped and paged.

Availability

A three-month availability view with blackout dates (isDateUnavailable).

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add calendar
First time? Set up garn in your project
Registry
calendar
Deps
react-day-pickerdate-fnsclass-variance-authoritylucide-react
Registry deps
buttonutilsuse-controllable-state

Import

import { Calendar } from "@garn/ui/calendar";

Props

The component's public props and their types.

size
Description
Density / airiness of the grid. Maps to `--garn-calendar-cell`.
Type
"lg" | "md" | "sm"
Default
md
fromYear
Description
Lower year bound for the dropdown / navigation when no min/max is given.
Type
number
getDayIndicators
Description
Per-day indicator dots (planned items, notifications, warnings, errors). Return one tone per dot; up to four render below the day number.
Type
(date: Date) => CalendarIndicatorTone[] | undefined
gridNavigation
Description
Fast nav by drill-down: click the caption to open a year grid, then a month grid (clamped to min/max). An alternative to `captionLayout` dropdowns; forces the `label` caption.
Type
boolean
Default
false
isDateUnavailable
Description
Per-date disabling (holidays, blackout dates).
Type
(date: Date) => boolean
maxDate
Description
Upper selectable bound (inclusive, day granularity). Clamps navigation, the year dropdown, and the disabled matcher.
Type
Date
maxNights
Description
Range mode: maximum number of nights (days) in the range.
Type
number
minDate
Description
Lower selectable bound (inclusive, day granularity). Clamps navigation, the year dropdown, and the disabled matcher.
Type
Date
minNights
Description
Range mode: minimum number of nights (days) in the range.
Type
number
showToday
Description
Render a footer with a Today jump-and-select + an aria-live selection summary (inside rdp's `role="status"` live-region footer).
Type
boolean
Default
false
todayLabel
Description
Accessible name for the footer's Today button.
Type
string
Default
"Go to today"
toYear
Description
Upper year bound for the dropdown / navigation when no min/max is given.
Type
number

Plus 62 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="calendar"][data-slot="calendar-nav-prev"][data-slot="calendar-nav-next"][data-slot="calendar-caption"][data-slot="calendar-caption-trigger"][data-slot="calendar-weekday"][data-slot="calendar-day"][data-slot="calendar-footer"][data-slot="calendar-summary"]

States

SelectorState
[data-slot=calendar-day][data-selected=true]day-selected
[data-slot=calendar-day][data-today=true]day-today
[data-range-middle=true]day-range-middle
[data-preview]day-preview
[data-outside=true]day-outside
[data-disabled=true]day-disabled
[data-indicators]day-indicators
PropertyTokenTier
dayCellSize--garn-calendar-cellsemantic · air
navButtonSize--garn-control-h-smsemantic · air
panelPadding--garn-pad-panelsemantic · air

When to use

Pick a date, several dates, or a date range from a month grid — inline (booking, availability, scheduling) or inside a popover as a date field. Constrain with minDate / maxDate / isDateUnavailable; choose mode for the selection; add getDayIndicators for per-day dots or showToday for a Today footer.

Reach for something else when

  • Free-form or precise date entry where typing is faster (use DatePicker / an input with a date mask)
  • a time-only value (use TimeField / TimePicker)
  • a date + time (use DateTimePicker)
  • a far-future/past year jump a month grid makes tedious (use the dropdown captions or gridNavigation, or a typed field).

Overview

Calendar picks a date, several dates, or a date range from a month grid — shown inline, or dropped into a Popover to build a date field. It's the smart substrate the whole date family embeds, so its constraints and navigation live here, not re-derived per picker: minDate / maxDate / isDateUnavailable build a single disabled matcher AND clamp navigation; fast nav is month + year dropdowns by default (captionLayout="dropdown") or an opt-in gridNavigation drill-down. Range mode gets real feel — a preview band from the anchor to the pointed day on hover and keyboard focus, resetOnSelect, and minNights / maxNights. A size axis (sm / md / lg) drives the --garn-calendar-cell token; getDayIndicators paints up to four tone dots per day; an opt-in showToday footer adds a live selection summary. Use it when seeing the month in context matters (booking, scheduling, availability); when the user knows the exact date, a typed field is faster — a Calendar inside a Popover gives you both.

Guidelines

Constrain in the grid, don't validate after. Pass minDate / maxDate / isDateUnavailable rather than a hand-built disabled matcher — they clamp navigation and the year dropdown too, so out-of-range and blackout days can't be reached or picked. The three date pickers pass this intent straight through to the same Calendar.

Let the use case choose inline vs popover, and the nav mode fit the range. Show the grid inline when browsing the month is the task (availability, a range); tuck it behind a labelled trigger in a Popover for a form field. Keep the dropdown captions for arbitrary date entry; reach for gridNavigation when jumping across years is common. For availability views, numberOfMonths renders several months and fixedWeeks keeps their height stable.

Keep the range and today legible, and stay keyboard-operable. In range mode communicate the start, end, and the span between them (the preview band + the footer summary do this); today's treatment intentionally wins over selection so it's never swallowed. Arrow keys move by day, Page by month, and every day announces its full date — don't strip the focus ring or hide the month/year controls.

Best practices

Do
  • Choose mode to fit the task (single / multiple / range) and hold selected in state with onSelect.
  • Constrain choices with minDate / maxDate / isDateUnavailable rather than validating after the fact — they clamp navigation too.
  • For a range, control it so the hover/focus preview band paints, and bound it with minNights / maxNights.
  • Pair with Popover + a labeled trigger to build a date-picker field (or reach for DatePicker, which wires this).
Don't
  • Don't force a calendar when a typed date is faster for the user.
  • Don't re-derive a disabled matcher the constraints already build (that's the duplication this substrate removed).
  • Don't leave a popover trigger unlabeled when using the calendar as a field — the placeholder isn't a name.
  • Don't reimplement day rendering — theme via classNames / components, and use the published data-slots.

Content guidelines

  • Label the field or trigger with what the date is for (“Start date”).
  • Show the selected date near the trigger in a clear, unambiguous format.
  • Keep indicator dots to a legible few per day; they're decorative, so don't encode meaning only in color.

Troubleshooting

Selecting a day does nothing.

Cause. react-day-picker is controlled — selected/onSelect weren't wired.

Fix. Hold the value in state and pass selected + onSelect (with the right mode).

Type errors on selected/onSelect.

Cause. The shape depends on mode (Date for single, Date[] for multiple, DateRange for range) — CalendarProps keeps that union intact.

Fix. Match the selected type to the mode you set.

The range preview band never appears while dragging out a range.

Cause. The band paints from the anchor (start picked, no end) to the hovered/focused day — it needs a controlled range so the anchor is known.

Fix. Control the range (selected + onSelect) in mode="range"; the band then follows hover and arrow-key focus.

Nav is silently capped at the current year, or the caption shows dropdowns you didn't ask for.

Cause. Month + year dropdowns are the DEFAULT (captionLayout="dropdown"); with no min/max/fromYear/toYear the year range defaults to a generous window (this year −100 … +10).

Fix. Pass captionLayout="label" for a plain label, or bound the years with minDate/maxDate or fromYear/toYear.

Out-of-range days are still reachable, or you rebuilt a disabled matcher.

Cause. The constraints already build the single disabled matcher and clamp navigation.

Fix. Pass minDate / maxDate / isDateUnavailable instead of a hand-rolled disabled — don't duplicate it.

Accessibility

Role
grid
ARIA APG
grid
Focus
Roving focus across day cells; the focused day shows a ring. Initial focus lands on the selected day, or today clamped into the selectable window.

Keyboard

ArrowLeftArrowRightMove to the previous / next day (roving focus, react-day-picker).
ArrowUpArrowDownMove to the same weekday in the previous / next week.
HomeEndFirst / last day of the week.
PageUpPageDownPrevious / next month.
EnterSpaceSelect the focused day. In range mode, arrow-key focus also previews the band to the focused day (keyboard parity with hover).
A1.3.1Info and RelationshipsA2.1.1KeyboardAA2.4.7Focus VisibleA4.1.2Name, Role, Value
  • react-day-picker renders a labeled `role=grid` with roving focus; each day button exposes an `aria-label` (its full date), `aria-selected`, and disabled state.
  • Set `mode` (single | multiple | range) to match the selection you need; the selected shape follows (Date | Date[] | DateRange).
  • The in-flow prev/next nav buttons carry accessible names; the default dropdown captions are native `<select>`s (keyboard- and OS-native), each labelled.
  • Under `gridNavigation`, the caption becomes a labelled button (aria-label describes the period it changes) that drills into a year grid then a month grid.
  • The opt-in `showToday` footer is rendered inside react-day-picker's `role=status` / `aria-live=polite` region: a Today jump-and-select (auto-disabled when today falls outside min/max) plus a live selection summary.
  • Indicator dots are purely decorative — the container is `aria-hidden` and `pointer-events-none`, so they never affect the day's name or clickability (a per-day SR summary is a documented follow-up).
  • When used as the surface of a date field (inside a Popover), also give the trigger an accessible label — the placeholder is not a name.