Calendar Preview

A subcomposed calendar that owns its selection and view state.

1<CalendarPreview defaultMonth={new Date(2024, 3, 1)}>
2 <CalendarPreview.Days />
3</CalendarPreview>

Later RFC 005 phases may still reshape props, slots and useCalendar()'s return, so pin an exact version if you adopt it now. Calendar and DatePicker remain the supported choice.

Playground

Anatomy

Every part renders its own default, so composition is opt-in depth:

1import { CalendarPreview } from '@raystack/apsara'
2
3<CalendarPreview>
4 <CalendarPreview.Days />
5</CalendarPreview>

Expanded, the day view is a header and a grid:

1<CalendarPreview>
2 <CalendarPreview.Days>
3 <CalendarPreview.Header>
4 <CalendarPreview.Caption />
5 <CalendarPreview.Reset />
6 <CalendarPreview.PrevMonth />
7 <CalendarPreview.NextMonth />
8 </CalendarPreview.Header>
9 <CalendarPreview.Grid />
10 </CalendarPreview.Days>
11 <CalendarPreview.Footer />
12</CalendarPreview>

.Grid renders the day cells itself and takes no children. .Day and .Weekday are not written inside it — they are overrides, passed through components:

1<CalendarPreview.Grid components={{ DayButton: MyDay, Weekday: MyWeekday }} />

Children override the content a part computes from context, so <CalendarPreview.Caption>Q3 2024</CalendarPreview.Caption> replaces the month label.

API Reference

CalendarPreview

The root. Owns the selected value and the visible month, provides both to every part, and renders a column that hugs its content. Also takes render, className and ref.

Prop

Type

CalendarPreview.Days

The day view — a header and a grid. Hugs its content rather than reserving a fixed height.

Prop

Type

CalendarPreview.Caption

The month label above the grid, and optionally the trigger for the month and year scroller.

Prop

Type

CalendarPreview.Grid

The day grid. Layout and per-day data live here rather than on the root, so a calendar with two grids can configure them independently.

Prop

Type

CalendarPreview.Header

The row above the grid. Composes .Caption, .Reset, .PrevMonth and .NextMonth when given no children. Takes render, className and ref.

Prop

Type

CalendarPreview.PrevMonth / CalendarPreview.NextMonth

Step the view one month. Never disabled by minDate or maxDate — bounds limit selection, not navigation.

Prop

Type

CalendarPreview.Reset

Restores defaultDate, reporting reason: 'reset'. Rendered whenever defaultDate is set, and disabled once the value already equals it — it stays mounted rather than disappearing, so activating it does not send focus to the page body or shift the nav buttons sideways. It carries data-restored while there is nothing to restore.

Prop

Type

CalendarPreview.Trigger

Anchors the popover and owns opening it. Renders the formatted value, or the placeholder, when given no children — wrap an .Input in it for a typeable field. Never renders a button, so the control inside stays focusable. Takes render, className and ref.

CalendarPreview.Content

The portaled popover surface. Takes Popover.Content props — side, align, sideOffset and the rest — and flips above the trigger on collision.

CalendarPreview.Input

Prop

Type

CalendarPreview.Body

The popup body: label, input, scale switcher and the view for the active scale. Renders all four when given no children. Takes render, className and ref.

CalendarPreview.Scales / CalendarPreview.Scale

The scale switcher, built on Apsara Tabs. Renders nothing when only one scale is offered, so a plain day calendar never grows a one-tab row. .Scale is only needed to relabel or reorder.

CalendarPreview.Panel

The view container. Mounts all five views; each gates on the active scale itself, so .Quarters can be mounted alone with no day grid in the tree.

CalendarPreview.Months / .Quarters / .HalfYears / .Years

Year-grouped period lists at 3, 4, 2 and 1 columns. Each is one continuous 320px scroll area with the year numbers as headings inside it, opening on the active year.

CalendarPreview.Label / CalendarPreview.Separator

The field label above the input, and the rule between the switcher and the view.

CalendarPreview.Footer

The row below the calendar. A bare string is wrapped in Text; anything else renders as given.

It needs no container of its own: the root renders a column that hugs its content, so .Days and .Footer stack whatever the surrounding layout does.

Prop

Type

useCalendar

