IndiaCN UI

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.json
import { 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.

MonTueWedThuFriSatSun

No date chosen yet.

CalendarDefault
'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.

Sep 2026
MonTueWedThuFriSatSun
Oct 2026
MonTueWedThuFriSatSun
CalendarRange
'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

2026
CalendarMonthView
'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.

2020-2029
CalendarYearView
'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

Sep 2026
MonTueWedThuFriSatSun

Only days in the current month can be chosen; everything outside the range is disabled.

CalendarBounded
'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:

KeyMoves
← →One day
↑ ↓One week
Home EndStart and end of the week
PageUp PageDownOne month
Shift + PageUp/DownOne 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 day aria-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

PropTypeDefaultDescription
valueDate | nullnullSelected day in single-select mode
onValueChange(date: Date) => void—Fires when a day is chosen
rangeIDateRange—Selected span; providing it switches the grid to ranges
onRangeChange(range: IDateRange) => void—Fires on each end of the span
months1 | 21Months shown side by side
defaultView'day' | 'month' | 'year''day'Which grid opens first
showViewControlsbooleanfalseShows the month and year selects and the view toggle
fixedWeeksbooleanfalseAlways render six week rows, even an all-outside one
weekStartsOn0 | 11Sunday or Monday; the kit starts on Monday
minDate—Earliest selectable day
maxDate—Latest selectable day
referenceDateDate—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.

On this page