Month Calendar
A theme-portable month event-grid (scheduler) — a 6×7 day-cell grid with weekday header, month navigation, a view-mode segmented control, and per-cell event chips carrying a status dot and series tint.
Preview
July 2026
Day sheet for the week of 2026-07-12.
Installation
npx visor add --block month-calendarThis copies files into your project:
blocks/month-calendar/month-calendar.tsx— the block componentblocks/month-calendar/month-calendar.module.css— the styles
The block depends only on @phosphor-icons/react (nav arrows) and the shared utils helper — it builds its own grid, so there are no component registry dependencies.
Usage
'use client';
import * as React from 'react';
import {
MonthCalendar,
type MonthCalendarEvent,
} from '@/blocks/month-calendar/month-calendar';
const events: MonthCalendarEvent[] = [
{ id: '1', date: new Date(2026, 6, 4), title: 'Launch party', status: 'success', series: 1 },
{ id: '2', date: new Date(2026, 6, 4), title: 'Standup', status: 'info', series: 2 },
{ id: '3', date: new Date(2026, 6, 20), title: 'Payment due', status: 'danger' },
];
export function Schedule() {
const [month, setMonth] = React.useState(new Date());
const [selected, setSelected] = React.useState<Date | undefined>(undefined);
const [view, setView] = React.useState('month');
return (
<MonthCalendar
month={month}
onMonthChange={setMonth}
events={events}
today={new Date()}
selectedDate={selected}
onSelectDate={setSelected}
onEventSelect={(event) => console.log('open', event.id)}
view={view}
onViewChange={setView}
/>
);
}Anatomy
The block is a single column with three regions:
- Header — prev/next month arrows around a localized
Month Yearlabel, plus a view-mode segmented control (Month/Week/Dayby default). The arrows are disabled when noonMonthChangehandler is supplied. - Weekday header — seven localized short weekday names, honoring
weekStartsOn(Sunday or Monday). - Day grid — a fixed 6×7 (42-cell) month grid. Each cell shows its day number and stacks its event chips; days outside the displayed month are dimmed.
Event chips
Each event renders as a chip with two token-driven signals:
- Status dot — a leading dot colored by
status(success/warning/danger/info, or a neutral default). Dots bind to the--surface-{success,warning,error,info}-defaulttokens. - Series tint — an optional
seriesindex (1–5) that washes the chip background and adds a leading accent bar, keyed to the theme's--chart-1…5ramp. Use it to color-code a recurring series or a resource lane.
When a day holds more events than maxChipsPerDay (default 3), the extras collapse into a +N more row.
Spans
An event with an end draws as one bar across the days it covers, instead of a chip on one day.
{ id: 'berlin', start: '2026-07-10', end: '2026-07-14', title: 'Berlin stay', color: '--region-berlin' }- Cut at week edges. A span that crosses a week edge renders as one segment per week. A segment that continues into the next (or from the previous) week runs flat into the edge and carries a caret.
- Lanes. Spans that share days stack in lanes. Past
maxLanes(default3) a day shows+Nfor the spans that did not fit; clicking it opens the day. - Colour.
series(1–5) keys to the theme's chart ramp.colortakes any colour token instead — a custom-property name ('--region-berlin'), avar()call or a CSS colour — and wins overseries. Chips take both too. - Dates are calendar dates.
startandendareYYYY-MM-DDstrings, read field by field and never built into a local-timeDate, so a stay does not shift by a day across time zones. (dateandDatevalues still work: aDateis read through its local calendar fields.)
Week detail slot
renderWeekDetail(week) renders under the week row that holds selectedDate, in the page flow, pushing later weeks down. An arrow points at the selected day. week is { index, start, end, days, selected } (all YYYY-MM-DD). Return nothing to keep it closed.
<MonthCalendar
selectedDate={selected}
onSelectDate={(_, iso) => setSelected(iso)}
renderWeekDetail={(week) => <DaySheet date={week.selected} />}
/>Activating a day moves focus into the slot (the first focusable control in it, else the slot itself) and sets aria-expanded on that day. The slot fades in; under prefers-reduced-motion it opens without animation.
Day mark
renderDayMark({ date, inMonth, isToday, isSelected }) renders a consumer mark (a count, a range end) opposite the day number in each cell's head.
Interaction
The block is fully controlled and SSR-safe — it never reads the system clock itself.
month+onMonthChange— drive the displayed month. The handler receives the first day of the target month.onSelectDate— when supplied, day cells hold a button and report the activated date (Date, plus theYYYY-MM-DDstring); the active day is highlighted viaselectedDate.onEventSelect— when supplied, chips become buttons and report the clicked event. Span bars are pointer-only art (click reports the event); the keyboard route to a span is its day.today— passnew Date()from a client boundary to highlight the current day. Omit it to keep server and client render identical (no hydration mismatch).view+onViewChange— drive the segmented control. The block always renders the month grid; swap in your own week/day surface based on the active view.
Accessibility
- Month-nav arrows carry
aria-label="Previous month"/"Next month". - The view-mode control is a labelled
role="group"; each segment reportsaria-pressed. - The month is a
role="grid"of week rows and day cells with one tab stop. Arrow keys move by day, Up/Down by week, Home/End to the week edges. - Each day names its events, spans with their position:
"June 12, 2026, Berlin stay, day 2 of 4". Span bars arearia-hiddenart; hidden spans (+N) are still named by the day. - The selected day is
aria-selected, drawn with an accent rule as well as a tint (not colour alone), and carriesaria-expandedwhile its week slot is open. Today isaria-current="date". - At phone width a bar's label truncates with an ellipsis; the day's name carries the full text.
- Status dots are decorative (
aria-hidden) — the chip's text label is the accessible name.
Edges
The block draws no visible border. Its resting edge reads the shared --control-edge-* tokens (--control-edge-width: 0 removes it) and its grid lines read --hairline-width (--hairline-width: 0 removes them without moving a box). Focus rings survive both switches.
Props
See MonthCalendarProps in blocks/month-calendar/month-calendar.tsx for the full list. In summary:
- Month —
month(required),onMonthChange,weekStartsOn,locale. - Events —
events,maxChipsPerDay,maxLanes,onEventSelect. - Selection —
selectedDate,onSelectDate,today. - Slots —
renderWeekDetail,renderDayMark. - View mode —
viewOptions,view,onViewChange.
MonthCalendarEvent is { id, start | date, end?, title, status?, series?, color? }, where status is "default" | "success" | "warning" | "danger" | "info" and series is 1 | 2 | 3 | 4 | 5.
About Blocks
Blocks are complete UI patterns made up of multiple Visor components. Unlike individual components, blocks represent larger, composed sections of UI — such as admin shells, login forms, or dashboard panels.
Blocks are copy-and-own, just like components. Install them into your project and customize freely.