Skip to content
Monogem

Component · Composite & Advanced

Calendar / Date Picker

Overview

Calendar is the month-grid primitive: a role="grid" of day cells with roving focus and the WAI-ARIA APG Date Picker Dialog grid keyboard model. It renders a month, moves a focus cursor across days with the arrow keys, and commits a single Date. Nothing else.

Date Picker is not a component on this page — it is a documented pattern: an Input-styled trigger that opens a <Popover> holding a <Calendar>. Keeping the two apart is the point of this page: the surface (Calendar) and the field-plus-overlay (Date Picker) are different responsibilities, and conflating them is how date components usually rot.

Calendar provides a two-dimensional roving-focus grid, month/year keyboard paging, and focus movement into and out of an overlay when used in a Date Picker — see Accessibility.

Anatomy

Calendar                       (state: selected value, visible month, FOCUSED day)
├─ caption row
│  ├─ prev button              buttonVariants({ variant: "outline" }), ChevronLeft, "Go to previous month"
│  ├─ sr-only <span>           aria-live="polite" — "October 2026"; the grid's accessible name
│  ├─ Month <Select>           aria-label="Month" — January…December, always all twelve
│  ├─ Year <Select>            aria-label="Year"  — fromYear…toYear (see below)
│  └─ next button              ChevronRight, "Go to next month"
└─ <table role="grid">         aria-labelledby → the sr-only caption (or your aria-label)
   ├─ <thead> <tr role="row">
   │  └─ <th role="columnheader" scope="col" aria-label="Monday">  short label shown, full name for AT
   └─ <tbody>
      └─ <tr role="row">        one per week — always 6 rows
         └─ <td role="gridcell" aria-selected>
            └─ <button data-day> one roving tabindex across the whole grid
  • Calendar — the client root. value / defaultValue (a Date, or null) + onValueChange; month / defaultMonth + onMonthChange for the visible month, independently controllable; min / max (inclusive bounds); isDateDisabled(date) for arbitrary unavailable days; weekStartsOn; fromYear / toYear (the year menu's range — defaults below); locale; autoFocus (the Date Picker sets this to move focus into the grid when the popover opens).
  • caption row — previous / next icon buttons for nearby months, plus a Month menu and a Year menu for the direct jump (see Caption navigation). The month / year name is also written to a visually hidden aria-live="polite" span, which is announced on a month change and is the grid's aria-labelledby target unless you pass your own aria-label. A nav button is disabled when the entire target month lies outside min / max.
  • grid — a real <table role="grid">. Weekday headers are <th role="columnheader" scope="col"> with the short label visible and the full weekday name on aria-label. The body is always six <tr role="row"> (stable height); each day is a <button data-day> inside a <td role="gridcell"> that carries aria-selected.

Live Example

Calendar — the month grid on its own

September 2026

No date selected.

Date Picker — field + popover

Nothing committed yet.

Unavailable days — weekends disabled

September 2026

Controlled month — navigation outside the grid

September 2026

Distant dates — jump by month & year

The caption Month / Year menus reach a 1986 birthday in two clicks — no repeated “previous month”.

Code Example

calendar-showcase.tsx
"use client";

import * as React from "react";
import { Calendar as CalendarIcon } from "lucide-react";

import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";

/**
* Live Calendar / Date Picker example — the month-grid primitive on its own,
* then the Date Picker pattern (field + popover) built from it.
*
* - Calendar is `role="grid"` with roving focus: one Tab stop, arrow keys move
* one day / one week and cross month boundaries, `PageUp` / `PageDown`
* change month (add `Shift` for year), `Enter` / `Space` select.
* - Selected day = `--primary`; today (unselected) = a `--border` ring;
* unavailable days (here: weekends) stay focusable but are not selectable.
* - Date Picker: an Input-styled trigger opens a <Popover> holding a
* <Calendar autoFocus>. Focus moves into the grid on open and back to the
* trigger on select / dismiss. The trigger is read-only in V1 — no typed
* date entry.
* - Controlled month: `month` / `onMonthChange` driven by buttons OUTSIDE the
* grid. The roving cursor re-syncs into the new month on its own, so the
* grid keeps exactly one tabbable day without stealing focus.
* - Distant dates: the caption's Month and Year `<Select>` menus jump straight
* to a far date (a birthday); `fromYear` widens the Year menu, which stays
* height-capped and scrolls rather than filling the viewport.
*/

const LONG_DATE = new Intl.DateTimeFormat(undefined, { dateStyle: "long" });

const SURFACE_CLASS =
"w-fit rounded-lg border-[length:var(--scale-px)] border-solid border-border bg-popover text-popover-foreground shadow-sm";

const TRIGGER_CLASS =
"flex h-10 w-64 items-center justify-between gap-2 rounded-sm border-[length:var(--scale-px)] border-solid border-input bg-background px-3 text-sm leading-normal text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";

function isWeekend(date: Date): boolean {
const day = date.getDay();
return day === 0 || day === 6;
}

export function CalendarShowcase() {
const [selected, setSelected] = React.useState<Date | null>(null);

const [picked, setPicked] = React.useState<Date | null>(null);
const [pickerOpen, setPickerOpen] = React.useState(false);
const pickerTriggerRef = React.useRef<HTMLButtonElement>(null);

const [dob, setDob] = React.useState<Date | null>(null);
const [dobOpen, setDobOpen] = React.useState(false);
const dobTriggerRef = React.useRef<HTMLButtonElement>(null);

const [month, setMonth] = React.useState(() => new Date());
const shiftMonth = (delta: number) =>
setMonth((m) => new Date(m.getFullYear(), m.getMonth() + delta, 1));

return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Calendar — the month grid on its own
</h3>
<div className={SURFACE_CLASS}>
<Calendar
aria-label="Event date"
value={selected}
onValueChange={setSelected}
/>
</div>
<p className="text-sm leading-normal text-muted-foreground" aria-live="polite">
{selected
? `Selected: ${LONG_DATE.format(selected)}`
: "No date selected."}
</p>
</section>

<section className="flex flex-col gap-1.5">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Date Picker — field + popover
</h3>
<Label htmlFor="deadline-trigger">Deadline</Label>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger>
<button
ref={pickerTriggerRef}
id="deadline-trigger"
type="button"
className={cn(TRIGGER_CLASS, !picked && "text-muted-foreground")}
>
{picked ? LONG_DATE.format(picked) : "Pick a date"}
<CalendarIcon aria-hidden="true" className="size-4 shrink-0 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent aria-label="Choose a date" className="w-auto p-0">
<Calendar
autoFocus
aria-label="Deadline"
value={picked}
onValueChange={(date) => {
setPicked(date);
setPickerOpen(false);
pickerTriggerRef.current?.focus();
}}
/>
</PopoverContent>
</Popover>
<p className="text-sm leading-normal text-muted-foreground" aria-live="polite">
{picked ? `Committed: ${LONG_DATE.format(picked)}` : "Nothing committed yet."}
</p>
</section>

<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Unavailable days — weekends disabled
</h3>
<div className={SURFACE_CLASS}>
<Calendar
aria-label="Weekday-only date"
isDateDisabled={isWeekend}
/>
</div>
</section>

<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Controlled month — navigation outside the grid
</h3>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => shiftMonth(-1)}>
Previous
</Button>
<Button variant="outline" size="sm" onClick={() => shiftMonth(1)}>
Next
</Button>
<Button variant="ghost" size="sm" onClick={() => setMonth(new Date())}>
Today
</Button>
</div>
<div className={SURFACE_CLASS}>
<Calendar
aria-label="Controlled-month date"
month={month}
onMonthChange={setMonth}
/>
</div>
</section>

<section className="flex flex-col gap-1.5">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Distant dates — jump by month &amp; year
</h3>
<Label htmlFor="dob-trigger">Date of birth</Label>
<Popover open={dobOpen} onOpenChange={setDobOpen}>
<PopoverTrigger>
<button
ref={dobTriggerRef}
id="dob-trigger"
type="button"
className={cn(TRIGGER_CLASS, !dob && "text-muted-foreground")}
>
{dob ? LONG_DATE.format(dob) : "Pick a date"}
<CalendarIcon aria-hidden="true" className="size-4 shrink-0 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent aria-label="Choose a date of birth" className="w-auto p-0">
<Calendar
autoFocus
aria-label="Date of birth"
fromYear={1920}
toYear={new Date().getFullYear()}
defaultMonth={new Date(1990, 0, 1)}
value={dob}
onValueChange={(date) => {
setDob(date);
setDobOpen(false);
dobTriggerRef.current?.focus();
}}
/>
</PopoverContent>
</Popover>
<p className="text-sm leading-normal text-muted-foreground">
The caption Month / Year menus reach a 1986 birthday in two clicks — no
repeated “previous month”.
</p>
</section>
</div>
);
}

