Calendar
Overview
The Calendar is a styling recipe for a date grid — the parts and per-cell states a date picker renders. It is a styling layer, not a date engine: selection, range logic, month navigation and time picking belong to the headless library you wire it to (for example React DayPicker or @internationalized/date). It is composed of two recipe parts:
useCalendarRecipe()— the.calendarsurface that owns the border, radius, padding, the header/grid/footer layout, and thesizeaxis. Each size sets the--calendar-cell-sizeand--calendar-cell-font-sizecustom properties, which cascade to every cell.useCalendarDayRecipe()— the.calendar-daybutton rendered for each day. It owns the square, content-adaptive cell, theselected,today,outside,disabled,bookedandrangestate axes, and avariantaxis (solid,outline,soft,subtle) for the active-selection style.
Everything else — the caption, navigation, weekday header, week rows, week numbers, presets sidebar and footer — is a structural class emitted by the root recipe (see Anatomy). Navigation and preset buttons reuse the Button recipe, time fields reuse the Input recipe, and the month/year selects reuse the Select recipe.
The Calendar recipes integrate directly with the default design tokens preset and generate type-safe utility classes at build time with zero runtime CSS.
Why use the Calendar recipe?
The Calendar recipe helps you:
- Style any headless calendar: Map the part classes onto React DayPicker, Reka UI, or your own grid — the recipe styles the slots, the library drives the dates.
- Ship every state out of the box:
selected,today, range start/middle/end,booked,disabledand outside-month all follow the same design rules. - Drive density from one place: A single
sizesets--calendar-cell-size, which cascades to every cell, week number and weekday label. - Keep cells square and content-adaptive:
aspect-ratio: 1plus aminfloor keeps cells square while letting them grow to fit content (like a price label on a booked day). - Customize without forking: Override base styles, default variants, or the
--calendar-cell-sizecustom property — all through the options API. - Stay type-safe: Full TypeScript support means your editor catches invalid
sizeor state values at compile time.
Usage
Register the recipes
Add the Calendar recipes to a local Styleframe instance. The global styleframe.config.ts provides design tokens and utilities, while the component-level file registers the recipes themselves:
import { styleframe } from 'virtual:styleframe';
import { useCalendarRecipe, useCalendarDayRecipe } from '@styleframe/theme';
const s = styleframe();
const calendar = useCalendarRecipe(s);
const calendarDay = useCalendarDayRecipe(s);
export default s;
Build the component
Put calendar({ size }) on the root and calendarDay({ ... }) on each day button. Map the structural classes onto your calendar library's slots. With React DayPicker the recipe classes drop straight into classNames and modifiersClassNames:
import { DayPicker } from "react-day-picker";
import { calendar, calendarDay } from "virtual:styleframe";
export function Calendar(props: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showWeekNumber
classNames={{
root: calendar({ size: "md" }),
months: "calendar-months",
month: "calendar-month",
month_caption: "calendar-caption",
caption_label: "calendar-caption-label",
dropdowns: "calendar-caption-dropdowns",
nav: "calendar-nav",
month_grid: "calendar-grid",
weekdays: "calendar-weekdays",
weekday: "calendar-weekday",
week: "calendar-week",
week_number: "calendar-week-number",
day_button: calendarDay(),
}}
modifiersClassNames={{
selected: calendarDay({ selected: "true" }),
today: calendarDay({ today: "true" }),
outside: calendarDay({ outside: "true" }),
disabled: calendarDay({ disabled: "true" }),
booked: calendarDay({ booked: "true" }),
range_start: calendarDay({ range: "start" }),
range_middle: calendarDay({ range: "middle" }),
range_end: calendarDay({ range: "end" }),
}}
{...props}
/>
);
}
See it in action
Sizes
Three size variants from sm to lg control density. Each sets --calendar-cell-size (the square cell floor) and --calendar-cell-font-size on the root, which cascade to every cell, week number and weekday label — so a single calendar({ size }) scales the whole grid.
Size Reference
| Size | Cell size (--calendar-cell-size) | Font |
|---|---|---|
sm | calc(var(--spacing) * 2) (≈32px) | @font-size.sm |
md | calc(var(--spacing) * 2.25) (≈36px) | @font-size.sm |
lg | calc(var(--spacing) * 2.5) (≈40px) | @font-size.md |
--calendar-cell-size with a class on the .calendar element — the per-size defaults are emitted at zero specificity (:where()), so any consumer class wins. See Custom cell size.Custom cell size
The named sizes only set defaults for --calendar-cell-size. Override the variable with a single class for a fully custom density — the cell uses it as the min-width/min-height floor and aspect-ratio: 1 keeps it square, so cells grow to fit extra content like a price label:
selector('.calendar-pricing', {
'--calendar-cell-size': 'calc(var(--spacing) * 4)',
});
Variants
Four selection variants control how the active selection — the selected day and range endpoints — is filled, mirroring Nuxt UI's calendar variants. The range middle band and the today/outside treatments stay the same across variants. Pass variant to calendarDay(); it defaults to solid.
| Variant | Selected day & range endpoints |
|---|---|
solid | Filled with @color.primary, white text |
outline | Transparent with an inset @color.primary ring and primary text |
soft | Tinted @color.primary-200 surface, no ring |
subtle | Tinted surface plus an inset @color.primary-300 ring |
box-shadows, not borders, so switching variants never shifts the cell layout.Day states
Each day is a calendarDay({ ... }) button. The state axes mirror the modifiers a headless calendar exposes, so you map them straight across. They are independent booleans, except range, which is positional (none, start, middle, end).
Today & Selected
today marks the current day with the primary color and a heavier weight; selected fills the day with @color.primary. When a day is both, the selected fill wins and the label stays legible.
Booked vs. Disabled
disabled dims the day to 0.5 opacity with a not-allowed cursor — an unreachable date. booked is intentionally distinct: a reserved/unavailable day is struck through and muted on a faint surface, but not dimmed away, so it still reads as a real, labelled date.
Outside month
outside mutes days that belong to the previous or next month so the current month stays prominent. Most libraries can also hide them entirely.
Ranges
For range selection, set range to start, middle or end. The endpoints fill with @color.primary and square their inner edge; the middle days use a soft primary band — so the selection reads as one continuous range.
Multiple months
For side-by-side months, render one .calendar-month per month inside .calendar-months; the wrapper lays them out in a row. How many months to show (e.g. a numberOfMonths prop) is your component's concern — the recipe just styles the columns. When months sit side by side, each grid shows exactly one month: outside-month days would duplicate across the grids, so render them as invisible .calendar-day-spacer cells to keep the columns aligned.
Month & year selector
Swap the static caption label for two selects inside .calendar-caption-dropdowns. The recipe ships baseline styling for native <select> elements in the slot (border, radius, typography that follows the calendar size); swap in the Select recipe for full theming.
Month & year pickers
Set type to month or year on calendarDay() to reuse the same cell for picker views — choosing a month within a year, or a year within a 12-year page. Picker cells keep the cascaded cell height but stretch to their grid column as pills instead of staying square. Add the -months or -years marker class to .calendar-grid for the 4-column layout (floored to the day grid's width, so switching views keeps the same footprint). The selection variant and the today accent (the current month or year) apply unchanged.
Presets
Add a .calendar-presets sidebar beside the calendar for quick picks like "Today" or "Last 7 days". The preset buttons reuse the Button recipe.
Date & time
Add a .calendar-footer below the grid for start/end time fields, built with the Input recipe.
Week numbers
Add the -week-numbers marker class to .calendar-grid to expand it to an 8-column layout with a leading .calendar-week-number cell per row.
Anatomy
The Calendar is two recipes (the root surface and the day button) plus a set of structural classes the root recipe emits for the fixed layout parts.
| Part | Class | Role |
|---|---|---|
| Root | useCalendarRecipe() → .calendar | The surface; owns padding, border, the size axis and the --calendar-cell-size cascade |
| Day | useCalendarDayRecipe() → .calendar-day | The day button; owns the square cell and all day states |
| Months | .calendar-months | Wrapper for one or more month columns (multi-month layouts) |
| Month | .calendar-month | A single month column: caption + grid + footer |
| Body | .calendar-body | Row layout that places the presets sidebar beside the months |
| Caption | .calendar-caption | Header row: navigation + month/year label or selects |
| Caption label | .calendar-caption-label | The "June 2026" label |
| Caption dropdowns | .calendar-caption-dropdowns | Slot for the month/year selects |
| Nav | .calendar-nav | Previous/next button group (buttons reuse the Button recipe) |
| Grid | .calendar-grid (+ .-week-numbers, .-months, .-years) | The 7-column day grid (8 with week numbers); 4 columns of pills in picker views |
| Weekdays | .calendar-weekdays | The weekday header row |
| Weekday | .calendar-weekday | A single weekday label (Su, Mo, …) |
| Week | .calendar-week | A single week row |
| Week number | .calendar-week-number | The leading ISO week-number cell |
| Day spacer | .calendar-day-spacer | Invisible stand-in for a hidden outside-month day in side-by-side months |
| Presets | .calendar-presets | The quick-pick sidebar |
| Footer | .calendar-footer | The time-picker row |
Accessibility
- Let the library own behavior. Selection, keyboard navigation (arrow keys, Page Up/Down), focus management and
ariastate are the headless calendar's job. The recipe styles the parts; pair it with an accessible engine like React DayPicker rather than a bare grid of buttons. - Mark the grid. Use
role="grid"on.calendar-grid,role="row"on rows, androle="columnheader"on weekday labels (most libraries do this for you). - Convey selection beyond color. Set
aria-selectedon selected days andaria-current="date"on today so the state isn't carried by color alone, satisfying WCAG 1.4.1. - Mirror disabled on the element. Set the real
disabledattribute on disabled and booked day buttons so they leave the tab order, in addition to the recipe state. - Label the navigation. Give the previous/next buttons an
aria-label("Previous month") — they render as icon-only Button instances. - Verify contrast. Selected and range days are
@color.whiteon@color.primary. Default tokens meet WCAG AA; if you override the primary color, re-check with the WebAIM Contrast Checker.
Customization
Overriding Defaults
Each composable accepts an optional second argument to override any part of the recipe configuration. Overrides are deep-merged with the defaults, so you only specify what changes:
import { styleframe } from 'virtual:styleframe';
import { useCalendarRecipe } from '@styleframe/theme';
const s = styleframe();
const calendar = useCalendarRecipe(s, {
base: {
borderRadius: '@border-radius.xl',
},
defaultVariants: {
size: 'lg',
},
});
export default s;
Filtering Variants
If you only need a subset of sizes, use the filter option to limit which values are generated. This reduces the output CSS:
import { styleframe } from 'virtual:styleframe';
import { useCalendarRecipe } from '@styleframe/theme';
const s = styleframe();
// Only generate the sm and md sizes
const calendar = useCalendarRecipe(s, {
filter: {
size: ['sm', 'md'],
},
});
export default s;
API Reference
useCalendarRecipe(s, options?)
Creates the calendar root recipe — the .calendar surface that owns the layout, the size axis and the --calendar-cell-size / --calendar-cell-font-size custom-property cascade, and emits the structural part classes.
Parameters:
| Parameter | Type | Description |
|---|---|---|
s | Styleframe | The Styleframe instance |
options | DeepPartial<RecipeConfig> | Optional overrides for the recipe configuration |
options.base | VariantDeclarationsBlock | Custom base styles for the surface |
options.variants | Variants | Custom variant definitions for the recipe |
options.defaultVariants | Record<keyof Variants, string> | Default variant values for the recipe |
options.filter | Record<string, string[]> | Limit which variant values are generated |
Variants:
| Variant | Options | Default |
|---|---|---|
size | sm, md, lg | md |
useCalendarDayRecipe(s, options?)
Creates the day-cell recipe — the .calendar-day button. The cell reads --calendar-cell-size from the root, stays square via aspect-ratio: 1, and exposes the day-state axes. Accepts the same parameters as useCalendarRecipe.
Variants:
| Variant | Options | Default |
|---|---|---|
type | day, month, year | day |
variant | solid, outline, soft, subtle | solid |
selected | true, false | false |
today | true, false | false |
outside | true, false | false |
disabled | true, false | false |
booked | true, false | false |
range | none, start, middle, end | none |
Best Practices
- Pair with a headless engine: Use the recipe for styling and a library like React DayPicker or
@internationalized/datefor the date logic and keyboard handling. - Drive size from the root: Set
sizeoncalendar()and let the--calendar-cell-sizecascade size every cell, rather than sizing cells individually. - Reuse the sibling recipes: Style navigation and presets with Button, time fields with Input, and the month/year selects with Select — don't re-create them.
- Distinguish booked from disabled: Use
bookedfor reserved-but-real dates (struck through) anddisabledfor unreachable ones (dimmed, out of tab order). - Filter what you don't need: If your calendar only uses two sizes, pass a
filteroption to reduce generated CSS.
FAQ
@internationalized/date). The recipe gives you the classes for every part and state; you map them onto the library's slots and modifiers.--calendar-cell-size custom property with a class on the .calendar element. The per-size defaults are emitted at zero specificity via :where(), so any consumer class overrides them. The day cell uses the variable as the min-width/min-height floor, and aspect-ratio: 1 keeps the cell square as it grows to fit content. The named sizes (sm–lg) just set sensible defaults for this variable — see Custom cell size..calendar-grid lays out seven equal columns with minmax(var(--calendar-cell-size), 1fr), and each .calendar-day sets aspect-ratio: 1 with a min-width/min-height floor. Cells stay square and aligned, but can grow to fit their content rather than clipping it.filter option, compound variants that reference filtered-out values are automatically removed, and default variants are adjusted if they reference a removed value — so the recipe stays consistent and only emits the CSS you use.Spinner
A loading spinner component with color, size, and optional overlay — built as a multi-part recipe system with SVG-based animation.
Checkbox
A custom checkbox built on the native input, with a CSS checkmark, checked, indeterminate, disabled, and focus states, light, dark, and neutral surface colors, and three sizes through the recipe system.