Reads the enclosing root's state, for building parts the library does not ship. Deliberately narrow:

1import { useCalendar } from '@raystack/apsara'
2
3const { value, setValue, scale, month, setMonth, isDateUnavailable } = useCalendar()

Calling it outside a CalendarPreview throws, naming the part that asked. scale is read-only for now — the setter arrives with the scale switcher in a later phase.

setValue(null) clears the selection and reports reason: 'clear', carrying the day that was cleared as details.toDate().

Prop

Type

Prop

Type

Slots

Every rendered part carries a stable data-slot attribute for styling and testing:

SlotElement
calendar-previewThe root, a column wrapping the parts
calendar-preview-triggerThe popover anchor
calendar-preview-contentThe portaled popover surface
calendar-preview-inputThe typeable date field
calendar-preview-daysThe day view surface
calendar-preview-headerThe header row, single-month layout
calendar-preview-month-headerOne month's header, when several months are shown
calendar-preview-captionThe month label, single-month layout
calendar-preview-month-header-captionOne month's label, when several months are shown
calendar-preview-caption-positionerThe scroller's positioning wrapper
calendar-preview-caption-popupThe month and year scroller (when dropdown is open)
calendar-preview-caption-monthsThe month column of the scroller
calendar-preview-caption-monthOne month in the scroller
calendar-preview-caption-yearsThe year column of the scroller
calendar-preview-caption-yearOne year in the scroller
calendar-preview-resetThe reset button
calendar-preview-prev-monthThe previous-month button
calendar-preview-next-monthThe next-month button
calendar-preview-gridThe grid root
calendar-preview-weeksWrapper around the table and its skeleton
calendar-preview-tableThe <table> that holds the days
calendar-preview-skeletonThe loading skeleton shown over the grid
calendar-preview-weekdayOne weekday heading
calendar-preview-week-numberOne week-number cell (when showWeekNumber)
calendar-preview-week-number-headerThe week-number column heading
calendar-preview-day-triggerThe tooltip trigger wrapping each day button
calendar-preview-dayThe <button> for a single day
calendar-preview-day-numberThe day number inside a day button
calendar-preview-day-infoContent above the number (when dateInfo resolves)
calendar-preview-day-tooltipThe tooltip shown on hover
calendar-preview-bodyThe popup body
calendar-preview-labelThe field label
calendar-preview-scalesThe scale switcher
calendar-preview-scaleOne scale chip
calendar-preview-separatorThe rule below the switcher
calendar-preview-panelThe view container
calendar-preview-months / -quarters / -half-years / -yearsOne period list
calendar-preview-period-groupOne year's block inside a period list
calendar-preview-period-yearThe year heading
calendar-preview-periodOne period cell
calendar-preview-footerThe footer row
calendar-preview-footer-textThe Text wrapping a string footer

Day cells also carry their state, so a stylesheet can target it without a class:

AttributeSet when
data-selectedThe day is the committed value
data-draftThe day has roving focus but is not committed
data-unavailableThe day is out of bounds or rejected by isDateUnavailable
data-todayThe day is today
data-outsideThe day belongs to an adjacent month
data-scaleThe granularity the value is committed at

Examples

Composition

Each part renders a default; children replace it.

1<CalendarPreview defaultMonth={new Date(2024, 3, 1)}>
2 <CalendarPreview.Days />
3</CalendarPreview>

Reset

.Reset restores defaultDate and leaves the visible month alone — it is a value reset, not a view reset. It renders whenever defaultDate is set, and goes disabled once there is nothing left to restore rather than unmounting: removing the focused element would strand a keyboard user, and dropping a child from the header would shift both nav buttons sideways every time the value crossed the default.

defaultDate follows the selection. At selection="range" it takes a range, and both edges have to match before the button counts as restored:

1<CalendarPreview
2 selection="range"
3 defaultDate={{ from: new Date(2024, 3, 10), to: new Date(2024, 3, 20) }}
4>

defaultDate is a separate prop from defaultValue because defaultValue is ignored once value is passed. Keying the reset off its own prop is what makes it work for a controlled calendar.

defaultDate={null} is a default of nothing selected, so the button clears the day and reports reason: 'clear'. Omitting the prop is the different case: the part has no job and renders nothing.

