Skip to content

Calendar

A month grid. It picks one day, a handful of days, or a range across as many months as you show at once, and it is the thing a date picker is built out of — the popover example is nine lines.

calendar/basic.tsx
September 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday

Import

import { Calendar } from "@forte-ui/react";

One component, not a namespace of parts. A calendar's anatomy is generated from a date rather than composed by hand, so there is nothing useful to hand you the pieces of — what varies is which days are pickable and how they are labelled, and those are props.

It carries no date library and no locale tables. Month arithmetic goes through the Date constructor and every name on screen — months, weekdays, day numbers — comes from Intl.DateTimeFormat, which the browser already has. The whole component adds nothing to your bundle beyond itself.

Selection

mode decides what selected holds, and the component is generic over it, so the callback is typed to match — onSelect={setDate} against a plain useState<Date>() typechecks without a cast.

modeselectedClicking the current selection
"single" (default)Dateclears it, unless required
"multiple"Date[]removes that day
"range"{ from, to }starts a new range

Pass selected to control the calendar and leave it off to let the calendar own its state. Which of the two you get is decided from whether the prop was passed, not from whether it is currently defined — useState<Date>() starts at undefined, and a component that read that as "uncontrolled" would silently switch modes on the first click.

onSelect receives null once the last day is cleared, so a controlled Date | null state takes it directly.

A range

calendar/range.tsx
September 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday
October 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday

Ranges are picked in two clicks, and the half-picked state is visible: with one end committed, the day under the pointer — or under keyboard focus — stands in for the other, so the span you are about to commit is drawn before you commit it. Clicking behind the first end swaps the two rather than throwing the pick away.

numberOfMonths shows several months side by side. The arrows still move one month at a time, which is what you want when comparing the end of one month with the start of the next; pagedNavigation makes them jump a whole page instead.

Several days

calendar/multiple.tsx
September 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday

Month and year dropdowns

calendar/dropdown.tsx
June 1995
SundayMondayTuesdayWednesdayThursdayFridaySaturday

captionLayout="dropdown" swaps the month and year label for two selects — or use "dropdown-months" / "dropdown-years" for one of the two. It is the difference between two clicks and two hundred and forty when the target is a date of birth.

minDate and maxDate do three jobs from one prop each: they stop the arrows at the ends, they trim the year dropdown to exactly that span, and they disable every day outside it.

Days that cannot be picked

calendar/disabled-dates.tsx
August 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday

disabled takes a matcher — a Date, a { from, to } range, { before } or { after }, { dayOfWeek }, a predicate, or an array of any of those, in which case a day is blocked if any one of them claims it.

Disabled days are marked with aria-disabled rather than the disabled attribute, and stay focusable. A disabled button cannot take focus, so a blocked-out week would trap arrow-key navigation on the day before it — the reader would press five times and go nowhere with no way to know why.

Week numbers, and a height that does not move

calendar/week-numbers.tsx
February 2026
WeekMondayTuesdayWednesdayThursdayFridaySaturdaySunday
5
6
7
8
9
10

showWeekNumbers adds a leading column of ISO 8601 week numbers. Those weeks run Monday to Sunday by definition, so pair it with weekStartsOn={1} or the numbers in the gutter will not describe the rows beside them.

fixedWeeks always renders six rows. A month needs four, five or six depending on where it starts, so without it a calendar in a popover changes height as you page through the year — and a popup that resizes under the pointer moves its own buttons out from under the cursor.

Localisation

calendar/locale.tsx
August 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday

locale is a BCP 47 tag and drives every string the grid renders, day numbers included — ar-EG above prints Arabic-Indic digits without the component knowing what a digit is.

weekStartsOn is a separate prop rather than a lookup from the locale, because it cannot be read from Intl in every browser. A silent fallback would move the columns for some of your users and not others, which is worse than one explicit number.

The chrome's own strings — the two arrow labels, the dropdown labels, the week-number column header — come from labels, which is merged over the English defaults, so you can override one without restating the rest.

Sizes

calendar/sizes.tsx
August 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday
size="sm"
August 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday
size="md"
August 2026
SundayMondayTuesdayWednesdayThursdayFridaySaturday
size="lg"

Three sizes, set once on the root. Cell size follows data-forte-density too, so a compact app gets a compact md for free.

A date picker

A calendar behind a field is DatePicker — a Popover around this component, plus the two things that composition always needs anyway: one place that owns the selection, since the field's text and the grid sit on opposite sides of a portal, and a rule for when picking a day closes the popup.

