Skip to content

Calendar

  • Choosing a day where the calendar is the content: a delivery slot, an availability view
  • Choosing a range of days on the page, such as leave or a stay

When people may also type the date, or space is short, use a DatePicker, which opens this calendar from a field.

October 2026
October 2026
SunMonTueWedThuFriSat
value: "2026-10-14"
import { useState } from 'react';
import { Calendar, Text } from '@arun-dev/ui';
/** Saturdays and Sundays. A date is an ISO string; its weekday comes from a UTC Date. */
const isWeekend = (date: string) => [0, 6].includes(new Date(date).getUTCDay());
export default function CalendarBasics() {
const [date, setDate] = useState<string | null>('2026-10-14');
return (
<div style={{ display: 'grid', gap: 'var(--space-2xs)' }}>
<Calendar
aria-label="Delivery day"
value={date}
onValueChange={setDate}
min="2026-10-07"
max="2026-11-30"
isDateUnavailable={isWeekend}
/>
<Text size="sm" color="secondary" aria-live="polite">
value: {JSON.stringify(date)}
</Text>
</div>
);
}
import { Calendar, RangeCalendar } from '@arun-dev/ui';
<Calendar aria-label="Delivery day" value={date} onValueChange={setDate} />
<RangeCalendar aria-label="Leave" value={range} onValueChange={setRange} maxDays={5} />

Each draws Previous and Next buttons, the month, and a grid of days: a table with the weekdays as column headers and a button per day, named with its full date. Dates are ISO strings, YYYY-MM-DD, in no time zone.

October – November 2026
October 2026
SunMonTueWedThuFriSat
November 2026
SunMonTueWedThuFriSat
value: null
import { useState } from 'react';
import { RangeCalendar, Text, type RangeCalendarProps } from '@arun-dev/ui';
type Range = NonNullable<RangeCalendarProps['value']>;
export default function CalendarRange() {
const [range, setRange] = useState<Range | null>(null);
return (
<div style={{ display: 'grid', gap: 'var(--space-2xs)' }}>
<RangeCalendar
aria-label="Leave"
value={range}
onValueChange={setRange}
maxDays={5}
months={2}
min="2026-10-01"
/>
<Text size="sm" color="secondary" aria-live="polite">
value: {JSON.stringify(range)}
</Text>
</div>
);
}

The first press starts the range and the second ends it, in either order; pressing the same day twice picks a single day. In between, the range follows the pointer or focus, and onValueChange is called only once it is complete.

maxDays limits the range’s length, both ends counted. Once the first day is picked, days further away are disabled. A range can’t span a day that can’t be picked, so days past the first unavailable one are disabled too.

PropDoes
min, maxDays outside are disabled; months past them can’t be shown
isDateUnavailableA function of the ISO date: true disables that day
maxDays(Range) the longest range, in days, counting both ends

A disabled day is drawn struck through and faded. It stays focusable, so the arrow keys keep moving by the calendar, and a screen reader announces it as unavailable — but it can’t be picked.

The grid is one Tab stop: the selected day, or today.

KeyMoves focus to
← / →The previous or next day (swapped in a right-to-left layout)
↑ / ↓The same day of the previous or next week
Home / EndThe first or last day of the week
Page Up / Page DownThe same day of the previous or next month
Shift + Page Up / DownThe same day of the previous or next year
Enter / SpacePicks the focused day

Moving past the month shows the next one. A day that doesn’t exist, such as the 31st of a shorter month, becomes its last day.