1<CalendarPreview
2 defaultMonth={new Date(2024, 3, 1)}
3 defaultDate={new Date(2024, 3, 17)}
4 defaultValue={new Date(2024, 3, 24)}
5>
6 <CalendarPreview.Days />
7</CalendarPreview>

Selection bounds

minDate, maxDate and isDateUnavailable disable cells. None of them clamps navigation — the chevrons and the scroller still reach any month. Bounds compare whole calendar days, so a minDate carrying a time of day still leaves its own day selectable.

isDateUnavailable is day scale only. A month, quarter, half-year or year cell never calls it — a day predicate has no single lift to a period, and answering per cell would run it 365 times a year. Period cells are bounded by minDate and maxDate instead, tested against the day the cell would emit, which is the same rule that makes a period available to one end of a pair and not the other.

1<CalendarPreview
2 defaultMonth={new Date(2024, 3, 1)}
3 minDate={new Date(2024, 3, 17)}
4>
5 <CalendarPreview.Days />
6</CalendarPreview>

Grid layout

Outside days are off by default, so a grid ends on the last day of its month with the leading cells blank.

1<CalendarPreview defaultMonth={new Date(2024, 3, 1)}>
2 <CalendarPreview.Days>
3 <CalendarPreview.Header />
4 <CalendarPreview.Grid showOutsideDays />
5 </CalendarPreview.Days>
6</CalendarPreview>

Date information and tooltips

dateInfo and tooltipMessages are functions of the date, not records keyed by a formatted string. dateInfo content renders above the day number; today's dot sits below it, so the two never collide.

1<CalendarPreview defaultMonth={new Date(2024, 3, 1)}>
2 <CalendarPreview.Days>
3 <CalendarPreview.Header />
4 <CalendarPreview.Grid
5 dateInfo={(date) =>
6 date.getDate() % 7 === 0 ? (
7 <Text size="micro" variant="accent">
8 $
9 </Text>
10 ) : null
11 }
12 />
13 </CalendarPreview.Days>
14</CalendarPreview>

Month and year scroller

<CalendarPreview.Caption dropdown /> turns the caption into a filled chip that opens two adjacent scrolling columns. It is a plain popover of buttons, not a Select — picking from either column moves the view and never selects a value.

Date picker

The date picker is not a separate export — it is this composition:

1<CalendarPreview value={date} onValueChange={setDate}>
2 <CalendarPreview.Trigger>
3 <CalendarPreview.Input />
4 </CalendarPreview.Trigger>
5 <CalendarPreview.Content>
6 <CalendarPreview.Days />
7 </CalendarPreview.Content>
8</CalendarPreview>

The popover opens when the input takes focus. Enter, blur and an outside click all commit — there is no Apply button. Dismissal is Base UI's, so escape and outside press behave like every other popover in the library.

"Without calendar icon" is composition rather than a prop: pass trailingIcon={null} to .Input.

1<CalendarPreview defaultMonth={new Date(2024, 3, 1)}>
2 <CalendarPreview.Trigger>
3 <CalendarPreview.Input />
4 </CalendarPreview.Trigger>
5 <CalendarPreview.Content>
6 <CalendarPreview.Days />
7 </CalendarPreview.Content>
8</CalendarPreview>

Range selection

selection="range" turns clicks into endpoints. Give each .Input a field:

1<CalendarPreview selection="range" value={range} onValueChange={setRange}>
2 <CalendarPreview.Trigger>
3 <CalendarPreview.Input field="start" />
4 <CalendarPreview.Input field="end" />
5 </CalendarPreview.Trigger>
6 <CalendarPreview.Content>
7 <CalendarPreview.Days numberOfMonths={2} />
8 </CalendarPreview.Content>
9</CalendarPreview>

onValueChange fires on a complete range or not at all. to is not nullable, so there is no partial { from?, to? } to gate on. The half-built range stays internal — the grid styles the track from it, but nothing is emitted until the second endpoint lands.

The click machine:

StateA click does
Nothing selectedsets from, moves focus to the end field
from only, later daycompletes the range, emits, closes the popover
from only, earlier daythat day becomes the new from
Complete rangerestarts — the new day is from, and the value stays at the previous range until the new one completes

Completing asks the popover to close through onOpenChange, so a consumer holding open open is not fought.