<DatePicker.Root selected={date} onSelect={setDate}>
  <DatePicker.Trigger aria-label="Due date">
    <DatePicker.Value placeholder="Pick a date" />
    <DatePicker.Icon />
  </DatePicker.Trigger>
  <DatePicker.Popup>
    <DatePicker.Calendar />
  </DatePicker.Popup>
</DatePicker.Root>

Use Calendar directly when it lives in the page — a booking grid, a dashboard, a settings panel. Reach for DatePicker when it lives behind a field.

One prop differs between the two: autoFocus. It is off on Calendar, because moving focus on mount drops a screen reader user out of whatever they were reading, and on inside DatePicker, where a press just opened the popup and the first arrow key should move a day rather than do nothing.

Accessibility

The grid is a real <table role="grid">, named by its month caption, with a roving tabindex: the whole calendar is one Tab stop and the arrows move within it. Tabbing through a month otherwise costs between twenty-eight and forty-two presses to get past.

Keyboard interactions
KeyBehaviour
TabEnters the grid at the selected day, or today, or the first of the month — the first of those that is on screen. Leaves the grid entirely on the next press.
← / →Moves one day. Follows the writing direction: in RTL the left arrow moves forward, because that is the direction the columns run.
↑ / ↓Moves one week.
Home / EndFirst and last day of the focused week.
Page Up / Page DownMoves one month, keeping the day of the month — clamped to the target month's length, so the 31st lands on the 28th of February rather than skipping it.
Shift + Page Up / Page DownMoves one year.
Enter then SpacePicks the focused day.

Moving past the edge of the displayed months pages the calendar and keeps focus on the day you asked for, so a run of walks straight from one month into the next.

What the grid says about itself:

  • Each day is named in full — "Thursday, August 27, 2026" — rather than being read as a bare number that means nothing out of its column.
  • Today carries aria-current="date", and a dot under the number. The dot is there so "today" is not a hue you have to be able to tell from the foreground.
  • Selected days, and every day between the two ends of a range, sit in cells marked aria-selected. A hover preview does not claim to be one: it is a guess, not a decision.
  • The month caption is a live region, so paging with the arrows — which changes nothing else a screen reader can see — is announced.
  • The weekday headers show two letters and announce the whole name. "Mo" is read as a word.
  • Under forced colors the selection repaints from the system Highlight pair, and outside days fall back to GrayText — they lose their muted colour otherwise and become indistinguishable from the month's own days.

footer renders inside a role="status" region, which is where a summary like "3 nights selected" belongs: it is a consequence of the selection, and it should be announced when it changes without moving focus.

Theming

Every property below is declared on the calendar's own root, so override them there — through className or an inline style — not on an ancestor, where the root's own declaration would beat the inherited value.

One knob is worth knowing before the rest: --forte-calendar-cell-size is the measure everything else derives from. The arrows, the caption row's height and the column widths all read it, so resizing a calendar past the three size presets is one value, not six.

Theming tokens for Calendar
PropertyControlsDefault
--forte-calendar-cell-sizeSize of one day cell, and the measure everything else derives fromvar(--forte-control-h-md)
--forte-calendar-nav-sizeSize of the two navigation arrowsvar(--forte-calendar-cell-size)
--forte-calendar-gapSpace between months shown side by sidevar(--forte-space-5)
--forte-calendar-head-gapSpace between the weekday header and the first weekvar(--forte-space-1)
--forte-calendar-day-gapSpace around a day inside its cell. 0px fuses the grid into one block2px
--forte-calendar-caption-font-sizeMonth and year text sizevar(--forte-font-size-2)
--forte-calendar-caption-font-weightMonth and year text weightvar(--forte-font-weight-semibold)
--forte-calendar-caption-colorMonth and year colourvar(--forte-color-foreground)
--forte-calendar-weekday-font-sizeWeekday initials text sizevar(--forte-font-size-1)
--forte-calendar-weekday-colorWeekday initials colourvar(--forte-color-foreground-subtle)
--forte-calendar-week-number-colorISO week-number colourvar(--forte-color-foreground-subtle)
--forte-calendar-day-font-sizeDay number text sizevar(--forte-font-size-2)
--forte-calendar-day-radiusCorner radius of a dayvar(--forte-radius-control)
--forte-calendar-day-colorDay number colourvar(--forte-color-foreground)
--forte-calendar-day-color-outsideColour of days belonging to the neighbouring monthvar(--forte-color-foreground-subtle)
--forte-calendar-day-bg-hoverDay fill on hovervar(--forte-color-panel-hover)
--forte-calendar-day-selected-bgFill of a selected day, and of the two ends of a rangevar(--forte-color-primary)
--forte-calendar-day-selected-bg-hoverFill of a selected day on hovervar(--forte-color-primary-hover)
--forte-calendar-day-selected-colorText colour on a selected dayvar(--forte-color-on-primary)
--forte-calendar-range-bgFill of the days between the two ends of a rangevar(--forte-color-primary-soft)
--forte-calendar-preview-bgFill of the band a half-picked range shows under the pointervar(--forte-color-panel-hover)
--forte-calendar-today-colorColour of today's numbervar(--forte-color-primary-text)
--forte-calendar-today-dot-sizeDiameter of the dot under today's number. 0px removes it3px
--forte-calendar-durationLength of a day's hover and selection crossfadevar(--forte-duration-fast)
--forte-calendar-easeCurve of a day's hover and selection crossfadevar(--forte-ease-standard)