Variants

None. One grid style. Size is fixed (--scale-9 day cells). The caller frames it — the bare Calendar in the showcase sits in a bordered --popover box; the Date Picker puts it in <PopoverContent className="p-0">.

States

Day cell

StateLook
Resting (in month)--popover-foreground text, transparent background, --radius-md
Hover / keyboard focus--accent background + --accent-foreground text; --ring focus ring on :focus-visible
Selected--primary background + --primary-foreground text — deliberately heavier than the --accent hover so "chosen" never reads as "hovered"
Today (unselected)a --border inset ring
Outside the visible month--muted-foreground text; still selectable — picking one moves the visible month
Unavailable (min / max / isDateDisabled)opacity-50, not-allowed cursor, aria-disabled="true". Still focusable — the arrow keys land on it so a screen reader announces the date is unavailable — but Enter / click do nothing

Caption nav button

StateLook
Restingoutline Button recipe — --border, --background, --foreground
Disablednative disabled, opacity-50 — the whole target month is outside min / max

Caption Month / Year menu

StateLook
Restinga Select trigger, --scale-8 tall, --input border, --background fill, --foreground medium-weight text, a trailing ChevronDown (--scale-4, opacity-60)
Focus--ring focus ring on :focus-visible — the same field-focus treatment as Input
Openthe Select role="listbox" on the --popover surface (light / dark), height-capped (~20rem, clamped to 60vh) with internal scroll, the current value scrolled into view, flipping / shifting off a viewport edge

