Calendar
A date grid with day, month and decade views.
Introduction
One surface, four layouts: a single month, two months side by side for ranges, the twelve months of a year, and a decade of years. The month and decade grids drill down — pick a year, then a month, then a day.
Measured from the UX4G kit at 1:1: today is a 1px primary outline, the selected day is filled primary, a range fills primary-50, and the card carries a shadow rather than a border.
Two deliberate departures. The kit's day cells are 24px on a 41px pitch; these are 36px on a 37px pitch, so the footprint is unchanged but the target clears WCAG 2.5.8 comfortably. And in the two-month view the kit repeats the full arrow set under both headers — here the back arrows sit on the first month and the forward arrows on the last, because four controls that move the same window read as four different destinations to a screen reader.
Most pages want Date Picker instead, which puts this grid behind a trigger.
Installation
npx shadcn@latest add https://indiacn.in/r/calendar.jsonimport { Calendar } from '@/components/ui/calendar';Usage
const [date, setDate] = useState<Date | null>(null);
return <Calendar value={date} onValueChange={setDate} showViewControls />;Examples
Default
The year and month selects plus the Month/Year buttons appear with showViewControls. When they are shown the arrow row is hidden — the kit's one-month card navigates by select, and offering both stacks two ways to do the same thing.
Every view holds the same width, so drilling from days to months to years does not resize the card under the pointer.
No date chosen yet.
'use client';
import { useState } from 'react';
import { Calendar } from '@/components/ui/calendar';
export default function Component() {
const [date, setDate] = useState<Date | null>(null);
return <Calendar value={date} onValueChange={setDate} showViewControls />;
}Range
Passing range switches the grid to spans. The first click sets the start, the second the end; clicking before the start begins a new span.
'use client';
import { useState } from 'react';
import { Calendar, IDateRange } from '@/components/ui/calendar';
export default function Component() {
const [range, setRange] = useState<IDateRange>({ from: null, to: null });
return <Calendar months={2} range={range} onRangeChange={setRange} />;
}Month view
'use client';
import { useCallback, useState } from 'react';
import { Calendar, IDateRange } from '@/components/ui/calendar';
import { Body3 } from '@/components/ui/typography';
export function CalendarDefault() {
const [date, setDate] = useState<Date | null>(null);
return (
<div className='flex flex-col gap-4'>
<Calendar value={date} onValueChange={setDate} showViewControls />
<Body3 className='text-neutral-600'>
{date ? date.toDateString() : 'No date chosen yet.'}
</Body3>
</div>
);
}
export function CalendarRange() {
const [range, setRange] = useState<IDateRange>({ from: null, to: null });
return <Calendar months={2} range={range} onRangeChange={setRange} />;
}
export function CalendarMonthView() {
const [date, setDate] = useState<Date | null>(null);
const handleChange = useCallback((next: Date) => setDate(next), []);
return <Calendar value={date} onValueChange={handleChange} defaultView='month' />;
}
export function CalendarYearView() {
const [date, setDate] = useState<Date | null>(null);
const handleChange = useCallback((next: Date) => setDate(next), []);
return <Calendar value={date} onValueChange={handleChange} defaultView='year' />;
}
export function CalendarBounded() {
const [date, setDate] = useState<Date | null>(null);
const today = new Date();
const min = new Date(today.getFullYear(), today.getMonth(), 1);
const max = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return (
<div className='flex flex-col gap-4'>
<Calendar value={date} onValueChange={setDate} min={min} max={max} />
<Body3 className='text-neutral-600'>
Only days in the current month can be chosen; everything outside the range is disabled.
</Body3>
</div>
);
}
Year view
The decade grid shows the year either side of the decade in grey, matching the kit.
'use client';
import { useCallback, useState } from 'react';
import { Calendar, IDateRange } from '@/components/ui/calendar';
import { Body3 } from '@/components/ui/typography';
export function CalendarDefault() {
const [date, setDate] = useState<Date | null>(null);
return (
<div className='flex flex-col gap-4'>
<Calendar value={date} onValueChange={setDate} showViewControls />
<Body3 className='text-neutral-600'>
{date ? date.toDateString() : 'No date chosen yet.'}
</Body3>
</div>
);
}
export function CalendarRange() {
const [range, setRange] = useState<IDateRange>({ from: null, to: null });
return <Calendar months={2} range={range} onRangeChange={setRange} />;
}
export function CalendarMonthView() {
const [date, setDate] = useState<Date | null>(null);
const handleChange = useCallback((next: Date) => setDate(next), []);
return <Calendar value={date} onValueChange={handleChange} defaultView='month' />;
}
export function CalendarYearView() {
const [date, setDate] = useState<Date | null>(null);
const handleChange = useCallback((next: Date) => setDate(next), []);
return <Calendar value={date} onValueChange={handleChange} defaultView='year' />;
}
export function CalendarBounded() {
const [date, setDate] = useState<Date | null>(null);
const today = new Date();
const min = new Date(today.getFullYear(), today.getMonth(), 1);
const max = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return (
<div className='flex flex-col gap-4'>
<Calendar value={date} onValueChange={setDate} min={min} max={max} />
<Body3 className='text-neutral-600'>
Only days in the current month can be chosen; everything outside the range is disabled.
</Body3>
</div>
);
}
Bounded
Only days in the current month can be chosen; everything outside the range is disabled.
'use client';
import { useCallback, useState } from 'react';
import { Calendar, IDateRange } from '@/components/ui/calendar';
import { Body3 } from '@/components/ui/typography';
export function CalendarDefault() {
const [date, setDate] = useState<Date | null>(null);
return (
<div className='flex flex-col gap-4'>
<Calendar value={date} onValueChange={setDate} showViewControls />
<Body3 className='text-neutral-600'>
{date ? date.toDateString() : 'No date chosen yet.'}
</Body3>
</div>
);
}
export function CalendarRange() {
const [range, setRange] = useState<IDateRange>({ from: null, to: null });
return <Calendar months={2} range={range} onRangeChange={setRange} />;
}
export function CalendarMonthView() {
const [date, setDate] = useState<Date | null>(null);
const handleChange = useCallback((next: Date) => setDate(next), []);
return <Calendar value={date} onValueChange={handleChange} defaultView='month' />;
}
export function CalendarYearView() {
const [date, setDate] = useState<Date | null>(null);
const handleChange = useCallback((next: Date) => setDate(next), []);
return <Calendar value={date} onValueChange={handleChange} defaultView='year' />;
}
export function CalendarBounded() {
const [date, setDate] = useState<Date | null>(null);
const today = new Date();
const min = new Date(today.getFullYear(), today.getMonth(), 1);
const max = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return (
<div className='flex flex-col gap-4'>
<Calendar value={date} onValueChange={setDate} min={min} max={max} />
<Body3 className='text-neutral-600'>
Only days in the current month can be chosen; everything outside the range is disabled.
</Body3>
</div>
);
}
Accessibility
The day grid follows the ARIA grid pattern:
| Key | Moves |
|---|---|
| ← → | One day |
| ↑ ↓ | One week |
| Home End | Start and end of the week |
| PageUp PageDown | One month |
| Shift + PageUp/Down | One year |
- Only the focused cell is tabbable, so the whole grid is a single tab stop rather than forty-two.
- Each day's accessible name is the full date — "21 October 2021" — not the bare number, which on its own could be any month.
- Today carries
aria-current='date'and the chosen dayaria-selected, so neither state depends on colour. - Days outside the shown month are disabled rather than dimmed-and-clickable, so their grey never has to pass a contrast rule it cannot meet.
- The arrows and the month/year selects all carry text labels; the chevrons are
aria-hidden.
API Reference
Calendar
| Prop | Type | Default | Description |
|---|---|---|---|
value | Date | null | null | Selected day in single-select mode |
onValueChange | (date: Date) => void | — | Fires when a day is chosen |
range | IDateRange | — | Selected span; providing it switches the grid to ranges |
onRangeChange | (range: IDateRange) => void | — | Fires on each end of the span |
months | 1 | 2 | 1 | Months shown side by side |
defaultView | 'day' | 'month' | 'year' | 'day' | Which grid opens first |
showViewControls | boolean | false | Shows the month and year selects and the view toggle |
fixedWeeks | boolean | false | Always render six week rows, even an all-outside one |
weekStartsOn | 0 | 1 | 1 | Sunday or Monday; the kit starts on Monday |
min | Date | — | Earliest selectable day |
max | Date | — | Latest selectable day |
referenceDate | Date | — | Anchors "today" and the opening month; fix it in tests |
IDateRange is { from: Date | null; to: Date | null }.
The module also exports the date helpers it uses — startOfDay, addDays, addMonths, isSameDay, MONTH_LABELS and MONTH_LABELS_LONG — so a consumer does not have to add a date library for the same four operations.