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 month and year selects plus the Month/Year buttons appear with showViewControls.

Sep 2026
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) => voidFires when a day is chosen
rangeIDateRangeSelected span; providing it switches the grid to ranges
onRangeChange(range: IDateRange) => voidFires 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
minDateEarliest selectable day
maxDateLatest selectable day
referenceDateDateAnchors "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