Usage Guidance

Calendar vs. Date Picker

CalendarDate Picker
What it isthe month grid — the Calendar componenta pattern: <Popover> + an Input-styled trigger + <Calendar autoFocus>
Always visible?yes — it is just a gridno — it lives in an anchored, non-modal Popover
Ownsselection, month navigation, keyboard, unavailable-day logicthe field's formatted display, open/close, focus hand-off
Accessible namearia-label, or aria-labelledby → its month captionthe trigger's <Label>
Ships asa componentguidance + a live example, no dedicated DatePicker component

The Date Picker trigger is read-only in V1 — a button that shows the formatted date (or a placeholder) and opens the calendar. Typed date entry (parsing "3/7/26" or "next friday") is deferred; it drags in locale-specific parsing that Calendar deliberately avoids.

Date model

Calendar operates on a native Date treated as date-only, local time:

  • Every date is normalised to new Date(year, month, day) — local midnight. There is no time component, no UTC conversion, no DST arithmetic, no timezone handling. Two dates are "the same day" when their local year / month / date match.
  • All month maths is a handful of pure local helpers inside Calendar (startOfMonth, addDays, addMonths, daysInMonth, …) — not a library.
  • Display strings and every per-day aria-label come from Intl.DateTimeFormat, using the browser locale or an explicit locale prop.
  • weekStartsOn (0–6, default 0 = Sunday) is an explicit prop. It is not derived from the locale in V1.

When you submit a date in a plain form, format it yourself — toISOString().slice(0, 10) (YYYY-MM-DD) in a hidden input is the recipe the showcase demonstrates. Calendar has no name prop (it ships as a surface, not a field).

Selection & focus

