Styleframe Logo
Forms

Calendar

A date-grid styling recipe with selected, today, range, booked, disabled, and outside-month day states, week numbers, month/year selectors, presets and a time-picker footer, plus a custom cell size through the recipe system.

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 .calendar surface that owns the border, radius, padding, the header/grid/footer layout, and the size axis. Each size sets the --calendar-cell-size and --calendar-cell-font-size custom properties, which cascade to every cell.
  • useCalendarDayRecipe() — the .calendar-day button rendered for each day. It owns the square, content-adaptive cell, the selected, today, outside, disabled, booked and range state axes, and a variant axis (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, disabled and outside-month all follow the same design rules.
  • Drive density from one place: A single size sets --calendar-cell-size, which cascades to every cell, week number and weekday label.
  • Keep cells square and content-adaptive: aspect-ratio: 1 plus a min floor 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-size custom property — all through the options API.
  • Stay type-safe: Full TypeScript support means your editor catches invalid size or 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:

src/components/calendar.styleframe.ts
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:

src/components/Calendar.tsx
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

SizeCell size (--calendar-cell-size)Font
smcalc(var(--spacing) * 2) (≈32px)@font-size.sm
mdcalc(var(--spacing) * 2.25) (≈36px)@font-size.sm
lgcalc(var(--spacing) * 2.5) (≈40px)@font-size.md
Pro tip: For a fully custom cell size, set --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:

src/components/calendar.styleframe.ts
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.

VariantSelected day & range endpoints
solidFilled with @color.primary, white text
outlineTransparent with an inset @color.primary ring and primary text
softTinted @color.primary-200 surface, no ring
subtleTinted surface plus an inset @color.primary-300 ring
Good to know: The rings are inset 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.

PartClassRole
RootuseCalendarRecipe().calendarThe surface; owns padding, border, the size axis and the --calendar-cell-size cascade
DayuseCalendarDayRecipe().calendar-dayThe day button; owns the square cell and all day states
Months.calendar-monthsWrapper for one or more month columns (multi-month layouts)
Month.calendar-monthA single month column: caption + grid + footer
Body.calendar-bodyRow layout that places the presets sidebar beside the months
Caption.calendar-captionHeader row: navigation + month/year label or selects
Caption label.calendar-caption-labelThe "June 2026" label
Caption dropdowns.calendar-caption-dropdownsSlot for the month/year selects
Nav.calendar-navPrevious/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-weekdaysThe weekday header row
Weekday.calendar-weekdayA single weekday label (Su, Mo, …)
Week.calendar-weekA single week row
Week number.calendar-week-numberThe leading ISO week-number cell
Day spacer.calendar-day-spacerInvisible stand-in for a hidden outside-month day in side-by-side months
Presets.calendar-presetsThe quick-pick sidebar
Footer.calendar-footerThe time-picker row

Accessibility

  • Let the library own behavior. Selection, keyboard navigation (arrow keys, Page Up/Down), focus management and aria state 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, and role="columnheader" on weekday labels (most libraries do this for you).
  • Convey selection beyond color. Set aria-selected on selected days and aria-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 disabled attribute 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.white on @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:

src/components/calendar.styleframe.ts
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:

src/components/calendar.styleframe.ts
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;
Good to know: Filtering also removes compound variants and adjusts default variants that reference filtered-out values, so your recipe stays consistent.

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:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the surface
options.variantsVariantsCustom variant definitions for the recipe
options.defaultVariantsRecord<keyof Variants, string>Default variant values for the recipe
options.filterRecord<string, string[]>Limit which variant values are generated

Variants:

VariantOptionsDefault
sizesm, md, lgmd

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:

VariantOptionsDefault
typeday, month, yearday
variantsolid, outline, soft, subtlesolid
selectedtrue, falsefalse
todaytrue, falsefalse
outsidetrue, falsefalse
disabledtrue, falsefalse
bookedtrue, falsefalse
rangenone, start, middle, endnone

Learn more about recipes →

Best Practices

  • Pair with a headless engine: Use the recipe for styling and a library like React DayPicker or @internationalized/date for the date logic and keyboard handling.
  • Drive size from the root: Set size on calendar() and let the --calendar-cell-size cascade 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 booked for reserved-but-real dates (struck through) and disabled for unreachable ones (dimmed, out of tab order).
  • Filter what you don't need: If your calendar only uses two sizes, pass a filter option to reduce generated CSS.

FAQ