Calendar
When to use
Section titled “When to use”- 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.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
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> );}Anatomy
Section titled “Anatomy”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.
Picking a range
Section titled “Picking a range”| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
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.
Limits
Section titled “Limits”| Prop | Does |
|---|---|
min, max | Days outside are disabled; months past them can’t be shown |
isDateUnavailable | A 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.
Keyboard
Section titled “Keyboard”The grid is one Tab stop: the selected day, or today.
| Key | Moves focus to |
|---|---|
| ← / → | The previous or next day (swapped in a right-to-left layout) |
| ↑ / ↓ | The same day of the previous or next week |
| Home / End | The first or last day of the week |
| Page Up / Page Down | The same day of the previous or next month |
| Shift + Page Up / Down | The same day of the previous or next year |
| Enter / Space | Picks 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.
What each day is: tags, marks and details
Section titled “What each day is: tags, marks and details”| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
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) | Looks | Token |
|---|---|---|
weekend | a muted number, no mark | --calendar-tag-weekend-color |
holiday | a number in the holiday colour, a dot | --calendar-tag-holiday-color |
festival | a star | --calendar-tag-festival-color |
birthday | a diamond | --calendar-tag-birthday-color |
booked | struck 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.
Loading from an API
Section titled “Loading from an API”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.
Jumping to a month and year
Section titled “Jumping to a month and year”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} />Locale
Section titled “Locale”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.
Accessibility
Section titled “Accessibility”- Each day is a button named with its full date, such as “Friday, October 16, 2026”; today has
aria-current="date", and the selected daysaria-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-labelsaying 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
Section titled “Calendar”| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | Controlled: the selected day, YYYY-MM-DD. Provide onValueChange alongside it. | |
defaultValue | string | null | Initial selection when uncontrolled. Read once, at mount. | |
onValueChange | (value: string) => void | Called with the day picked. | |
min | string | The earliest day that can be picked, as an ISO date. Earlier months cannot be shown. | |
max | string | The latest day that can be picked, as an ISO date. Later months cannot be shown. | |
isDateUnavailable | (date: string) => boolean | Days that cannot be picked, such as weekends or booked dates. Called with an ISO date. | |
months | number | 1 | How many months to show side by side. They wrap when there is no room. |
locale | string | The locale for month and weekday names, digits and the first day of the week. Defaults to the browser's. | |
firstDayOfWeek | number | 0 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. | |
tagPriority | string[] | ['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. |
tagStyles | Record<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 }) => void | Called with the first and last day shown when the calendar mounts and whenever the months shown change: fetch their information here. | |
loading | boolean | false | Day information is loading: the grids are aria-busy, and the marks wait. |
dayProps | (date, info) => button attributes | Extra 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. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
RangeCalendar
Section titled “RangeCalendar”| Prop | Type | Default | Description |
|---|---|---|---|
value | { start: string; end: string } | null | Controlled: the selected range, ISO dates with both ends included. Provide onValueChange alongside it. | |
defaultValue | { start: string; end: string } | null | Initial range when uncontrolled. Read once, at mount. | |
onValueChange | (value: { start: string; end: string }) => void | Called once a range is complete: after the second pick, never the first. | |
maxDays | number | The 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. | |
min | string | The earliest day that can be picked, as an ISO date. Earlier months cannot be shown. | |
max | string | The latest day that can be picked, as an ISO date. Later months cannot be shown. | |
isDateUnavailable | (date: string) => boolean | Days that cannot be picked, such as weekends or booked dates. Called with an ISO date. | |
months | number | 1 | How many months to show side by side. They wrap when there is no room. |
locale | string | The locale for month and weekday names, digits and the first day of the week. Defaults to the browser's. | |
firstDayOfWeek | number | 0 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. | |
tagPriority | string[] | ['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. |
tagStyles | Record<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 }) => void | Called with the first and last day shown when the calendar mounts and whenever the months shown change: fetch their information here. | |
loading | boolean | false | Day information is loading: the grids are aria-busy, and the marks wait. |
dayProps | (date, info) => button attributes | Extra 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. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything 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-sizeor--calendar-day-selected-bg.