Three distinct pieces of state, deliberately separate:

  • Selected value — the committed Date. Changes only on an explicit select (click, or Enter / Space on the focused day). Fires onValueChange. Rendered as the --primary fill.
  • Visible month — which month the grid shows. Follows selection and keyboard navigation across month boundaries; also driven by the caption arrows. Controllable via month.
  • Focused day — the roving cursor. The grid is one Tab stop; exactly one day is tabIndex={0} — the selected day if it's in view, else today, else the first of the month. The arrow keys move it; crossing a month edge moves the visible month with it. Changing the controlled month or value from outside — or picking from the caption Month / Year menus — re-syncs the cursor (onto the selection when it comes into view, otherwise back into the visible month) so the one-tabbable-day invariant always holds — without stealing DOM focus from the menu.

Caption navigation

Two ways to move the visible month, both always available:

  • Previous / next buttons — one month at a time, for nearby navigation. Disabled when the whole target month is outside min / max.
  • Month and Year menus — the direct jump, so a distant date (a 1986 birthday, a report start-date) is one interaction, not thirty presses of previous. Both are the Select primitive, so the option list sits on the Monogem --popover surface in light and dark, is height-capped (~20rem, further clamped to 60vh) and scrolls internally rather than filling the viewport, opens with the current value scrolled into view, and flips / shifts on collision. Changing either updates the grid immediately.
    • Month — always all twelve, January–December, localised via locale.
    • Year — fromYear…toYear inclusive. Defaults: fromYear = 100 years before today (or min's year when min is set); toYear = 10 years after today (or max's year). The visible year is always folded in, so the menu never lacks a matching option. A distant year (1986) is reached by scrolling the capped menu or by type-ahead.

The keyboard and roving-focus model inside the grid is unchanged. The caption controls are their own Tab stops before the grid (prev → Month → Year → next → grid); after a jump the grid's one tabbable day is re-homed into the new month and Tab reaches it as usual.

Tokens

Calendar uses existing Monogem tokens and needs no date library or extra dependency: the --primary pair for the selected day, --accent for hover / keyboard focus, --border for the “today” ring and grid furniture, --muted-foreground for weekday headers and outside-month days, and --ring for the focus ring. The nav arrows reuse buttonVariants directly; the caption Month / Year menus reuse the Select primitive as-is, so their option list sits on the same --popover surface, height cap, scroll, and flip / shift collision handling as every other Monogem overlay.

TokenWhere usedRationale
--primary / --primary-foregroundSelected day fill + textColor's "main action" pair — the one unambiguous marker in a dense grid, stronger than --accent
--accent / --accent-foregroundDay hover + keyboard-focus backgroundColor names --accent for "hover states, selected rows" — matches Select / Command active rows
--border"Today" ring, caption button border, caption menu list borderThe system hairline
--input / --background / --foregroundCaption Month / Year Select trigger border, fill, textThe field-border token — the menus read as small Input-style fields
--ringDay :focus-visible ring, caption Select trigger :focus-visible ringThe system-wide focus token, same as Button / Input
--muted-foregroundWeekday header row, outside-month daysColor names this for quiet secondary text
--popover / --popover-foregroundAmbient surface + resting day text; caption Month / Year menu list surfaceCalendar renders inside a --popover container (Popover, or the bordered box in the showcase); the caption menus reuse Select's --popover list
--shadow-md / --radius-lgCaption Month / Year menu listThe shared --popover overlay recipe — same as Select / Popover
--radius-md (rounded-md)Day cell cornerRadius names "buttons" — a day cell is a small button
--scale-9 (size-9)Day cell boxA 36px hit target, one step under Button's default height so seven fit a sensible width
--scale-3 (p-3)Calendar paddingSpacing
--scale-4 (size-4)Nav chevron sizeIcons' inline size

Text recipe: day numbers text-sm leading-none; weekday headers text-xs font-normal leading-snug; caption Month / Year menus text-sm font-medium leading-snug.

The Date Picker trigger reuses Input's --input / --ring / --destructive treatment via the same class recipe Select's trigger uses.

Do / Don’t

Do

  • Name every Calendar — pass aria-label, or let the month caption name it via the built-in aria-labelledby.
  • Give the Date Picker trigger a visible <Label> (htmlFor → the trigger id).
  • Use min / max for a bounded range (a booking window), isDateDisabled for scattered unavailability (weekends, holidays, sold-out days).
  • In the Date Picker pattern, pass autoFocus to the <Calendar> and move focus back to the trigger after a pick or a dismiss (the showcase uses a ref) — Popover only restores focus itself on Escape.
  • Format the value for display and for form submission yourself, with Intl.DateTimeFormat / toISOString().slice(0,10).

