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(aDate, ornull) +onValueChange;month/defaultMonth+onMonthChangefor 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'saria-labelledbytarget unless you pass your ownaria-label. A nav button isdisabledwhen the entire target month lies outsidemin/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 onaria-label. The body is always six<tr role="row">(stable height); each day is a<button data-day>inside a<td role="gridcell">that carriesaria-selected.
Live Example
Calendar — the month grid on its own
No date selected.
Date Picker — field + popover
Nothing committed yet.
Unavailable days — weekends disabled
Controlled month — navigation outside the grid
Distant dates — jump by month & year
The caption Month / Year menus reach a 1986 birthday in two clicks — no repeated “previous month”.
Code Example
"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 & 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
| State | Look |
|---|---|
| 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
| State | Look |
|---|---|
| Resting | outline Button recipe — --border, --background, --foreground |
| Disabled | native disabled, opacity-50 — the whole target month is outside min / max |
Caption Month / Year menu
| State | Look |
|---|---|
| Resting | a 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 |
| Open | the 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
| Calendar | Date Picker | |
|---|---|---|
| What it is | the month grid — the Calendar component | a pattern: <Popover> + an Input-styled trigger + <Calendar autoFocus> |
| Always visible? | yes — it is just a grid | no — it lives in an anchored, non-modal Popover |
| Owns | selection, month navigation, keyboard, unavailable-day logic | the field's formatted display, open/close, focus hand-off |
| Accessible name | aria-label, or aria-labelledby → its month caption | the trigger's <Label> |
| Ships as | a component | guidance + 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-labelcome fromIntl.DateTimeFormat, using the browser locale or an explicitlocaleprop. weekStartsOn(0–6, default0= 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, orEnter/Spaceon the focused day). FiresonValueChange. Rendered as the--primaryfill. - 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 controlledmonthorvaluefrom 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
--popoversurface in light and dark, is height-capped (~20rem, further clamped to60vh) 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 vialocale. - Year —
fromYear…toYearinclusive. Defaults:fromYear= 100 years before today (ormin's year whenminis set);toYear= 10 years after today (ormax'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.
- Month — always all twelve,
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.
| Token | Where used | Rationale |
|---|---|---|
--primary / --primary-foreground | Selected day fill + text | Color's "main action" pair — the one unambiguous marker in a dense grid, stronger than --accent |
--accent / --accent-foreground | Day hover + keyboard-focus background | Color names --accent for "hover states, selected rows" — matches Select / Command active rows |
--border | "Today" ring, caption button border, caption menu list border | The system hairline |
--input / --background / --foreground | Caption Month / Year Select trigger border, fill, text | The field-border token — the menus read as small Input-style fields |
--ring | Day :focus-visible ring, caption Select trigger :focus-visible ring | The system-wide focus token, same as Button / Input |
--muted-foreground | Weekday header row, outside-month days | Color names this for quiet secondary text |
--popover / --popover-foreground | Ambient surface + resting day text; caption Month / Year menu list surface | Calendar renders inside a --popover container (Popover, or the bordered box in the showcase); the caption menus reuse Select's --popover list |
--shadow-md / --radius-lg | Caption Month / Year menu list | The shared --popover overlay recipe — same as Select / Popover |
--radius-md (rounded-md) | Day cell corner | Radius names "buttons" — a day cell is a small button |
--scale-9 (size-9) | Day cell box | A 36px hit target, one step under Button's default height so seven fit a sensible width |
--scale-3 (p-3) | Calendar padding | Spacing |
--scale-4 (size-4) | Nav chevron size | Icons' 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-inaria-labelledby. - Give the Date Picker trigger a visible
<Label>(htmlFor→ the triggerid). - Use
min/maxfor a bounded range (a booking window),isDateDisabledfor scattered unavailability (weekends, holidays, sold-out days). - In the Date Picker pattern, pass
autoFocusto the<Calendar>and move focus back to the trigger after a pick or a dismiss (the showcase uses aref) — Popover only restores focus itself onEscape. - 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/toYearwhen 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
--primaryfill plus the ring are two independent signals.
Accessibility
-
Roles: the grid is a real
<table role="grid">named byaria-labelledby(its month caption) oraria-label; weeks are<tr role="row">; column headers are<th role="columnheader" scope="col">with the full weekday name onaria-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 aretabIndex={-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 arole="listbox", with DOM focus kept on the trigger andaria-activedescendanttracking the explored option. Each carries the Select keyboard model (Enter/Space/ArrowDown/ArrowUpto open, then non-wrappingArrowUp/ArrowDown,Home/End, printable-character type-ahead,Escapeto close without committing) and anaria-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 soTabstill lands on it. -
Keyboard (inside the grid):
Key Behaviour 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, Tabout 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 therole="combobox"trigger, so they announce as comboboxes and carry Select'srole="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--popoversurface in both themes and is not clipped by the Date Picker popover, which sets nooverflowclip on its content. -
The Date Picker Popover is non-modal — the page behind it is never
inertand focus is not trapped. On open, focus moves into the grid; on select or dismissal, it returns to the trigger. -
Contrast:
--primary-foregroundon--primaryand--accent-foregroundon--accentare 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.