Typing is stricter than clicking. A click means "the next endpoint", so an earlier day restarts the range, as the table above says. Typing names the field it lands in, so an endpoint that crosses its partner is rejected instead: onValidityChange reports out-of-order, the field goes red, and nothing is emitted. Two endpoints on the same day are a valid range.

1<CalendarPreview.Input
2 field="end"
3 errorMessages={{ 'out-of-order': 'Pick a day after the start' }}
4 onValidityChange={({ message }) => setError(message)}
5/>

Instead of a lock prop, mark one endpoint's .Input as readOnly — the grid will not rewrite it. A read-only endpoint with no value makes the range unsatisfiable: the free endpoint sets, the range never completes, and nothing emits. Give a read-only endpoint a value.

1<CalendarPreview selection="range" defaultMonth={new Date(2024, 3, 1)}>
2 <CalendarPreview.Trigger>
3 <Flex align="center" gap={3}>
4 <CalendarPreview.Input field="start" />
5 <CalendarPreview.Input field="end" />
6 </Flex>
7 </CalendarPreview.Trigger>
8 <CalendarPreview.Content>
9 <CalendarPreview.Days numberOfMonths={2} />
10 </CalendarPreview.Content>
11</CalendarPreview>

Invalid typed dates

Typing is checked on every keystroke, and a date that fails is never committedonValueChange does not fire and the previous value stands.

.Input marks itself aria-invalid and data-invalid, and data-invalid is what Input paints its error border from, so the field turns red on its own with nothing wired up.

onValidityChange carries a ready-to-render message, so a message under the field is one line — it is undefined while valid, which is exactly what Field's error wants:

1<Field label="Start date" error={error}>
2 <CalendarPreview>
3 <CalendarPreview.Trigger>
4 <CalendarPreview.Input onValidityChange={({ message }) => setError(message)} />
5 </CalendarPreview.Trigger>
6 <CalendarPreview.Content>
7 <CalendarPreview.Days />
8 </CalendarPreview.Content>
9 </CalendarPreview>
10</Field>

The default is a flat "Invalid input" for most reasons. It stays deliberately vague because only you know the field's bounds — the component cannot say which dates would be accepted without inventing wording it has no basis for.

out-of-order is the exception, and gets a real default: it needs no knowledge of your bounds, only of which endpoint was typed.

Override it with errorMessages, per reason. Anything left out keeps the default, so wording one reason does not mean restating the rest:

1<CalendarPreview.Input
2 errorMessages={{
3 unparseable: 'Use DD/MM/YYYY, like 15/04/2024',
4 'out-of-bounds': 'Pick a date in April 2024'
5 }}
6 onValidityChange={({ message }) => setError(message)}
7/>

The reason is also on the payload if you would rather branch on it yourself:

reasonMeans
unparseableThe text is not a date the input could read at all
out-of-boundsA real date, outside minDate / maxDate
unavailableA real date in range that isDateUnavailable rejected
out-of-orderRange only — the endpoint crossed its partner

It fires only when validity changes, not on every keystroke, so it is safe to drive state with.

Blurring or pressing Enter on a date that does not resolve leaves the typed text in the field rather than discarding what was typed. The field stays marked invalid, but it now shows something other than the committed value — so read the value from onValueChange, never from the input's text.

Migrating from Calendar

CalendarPreview is not a drop-in replacement. Two props keep their names and change their meaning, so they are the ones to check first — neither produces a type error in every case, and both fail quietly.

PropOn CalendarOn CalendarPreview
disabledA day matcher — disabled={{ before: today }} blocks those daysA boolean that makes the whole calendar inert. Use isDateUnavailable or minDate / maxDate for days
showOutsideDaysDefaults to trueDefaults to false

The rest are renames. Most follow the repo's conventions (onValueChange, loading, a boolean disabled), which is why the names moved rather than the behaviour:

CalendarCalendarPreview
selectedvalue
onSelectonValueChange
startMonth / endMonthminDate / maxDate
disabled (matcher)isDateUnavailable
loadingDataloading
dateFormatformatValue
requiredclearable (inverted)
footer prop<CalendarPreview.Footer> part
captionLayout="dropdown"<CalendarPreview.Caption dropdown />

Two things have no replacement yet: the record forms of dateInfo and tooltipMessages (both are functions here), and the classNames escape hatch — style through the data-slot attributes in the table above instead.