Don’t

  • Use the caption Month / Year menus for a far date (a birthday, a report start-date); the previous / next buttons are for nearby months. Widen the year menu with fromYear / toYear when the default (100 years back / 10 forward) isn't enough.
  • Don't reach for it when a plain <input type="date"> will do — the native control is lighter and already localised.
  • Don't expect range or multi-date selection, time-of-day, or timezone handling — all explicitly out of V1.
  • Don't type into the Date Picker trigger — it is read-only in V1.
  • Don't skip the "today" ring or rely on colour alone to mark the selected day — the --primary fill plus the ring are two independent signals.

Accessibility

  • Roles: the grid is a real <table role="grid"> named by aria-labelledby (its month caption) or aria-label; weeks are <tr role="row">; column headers are <th role="columnheader" scope="col"> with the full weekday name on aria-label; days are <button> inside <td role="gridcell" aria-selected>.

  • Roving focus: the grid is one Tab stop. Exactly one day is tabIndex={0} (selected → today → first of month); all others are tabIndex={-1}. The arrow keys move both the cursor and, when needed, the visible month.

  • Caption controls: the previous / next buttons and the Month / Year menus are each their own Tab stop, in DOM order before the grid (prev → Month → Year → next → grid). The menus are the Select primitive — a role="combobox" trigger (aria-haspopup="listbox", aria-expanded) that opens a role="listbox", with DOM focus kept on the trigger and aria-activedescendant tracking the explored option. Each carries the Select keyboard model (Enter / Space / ArrowDown / ArrowUp to open, then non-wrapping ArrowUp / ArrowDown, Home / End, printable-character type-ahead, Escape to close without committing) and an aria-label ("Month" / "Year"). Committing a value moves the visible month at once and returns focus to the trigger — never the grid — and the grid's one tabbable day is re-homed into the new month so Tab still lands on it.

  • Keyboard (inside the grid):

    KeyBehaviour
    ArrowLeft / ArrowRight−1 / +1 day, crossing week and month edges (the visible month follows)
    ArrowUp / ArrowDown−7 / +7 days
    Home / Endfirst / last day of the focused week (respecting weekStartsOn)
    PageUp / PageDownsame day, previous / next month
    Shift + PageUp / PageDownsame day, previous / next year
    Enter / Spaceselect the focused day; in the Date Picker, also close the popover and return focus to the trigger
    Escapewith a caption Month / Year menu open in the bare Calendar, closes just that menu; otherwise, in the Date Picker, closes the popover with no change (Popover's contract)
    Tableaves the grid in one step; in the Date Picker, Tab out of the popover closes it
  • Unavailable days are aria-disabled="true" and stay in the arrow-key rotation — a screen-reader user hears the date and that it is unavailable — but they cannot be selected. This follows the APG grid pattern (focusable, not selectable), and is the opposite of a native <select>'s skipped <option>.

  • Month changes are announced: a visually hidden aria-live="polite" span in the caption carries the "October 2026" text (however the month changed — arrows, buttons, or the Month / Year menus).

  • Caption Month / Year menus are the Select primitive with aria-label="Month" / aria-label="Year" on the role="combobox" trigger, so they announce as comboboxes and carry Select's role="listbox" / aria-activedescendant / no-wrap keyboard model. The option list is rendered inline (no portal, z-50) with the Popover-style flip / shift, so it stays on the Monogem --popover surface in both themes and is not clipped by the Date Picker popover, which sets no overflow clip on its content.

  • The Date Picker Popover is non-modal — the page behind it is never inert and focus is not trapped. On open, focus moves into the grid; on select or dismissal, it returns to the trigger.

  • Contrast: --primary-foreground on --primary and --accent-foreground on --accent are the system pairings, both clearing WCAG 2.1 AA in light and dark; the "today" ring is a shape cue, not a colour-only one.

Related Components / Patterns

  • Select — the caption Month / Year menus.
  • Popover — hosts the Calendar in a Date Picker.
  • Input — the Date Picker trigger’s visual recipe.