The parts expose their state as data attributes — data-size and data-mode on the root; data-selected, data-today, data-outside, data-in-range on days; data-range-start, data-range-end, data-preview on their cells — so a Tailwind arbitrary variant such as data-[today]:... can target them without a wrapper.

Both navigation arrows are Buttons, so they answer to navVariant and to every --forte-button-* knob set on the calendar. They are told apart by data-nav="previous" / data-nav="next".

API reference

Props for Calendar
PropTypeDefaultDescription
autoFocusbooleanfalseFocus the calendar's active day on mount. Use it when the calendar opens in a popover, not on a calendar sitting in the page.
captionLayoutCalendarCaptionLayoutlabelWhat sits between the arrows: a static month and year, or dropdowns for one or both. Dropdowns turn a birthday twenty years back into two clicks instead of two hundred and forty.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
defaultMonthDatethe month of the selection, or the current monthThe month an uncontrolled calendar opens on.
defaultSelectedDate | CalendarRange | Date[] | nullThe selection an uncontrolled calendar starts with.
disabledCalendarMatcherDays that cannot be picked, as a date, a range, `{ before }` / `{ after }`, `{ dayOfWeek }`, a predicate, or an array of any of those. Disabled days stay focusable so keyboard navigation can cross them.
fixedWeeksbooleanfalseAlways render six rows, so the calendar keeps one height all year and a popover containing it never resizes as you page through it.
footerReactNodeRendered under the grid inside a `role="status"` region, so a summary like "3 nights selected" is announced as it changes.
labelsCalendarLabelsChrome strings — arrow and dropdown labels. Merged over the English defaults.
localestringen-USBCP 47 tag used for every month, weekday and day name. Pinned rather than left to the runtime on purpose: `undefined` resolves to the server's locale during SSR and the browser's on the client, and "August" hydrating over "août" is a mismatch React can only fix by throwing the markup away.
maxDateDateLatest selectable day. The upper half of `minDate`.
minDateDateEarliest selectable day. Also stops navigation and trims the year dropdown, so one prop bounds the calendar in every direction at once.
modeCalendarModesingleHow many days can be picked at once. `"single"` selects one `Date`, `"multiple"` a `Date[]`, `"range"` a `{ from, to }` object.
monthDateThe month on display, as any date within it. Pass it with `onMonthChange` to drive navigation yourself.
navVariantButtonVariantghostVisual weight of the two navigation arrows — any `Button` variant.
numberOfMonthsnumber1How many months to show side by side.
onMonthChange((month: Date) => void)Called with the first day of the new leading month whenever navigation moves.
onSelect((selection: CalendarSelection<M> | null, day: Date) => void)Called with the next selection and the day that was clicked. The selection is `null` once the last pick is cleared, so a controlled `useState<Date>()` takes it directly.
pagedNavigationbooleanfalseMove a whole page at a time — with `numberOfMonths={2}`, the arrows jump two months instead of one.
requiredbooleanfalseKeep at least one day selected: clicking the current selection no longer clears it.
selectedDate | CalendarRange | Date[] | nullThe current selection. Pass it with `onSelect` to control the calendar; leave it off entirely to let the calendar own its state.
showOutsideDaysbooleantrueFill the first and last rows with the neighbouring months' days instead of leaving them blank.
showWeekNumbersbooleanfalseAdd a leading column of ISO 8601 week numbers.
sizeCalendarSizemdCell size and text size. Follows the ambient `data-forte-density` too.
todayDateToday's date. Override it to pin the "today" marker in tests or stories.
weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 60First column of the week — 0 is Sunday, 1 Monday, 6 Saturday. Not derived from `locale`: `Intl` cannot be asked for it in every browser, and a silent fallback would move the columns under some of your users and not others.