Slot names changed too, so a stylesheet written against Calendar needs a second set of selectors rather than an edit:

Calendar slotCalendarPreview slot
calendar-grid-tablecalendar-preview-table
calendar-grid-skeletoncalendar-preview-skeleton
calendar-month-gridcalendar-preview-weeks
calendar-nav-previouscalendar-preview-prev-month

Performance

dateInfo, tooltipMessages and isDateUnavailable are functions rather than records, so the grid cannot tell a changed rule from a re-created one. Passing an inline arrow re-renders every day cell on every render of the surrounding component. Wrap them in useCallback, or hoist them out of the component, whenever the calendar is inside anything that re-renders often.

Localization

English only for now. There is no locale prop: month and weekday names come from date-fns' default en-US, and the nav, reset and caption labels are hardcoded strings. timeZone is unaffected — a calendar can render in any zone, in English. Localization is tracked against RFC 005 rather than patched in per-part.

Scale-aware selection

Pass scales to select at granularities coarser than a day. A single value hides the switcher; anything more shows it.

1<CalendarPreview scales={['day', 'month', 'quarter', 'halfYear', 'year']}>
2 <CalendarPreview.Trigger placeholder="Add start date" />
3 <CalendarPreview.Content>
4 <CalendarPreview.Body />
5 </CalendarPreview.Content>
6</CalendarPreview>
1<CalendarPreview
2 scales={["day", "month", "quarter", "halfYear", "year"]}
3 defaultMonth={new Date(2026, 7, 1)}
4 defaultScale="quarter"
5>
6 <CalendarPreview.Body />
7</CalendarPreview>

The value carries its scale

A Date cannot say whether it means "August 2026" or "1 August 2026", so beyond day scale the value is a ScaleValue:

1interface ScaleValue { date: 'YYYY-MM-DD'; scale: Scale }
scalesvalue
omitted, or 'day'Date — unchanged
any other scale, or any arrayScaleValue

date is stored as YYYY-MM-DD because lexicographic order is chronological order, which is what lets bounds compare without parsing. It is never what you see — every trigger, input and annotation renders through formatValue, which is DD MMM YYYY at day scale and the period's own shorthand above it. onValueChange's details carry toDate() if you want a Date.

Switching scale drafts, it does not emit

Moving between scales moves the view and sets a draft. Nothing is emitted until a cell is clicked or Enter is pressed; Escape drops the draft and restores the input from value.

trailingValue picks the edge

A period has two edges, and which one a field means depends on the field. trailingValue emits the period's last day rather than its first — "July 2026" becomes 2026-07-31 instead of 2026-07-01. It changes the value, not the formatting, and it is month-end correct: February 2028 trailing is 2028-02-29.

That also decides availability, which tests the date a period would produce. Bounded at 15 July 2026:

PeriodA start field emitsAn end field emitsStartEnd
H1 20261 Jan30 Jundisableddisabled
July 20261 Jul31 Juldisabledavailable
Q3 20261 Jul30 Sepdisabledavailable

Every one of those periods starts before the bound. Only the produced date separates them.

A start/end pair is two roots

Not selection="range". Each end has its own scales and trailingValue, and they can hold different scales — "1 Aug 2026 → Q3 2026" is not expressible as one range value. The consumer owns the pair and any from <= to check.

1<Flex align="center" gap={3}>
2 <CalendarPreview
3 scales={["day", "month", "quarter", "halfYear", "year"]}
4 defaultValue={{ date: "2026-08-01", scale: "day" }}
5 >
6 <CalendarPreview.Trigger placeholder="Add start date" />
7 <CalendarPreview.Content>
8 <CalendarPreview.Body />
9 </CalendarPreview.Content>
10 </CalendarPreview>
11
12 <Text size="small" variant="secondary">
13
14 </Text>
15

Accessibility

  • Arrow keys move between days; the focused cell carries data-draft until it is committed
  • readOnly is conveyed with aria-readonly on the grid and aria-disabled on each day, and the grid stays focusable and arrow-navigable — unlike disabled
  • Each grid is labelled with its month, so the caption is not the only announcement
  • Nav buttons carry aria-label, and the scroller's columns are labelled groups
  • Selected and unavailable days are announced through their native button state