October 2026
October 2026
SunMonTueWedThuFriSat
Holiday Festival Birthday12 Booked
2026-10-20Nothing on: the hall is free.
import { useRef, useState, type CSSProperties } from 'react';
import { Calendar, Stack, Text, type CalendarProps } from '@arun-dev/ui';
/** One day as the hall's API returns it. */
type HallDay = {
festival?: string;
holiday?: string;
birthdays?: string[];
booking?: string;
};
/** A stand-in for the API: what is on in the hall, for any range of dates. */
const EVENTS: Record<string, HallDay> = {
'2026-10-02': { holiday: 'Gandhi Jayanti' },
'2026-10-10': { booking: 'Wedding reception, 18:00–23:00' },
'2026-10-11': { booking: 'Wedding reception, 10:00–14:00' },
'2026-10-17': { birthdays: ['Asha'] },
'2026-10-20': { festival: 'Dussehra', holiday: 'Dussehra' },
'2026-10-24': { booking: 'Yoga camp, 06:00–09:00', birthdays: ['Meera'] },
// A festival that is a holiday, on a Sunday, with a birthday.
'2026-11-08': { festival: 'Diwali', holiday: 'Diwali', birthdays: ['Ravi'] },
'2026-11-14': { booking: "Children's day fair, 09:00–17:00" },
'2026-11-24': { festival: 'Guru Nanak Jayanti' },
'2026-12-25': { festival: 'Christmas', holiday: 'Christmas' },
};
function fetchHallDays(start: string, end: string): Promise<Record<string, HallDay>> {
const inRange = Object.entries(EVENTS).filter(([date]) => date >= start && date <= end);
return new Promise((resolve) => setTimeout(() => resolve(Object.fromEntries(inRange)), 700));
}
const isWeekend = (date: string) => [0, 6].includes(new Date(date).getUTCDay());
/** The day's events, in words: for screen readers, the card and the list. */
function describe(day: HallDay | undefined): string[] {
if (!day) return [];
return [
day.festival && `${day.festival} (festival)`,
day.holiday && `${day.holiday}: public holiday`,
...(day.birthdays ?? []).map((name) => `${name}'s birthday`),
day.booking && `Booked: ${day.booking}`,
].filter((line): line is string => !!line);
}
/** Your colours and shapes: festivals pink stars, birthdays rings, holidays the error red. */
const TAG_STYLES: CalendarProps['tagStyles'] = {
festival: { color: '#db2777', mark: 'star' },
birthday: { color: 'var(--color-status-info)', mark: 'ring' },
holiday: { color: 'var(--color-status-error)', mark: 'dot' },
};
const mark = (color: string, shape: 'dot' | 'ring' | 'star'): CSSProperties => ({
display: 'inline-block',
inlineSize: shape === 'star' ? 10 : 7,
blockSize: shape === 'star' ? 10 : 7,
borderRadius: shape === 'star' ? 0 : '50%',
background: shape === 'ring' ? 'none' : color,
border: shape === 'ring' ? `1px solid ${color}` : undefined,
clipPath:
shape === 'star'
? 'polygon(50% 0, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)'
: undefined,
});
export default function CalendarHall() {
const [days, setDays] = useState<Record<string, HallDay>>({});
const [loading, setLoading] = useState(false);
const [date, setDate] = useState<string | null>('2026-10-20');
// The latest request wins: a slow reply for a month already left is dropped.
const latest = useRef('');
const load = ({ start, end }: { start: string; end: string }) => {
const key = `${start}/${end}`;
latest.current = key;
setLoading(true);
fetchHallDays(start, end).then((result) => {
if (latest.current !== key) return;
setDays((known) => ({ ...known, ...result }));
setLoading(false);
});
};
const selected = date ? describe(days[date]) : [];
return (
<Stack direction="row" gap="lg" wrap align="start" style={{ inlineSize: '100%' }}>
<Stack gap="sm">
<Calendar
aria-label="Community hall"
locale="en-US"
value={date}
onValueChange={setDate}
onVisibleRangeChange={load}
loading={loading}
// Booked first, then festivals over holidays: your order, not the built-in one.
tagPriority={['booked', 'festival', 'holiday', 'birthday']}
tagStyles={TAG_STYLES}
getDayInfo={(day) => {
const info = days[day];
const lines = describe(info);
return {
tags: [
isWeekend(day) && 'weekend',
info?.booking && 'booked',
info?.festival && 'festival',
info?.holiday && 'holiday',
(info?.birthdays?.length ?? 0) > 0 && 'birthday',
],
description: lines.join(', ') || undefined,
details: lines.length > 0 && (
<ul>
{lines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
),
};
}}
/>
{/* The legend: every mark the calendar may draw, in words. */}
<Stack direction="row" gap="sm" wrap>
<Text size="xs" color="secondary">
<span style={mark('var(--color-status-error)', 'dot')} /> Holiday
</Text>
<Text size="xs" color="secondary">
<span style={mark('#db2777', 'star')} /> Festival
</Text>
<Text size="xs" color="secondary">
<span style={mark('var(--color-status-info)', 'ring')} /> Birthday
</Text>
<Text size="xs" color="secondary">
<s>12</s> Booked
</Text>
</Stack>
</Stack>
{/* The chosen day's events, beside the calendar: the full list, for every input. */}
<Stack gap="2xs" style={{ minInlineSize: '12rem' }} aria-live="polite">
<Text weight="semibold">{date ?? 'No day chosen'}</Text>
{loading && (
<Text size="sm" color="secondary">
Loading…
</Text>
)}
{!loading && selected.length === 0 && (
<Text size="sm" color="secondary">
Nothing on: the hall is free.
</Text>
)}
{selected.map((line) => (
<Text key={line} size="sm">
{line}
</Text>
))}
</Stack>
</Stack>
);
}

All optional. getDayInfo says what a day is; without it the calendar is as above.

getDayInfo={(date) => ({
tags: [isWeekend(date) && 'weekend', 'festival', 'holiday'], // any number; falsy ones dropped
description: 'Diwali, public holiday', // added to the spoken name
details: <ul>…</ul>, // the card, on hover, focus or tap
})}

Tags can overlap freely: a festival that is a holiday, on a weekend, with a birthday. Each day lists them in data-tags, so your CSS can style any tag: [data-tags~='maintenance'].

One mark per day. The day shows a mark for its tag highest in tagPriority — by default booked, holiday, festival, birthday — named in data-mark; the rest are for the details card. Tags outside tagPriority style, but mark nothing. Mark the exceptions, not the usual: a dot on every free day is noise.

Tag (built in)LooksToken
weekenda muted number, no mark--calendar-tag-weekend-color
holidaya number in the holiday colour, a dot--calendar-tag-holiday-color
festivala star--calendar-tag-festival-color
birthdaya diamond--calendar-tag-birthday-color
bookedstruck through--calendar-tag-booked-color

Your own colours and shapes go in tagStyles, for built-in tags or your own: { festival: { color: '#db2777', mark: 'star' } }. Shapes are dot, ring, star, diamond, bar, strike and none. Every mark is a shape as well as a colour, so it does not rest on colour alone; show a legend beside the calendar, as the example does. On the selected day the mark takes the day’s colour. For anything else per day, dayProps adds attributes to its button.

The details card shows a day’s details: after a short rest of the mouse, at once on keyboard focus — so the arrow keys show each day’s card in turn — and on a tap, which focuses the day. Esc hides it; inside a DatePicker, a second Esc closes the picker. It describes its day to screen readers, as a tooltip does, and it is for reading: links or buttons in it can’t be reached, since the arrow keys and Tab belong to the grid. Put actions beside the calendar, acting on the selected day, as the example’s list does.

getDayInfo is called for every day shown, so it is a lookup in data already loaded. Fetch it from onVisibleRangeChange, called with the first and last day shown when the calendar mounts and whenever the months shown change — one request per view. While it loads, loading marks the grids busy and holds the marks back. Keep the latest request only, as the example does, or let a data library such as React Query cache each month; whether days can be picked before the data arrives is yours, through isDateUnavailable.

captionLayout="dropdown" replaces the month heading with month and year selects, for dates far from today, such as a date of birth. The years run from min’s to max’s, or else from 100 years back to 10 ahead. The heading is kept out of sight, still announcing each new month.

<Calendar aria-label="Date of birth" captionLayout="dropdown" min="1900-01-01" max={today} />

Month and weekday names, digits and the first day of the week come from the browser’s locale, or locale: en-US starts on Sunday, en-GB on Monday. firstDayOfWeek overrides the week’s start, 0 for Sunday to 6 for Saturday. Translate the names of the Previous and Next buttons and the month and year selects with labels.

  • Each day is a button named with its full date, such as “Friday, October 16, 2026”; today has aria-current="date", and the selected days aria-selected.
  • The month heading is a live region, so a screen reader announces the new month as it changes.
  • Each grid is named by its month. Give the calendar an aria-label saying what is picked.
  • Selection is drawn as a filled day, and a range as a band between filled ends; today is bold and in the accent colour.
Calendar props
PropTypeDefaultDescription
valuestring | nullControlled: the selected day, YYYY-MM-DD. Provide onValueChange alongside it.
defaultValuestring | nullInitial selection when uncontrolled. Read once, at mount.
onValueChange(value: string) => voidCalled with the day picked.
minstringThe earliest day that can be picked, as an ISO date. Earlier months cannot be shown.
maxstringThe latest day that can be picked, as an ISO date. Later months cannot be shown.
isDateUnavailable(date: string) => booleanDays that cannot be picked, such as weekends or booked dates. Called with an ISO date.
monthsnumber1How many months to show side by side. They wrap when there is no room.
localestringThe locale for month and weekday names, digits and the first day of the week. Defaults to the browser's.
firstDayOfWeeknumber0 for Sunday to 6 for Saturday. Defaults to the locale's.
labels{ previous?; next?; month?; year? }Names for the Previous and Next buttons and the month and year selects, in English unless replaced.
getDayInfo(date: string) => { tags?; description?; details? }What each day is: tags for marks and styling, a description added to its spoken name, and details for the card. A lookup in data already loaded. Optional.
tagPrioritystring[]['booked', 'holiday', 'festival', 'birthday']The tags that may mark a day, the first winning: a day shows one mark, for its tag highest here. Other tags style, but mark nothing.
tagStylesRecord<string, { color?: string; mark?: 'dot' | 'ring' | 'star' | 'diamond' | 'bar' | 'strike' | 'none' }>The colour and shape of each tag’s mark. Tags left out keep the built-in look, from the --calendar-tag-* tokens.
onVisibleRangeChange(range: { start: string; end: string }) => voidCalled with the first and last day shown when the calendar mounts and whenever the months shown change: fetch their information here.
loadingbooleanfalseDay information is loading: the grids are aria-busy, and the marks wait.
dayProps(date, info) => button attributesExtra attributes for each day’s button, from its date and tags: per-day styling.
captionLayout'label' | 'dropdown''label'dropdown shows month and year selects above the days, to jump to a far-off month such as a date of birth. Years run from min's to max's, or 100 years back to 10 ahead.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
RangeCalendar props
PropTypeDefaultDescription
value{ start: string; end: string } | nullControlled: the selected range, ISO dates with both ends included. Provide onValueChange alongside it.
defaultValue{ start: string; end: string } | nullInitial range when uncontrolled. Read once, at mount.
onValueChange(value: { start: string; end: string }) => voidCalled once a range is complete: after the second pick, never the first.
maxDaysnumberThe most days the range may cover, both ends included: 7 allows the 1st to the 7th. Once a first day is picked, days further away are disabled.
minstringThe earliest day that can be picked, as an ISO date. Earlier months cannot be shown.
maxstringThe latest day that can be picked, as an ISO date. Later months cannot be shown.
isDateUnavailable(date: string) => booleanDays that cannot be picked, such as weekends or booked dates. Called with an ISO date.
monthsnumber1How many months to show side by side. They wrap when there is no room.
localestringThe locale for month and weekday names, digits and the first day of the week. Defaults to the browser's.
firstDayOfWeeknumber0 for Sunday to 6 for Saturday. Defaults to the locale's.
labels{ previous?; next?; month?; year? }Names for the Previous and Next buttons and the month and year selects, in English unless replaced.
getDayInfo(date: string) => { tags?; description?; details? }What each day is: tags for marks and styling, a description added to its spoken name, and details for the card. A lookup in data already loaded. Optional.
tagPrioritystring[]['booked', 'holiday', 'festival', 'birthday']The tags that may mark a day, the first winning: a day shows one mark, for its tag highest here. Other tags style, but mark nothing.
tagStylesRecord<string, { color?: string; mark?: 'dot' | 'ring' | 'star' | 'diamond' | 'bar' | 'strike' | 'none' }>The colour and shape of each tag’s mark. Tags left out keep the built-in look, from the --calendar-tag-* tokens.
onVisibleRangeChange(range: { start: string; end: string }) => voidCalled with the first and last day shown when the calendar mounts and whenever the months shown change: fetch their information here.
loadingbooleanfalseDay information is loading: the grids are aria-busy, and the marks wait.
dayProps(date, info) => button attributesExtra attributes for each day’s button, from its date and tags: per-day styling.
captionLayout'label' | 'dropdown''label'dropdown shows month and year selects above the days, to jump to a far-off month such as a date of birth. Years run from min's to max's, or 100 years back to 10 ahead.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
  • Several months sit side by side with months, and wrap onto rows where there’s no room.
  • Restyle with the --calendar-* tokens, such as --calendar-day-size or --calendar-day-selected-bg.