Calendar
An accessible, always-visible inline calendar. Supports day / month / year drill-down views, min/max bounds, disabled weekend days, theming, and native form association. Use this when you want the calendar rendered directly in the page — see Date Picker for the trigger + popup variant.
Features
Full keyboard nav — Arrow keys (including ArrowUp/ArrowDownfor row jumping),Home/End,Enter/SpaceARIA — role="group"host,role="grid"day grid,role="gridcell"day cells,aria-selected,aria-current="date"for today,aria-disabledon disabled cellsThree views — Day → Month → Year cycling via the header label button Internationalised — Intl.DateTimeFormat; pass any BCP 47 locale stringMin / Max bounds — ISO 8601 min/max; out-of-range days are disabledWeekend disabling — JSON array of day-of-week indices, e.g. weekend-days="[0,6]"Form-associated — participates in native <form>submission; value is the ISO date string6 semantic colors — primary, secondary, info, success, warning, error CSS custom properties — --calendar-*tokens for full theming control
Source Code
View Source Code
import { define, useField, html, prop } from '@vielzeug/craft';
import { computed, signal } from '@vielzeug/ripple';
import { Temporal, format } from '@vielzeug/tempo';
import type { ComponentSize, RoundedSize, ThemeColor } from '../../shared';
import '../../content/icon/icon';
import '../../feedback/badge/badge';
import { createDatePickerControl, parseIso, toIsoString, type DatePickerView } from '../../headless';
import { disablableBundle, roundableBundle, sizableBundle, themableBundle } from '../../shared';
import { colorThemeMixin, reducedMotionMixin } from '../../styles';
import componentStyles from './calendar.css?inline';
// ── Types ─────────────────────────────────────────────────────────────────────
export type SgCalendarEvents = {
change: { isoValue: string | null };
};
/**
* A calendar event entry.
* Dates must be ISO 8601 strings (yyyy-MM-dd).
*
* The `color` field accepts any valid CSS color value (e.g. `'#e11d48'`,
* `'var(--color-primary)'`). An invalid value silently falls back to the
* component's theme color. Only pass values you control.
*/
export type CalendarEvent = {
/**
* Any CSS color value for the event dot / pill.
* Defaults to the component's theme color when omitted.
* An invalid CSS value silently produces no custom color.
*/
color?: string;
/** ISO date the event falls on (yyyy-MM-dd) */
date: string;
/** Unique identifier */
id: string;
/** Short label shown in the calendar cell */
label: string;
};
export type SgCalendarProps = {
/** Theme color */
color?: ThemeColor;
/** Disable all date selection */
disabled?: boolean;
/**
* Array of calendar events to display on day cells.
* In normal mode each event renders as a small colored dot (max 3 dots, then "+N").
* In expanded mode each event renders as a full-width colored pill with its label.
* Pass as a JS property or as a JSON-encoded attribute.
* @example
* ```js
* calendar.events = [
* { id: '1', date: '2025-06-15', label: 'Team standup', color: 'var(--color-primary)' },
* { id: '2', date: '2025-06-20', label: 'Release', color: '#e11d48' },
* ];
* ```
*/
events?: CalendarEvent[];
/**
* Expanded layout — large cells with top-aligned day numbers suitable
* for a full-page calendar app view.
*/
expanded?: boolean;
/** Expand to container width */
fullwidth?: boolean;
/** Locale for day/month names (default: browser locale) */
locale?: string;
/** Latest selectable date in ISO 8601 format (yyyy-MM-dd) */
max?: string;
/** Earliest selectable date in ISO 8601 format (yyyy-MM-dd) */
min?: string;
/** Form field name (when used inside a form) */
name?: string;
/** Mark field as required */
required?: boolean;
/** Border radius override */
rounded?: RoundedSize;
/** Component size */
size?: ComponentSize;
/**
* Selected date in ISO 8601 format (yyyy-MM-dd).
* @example '2025-06-15'
*/
value?: string;
/**
* Day-of-week indices to disable (0 = Sunday … 6 = Saturday).
* Pass as a JSON array attribute or a JS property.
* @example
* ```html
* <sg-calendar weekend-days="[0,6]"></sg-calendar>
* ```
*/
'weekend-days'?: number[];
};
/**
* An accessible, keyboard-navigable inline calendar component.
* Supports day/month/year drill-down views, min/max bounds, disabled weekend
* days, and optional form association.
*
* @element sg-calendar
*
* @attr {string} value - Selected date in ISO 8601 format (yyyy-MM-dd)
* @attr {string} min - Minimum selectable date (yyyy-MM-dd)
* @attr {string} max - Maximum selectable date (yyyy-MM-dd)
* @attr {boolean} disabled - Disable all interaction
* @attr {boolean} required - Required field (form association)
* @attr {string} name - Form field name
* @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
* @attr {string} size - Component size: 'sm' | 'md' | 'lg'
* @attr {string} rounded - Border radius: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
* @attr {string} locale - BCP 47 locale string
* @attr {string} weekend-days - JSON array of day indices to disable (0=Sun…6=Sat)
* @attr {boolean} fullwidth - Expand to full container width
* @attr {boolean} expanded - Large-cell calendar-app layout with top-aligned day numbers
*
* @fires change - Fired when a date is selected. detail: { isoValue: string | null }
*
* @cssprop --calendar-bg - Calendar background
* @cssprop --calendar-border-color - Calendar border color
* @cssprop --calendar-radius - Calendar border radius
* @cssprop --calendar-shadow - Calendar drop shadow
* @cssprop --calendar-day-selected-bg - Background of selected day cell
* @cssprop --calendar-day-today-color - Colour of today's date number
* @cssprop --calendar-day-outside-opacity - Opacity of days outside visible month
*
* @part calendar - The root calendar panel
* @part header - Calendar header (nav + label)
* @part grid - Day grid
* @part day - Individual day cell
*
* @example
* ```html
* <!-- Single date with bounds -->
* <sg-calendar value="2025-06-15" min="2025-01-01" max="2025-12-31" color="primary"></sg-calendar>
*
* <!-- Expanded calendar-app layout -->
* <sg-calendar expanded fullwidth color="primary"></sg-calendar>
* ```
*/
export const CALENDAR_TAG = 'sg-calendar' as const;
/** Maximum event dots/pills shown per cell before "+N" overflow */
const MAX_EVENTS = 3;
// ── Component ────────────────────────────────────────────────────────────────
define<SgCalendarProps, SgCalendarEvents>(CALENDAR_TAG, {
formAssociated: true,
props: {
...themableBundle,
...sizableBundle,
...disablableBundle,
...roundableBundle,
events: prop.json([] as CalendarEvent[]),
expanded: prop.bool(false),
fullwidth: prop.bool(false),
locale: prop.string(),
max: prop.string(),
min: prop.string(),
name: prop.string(),
required: prop.bool(false),
value: prop.string(),
'weekend-days': prop.json([] as number[]),
},
setup(props, { bind, emit }) {
// ── Derived flags ────────────────────────────────────────────────────────
const isDisabled = computed(() => props.disabled.value === true);
const isExpanded = computed(() => props.expanded.value === true);
// ── Selected date: local-override pattern ─────────────────────────────────
// `localSelection` holds a user-initiated pick (or undefined = no override).
// `selectedDate` derives from it, falling back to the value prop reactively.
// This eliminates setInterval polling — external prop changes propagate
// through the computed graph automatically.
const localSelection = signal<Temporal.PlainDate | null | undefined>(undefined);
const selectedDate = computed(() =>
localSelection.value !== undefined ? localSelection.value : parseIso(props.value.value),
);
// ── Date-picker control ──────────────────────────────────────────────────
const ctrl = createDatePickerControl({
get locale() {
return props.locale.value ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
},
get max() {
return parseIso(props.max.value);
},
get min() {
return parseIso(props.min.value);
},
onChange(date) {
localSelection.value = date;
currentView.value = 'day';
displayYear.value = ctrl.displayYear();
displayMonth.value = ctrl.displayMonth();
emit('change', { isoValue: toIsoString(date) });
},
get value() {
return selectedDate.value;
},
get weekendDays() {
return props['weekend-days'].value ?? [];
},
});
// ── Reactive display state ────────────────────────────────────────────────
// Three separate primitive signals — the proven pattern. Mutating any one
// of them invalidates only the computeds that read it.
const currentView = signal<DatePickerView>('day');
const displayYear = signal(ctrl.displayYear());
const displayMonth = signal(ctrl.displayMonth()); // 1-indexed (Temporal)
// ── Form value ───────────────────────────────────────────────────────────
useField<string>({
disabled: isDisabled,
toFormValue: (v) => v || null,
value: computed(() => toIsoString(selectedDate.value) ?? ''),
});
// ── Events lookup ─────────────────────────────────────────────────────────
const eventsByDate = computed(() => {
const map = new Map<string, CalendarEvent[]>();
for (const evt of props.events.value ?? []) {
const list = map.get(evt.date) ?? [];
list.push(evt);
map.set(evt.date, list);
}
return map;
});
// ── Derived cells ─────────────────────────────────────────────────────────
// Each reads a display signal so they re-run when navigation changes.
const dayCells = computed(() => {
void displayYear.value;
void displayMonth.value;
void selectedDate.value; // re-run when selection changes to refresh isSelected flags
return ctrl.dayCells();
});
const monthCells = computed(() => {
void displayYear.value;
return ctrl.monthCells();
});
const yearCells = computed(() => {
void displayYear.value;
return ctrl.yearCells();
});
const weekdayLabels = computed(() => ctrl.weekdayLabels());
const displayMonth_ = computed(() => {
const d = Temporal.PlainDate.from({ day: 1, month: displayMonth.value, year: displayYear.value });
const loc = props.locale.value ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
return format(d, { intl: { month: 'long' }, locale: loc, tz: 'UTC' });
});
const displayYear_ = computed(() => String(displayYear.value));
const displayLabel = computed(() => `${displayMonth_.value} ${displayYear_.value}`);
// ── Navigation ───────────────────────────────────────────────────────────
function handlePrev(): void {
if (currentView.value === 'day') ctrl.prevMonth();
else ctrl.prevYear();
displayYear.value = ctrl.displayYear();
displayMonth.value = ctrl.displayMonth();
}
function handleNext(): void {
if (currentView.value === 'day') ctrl.nextMonth();
else ctrl.nextYear();
displayYear.value = ctrl.displayYear();
displayMonth.value = ctrl.displayMonth();
}
function handleHeaderClick(): void {
const views: DatePickerView[] = ['day', 'month', 'year'];
const next = views[(views.indexOf(currentView.value) + 1) % views.length];
ctrl.setView(next);
currentView.value = next;
}
// ── Selection handlers — all disabled guards live here ────────────────────
function handleSelectMonth(month: number): void {
if (isDisabled.value) return;
ctrl.goTo(ctrl.displayYear(), month);
ctrl.setView('day');
displayYear.value = ctrl.displayYear();
displayMonth.value = ctrl.displayMonth();
currentView.value = 'day';
}
function handleSelectYear(year: number): void {
if (isDisabled.value) return;
ctrl.goTo(year, ctrl.displayMonth());
ctrl.setView('month');
displayYear.value = ctrl.displayYear();
currentView.value = 'month';
}
function handleSelectDay(isoStr: string): void {
if (isDisabled.value) return;
const date = parseIso(isoStr);
if (!date) return;
ctrl.select(date); // ctrl.select() internally rejects disabled/out-of-range dates
}
// ── Day-cell keyboard navigation (full ARIA grid pattern) ─────────────────
function handleDayKeydown(e: KeyboardEvent): void {
const cell = e.currentTarget as HTMLElement;
const grid = cell.closest('.cal-grid-days');
if (!grid) return;
const allCells = Array.from(grid.querySelectorAll<HTMLElement>('.cal-cell-day'));
const idx = allCells.indexOf(cell);
if (idx === -1) return;
let target: HTMLElement | undefined;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelectDay(cell.dataset.iso ?? '');
return;
} else if (e.key === 'ArrowRight') {
target = allCells[idx + 1];
} else if (e.key === 'ArrowLeft') {
target = allCells[idx - 1];
} else if (e.key === 'ArrowDown') {
target = allCells[idx + 7];
} else if (e.key === 'ArrowUp') {
target = allCells[idx - 7];
} else if (e.key === 'Home') {
target = allCells[Math.floor(idx / 7) * 7]; // first cell in same row
} else if (e.key === 'End') {
target = allCells[Math.floor(idx / 7) * 7 + 6]; // last cell in same row
} else {
return;
}
e.preventDefault();
target?.focus();
}
// ── Host bindings ────────────────────────────────────────────────────────
bind({
attr: {
'aria-disabled': () => (isDisabled.value ? 'true' : null),
'aria-label': () => displayLabel.value,
role: 'group',
},
class: {
'is-disabled': isDisabled,
},
});
// ── Template ─────────────────────────────────────────────────────────────
return html`
<div class="calendar" part="calendar" role="presentation">
<!-- Header -->
<div class="cal-header" part="header">
<button class="nav-btn" type="button" aria-label="Previous" ?disabled="${isDisabled}" @click="${handlePrev}">
<sg-icon name="chevron-left" size="16" stroke-width="2" aria-hidden="true"></sg-icon>
</button>
<button
class="cal-label-btn"
type="button"
:aria-label="${() =>
`Switch to ${currentView.value === 'day' ? 'month' : currentView.value === 'month' ? 'year' : 'day'} view`}"
?disabled="${isDisabled}"
@click="${handleHeaderClick}">
<span class="cal-label-month">${displayMonth_}</span><span class="cal-label-sep" aria-hidden="true">/</span
><span class="cal-label-year">${displayYear_}</span>
</button>
<button class="nav-btn" type="button" aria-label="Next" ?disabled="${isDisabled}" @click="${handleNext}">
<sg-icon name="chevron-right" size="16" stroke-width="2" aria-hidden="true"></sg-icon>
</button>
</div>
<!-- Day view -->
<div
class="cal-grid cal-grid-days"
role="grid"
part="grid"
:aria-label="${() => displayLabel.value}"
?hidden="${() => currentView.value !== 'day'}">
${() =>
weekdayLabels.value.map(
(lbl) => html`<div class="cal-cell cal-cell-head" role="columnheader" aria-label="${lbl}">${lbl}</div>`,
)}
${() =>
dayCells.value.map(
(cell) =>
html`<div
class="cal-cell cal-cell-day"
role="gridcell"
part="day"
:aria-selected="${() => String(cell.isSelected)}"
:aria-disabled="${() => String(cell.isDisabled || isDisabled.value)}"
:aria-current="${() => (cell.isToday ? 'date' : null)}"
?data-selected="${() => cell.isSelected}"
?data-today="${() => cell.isToday}"
?data-outside="${() => cell.isOutsideMonth}"
?data-disabled="${() => cell.isDisabled || isDisabled.value}"
data-iso="${cell.iso}"
data-day="${String(cell.day)}"
tabindex="${() => (cell.isDisabled || isDisabled.value ? '-1' : '0')}"
@click="${() => handleSelectDay(cell.iso)}"
@keydown="${handleDayKeydown}">
${String(cell.day)}
${() => {
const evts = eventsByDate.value.get(cell.iso) ?? [];
if (!evts.length) return html``;
const shown = evts.slice(0, MAX_EVENTS);
const overflow = evts.length - MAX_EVENTS;
if (isExpanded.value) {
return html`<div
class="cal-events"
aria-label="${() => `${evts.length} event${evts.length > 1 ? 's' : ''}`}">
${shown.map(
(evt) =>
html`<sg-badge
class="cal-event-pill"
size="xs"
rounded="sm"
aria-label="${evt.label}"
style="${() =>
evt.color ? `--badge-bg:${evt.color};--badge-border-color:${evt.color}` : ''}">
${evt.label}
</sg-badge>`,
)}
${overflow > 0
? html`<span class="cal-event-pill-overflow" aria-hidden="true">+${overflow} more</span>`
: html``}
</div>`;
}
return html`<div
class="cal-dots"
aria-label="${() => `${evts.length} event${evts.length > 1 ? 's' : ''}`}">
${shown.map(
(evt) =>
html`<sg-badge
class="cal-dot"
dot
size="xs"
aria-hidden="true"
style="${() =>
evt.color
? `--badge-bg:${evt.color};--badge-border-color:${evt.color}`
: ''}"></sg-badge>`,
)}
${overflow > 0
? html`<span class="cal-dot-overflow" aria-hidden="true">+${overflow}</span>`
: html``}
</div>`;
}}
</div>`,
)}
</div>
<!-- Month view -->
<div
class="cal-grid cal-grid-months"
role="grid"
:aria-label="${() => displayYear_.value}"
?hidden="${() => currentView.value !== 'month'}">
${() =>
monthCells.value.map(
(cell) =>
html`<div
class="cal-cell cal-cell-month"
role="gridcell"
:aria-selected="${() => String(cell.isSelected)}"
:aria-disabled="${() => String(cell.isDisabled || isDisabled.value)}"
?data-selected="${() => cell.isSelected}"
?data-disabled="${() => cell.isDisabled || isDisabled.value}"
tabindex="${() => (cell.isDisabled || isDisabled.value ? '-1' : '0')}"
@click="${() => handleSelectMonth(cell.month)}"
@keydown="${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelectMonth(cell.month);
}
}}">
${cell.shortLabel}
</div>`,
)}
</div>
<!-- Year view -->
<div
class="cal-grid cal-grid-years"
role="grid"
aria-label="Select year"
?hidden="${() => currentView.value !== 'year'}">
${() =>
yearCells.value.map(
(cell) =>
html`<div
class="cal-cell cal-cell-year"
role="gridcell"
:aria-selected="${() => String(cell.isSelected)}"
:aria-disabled="${() => String(cell.isDisabled || isDisabled.value)}"
?data-selected="${() => cell.isSelected}"
?data-disabled="${() => cell.isDisabled || isDisabled.value}"
tabindex="${() => (cell.isDisabled || isDisabled.value ? '-1' : '0')}"
@click="${() => handleSelectYear(cell.year)}"
@keydown="${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelectYear(cell.year);
}
}}">
${String(cell.year)}
</div>`,
)}
</div>
</div>
`;
},
shadow: { delegatesFocus: true },
styles: [colorThemeMixin, reducedMotionMixin, componentStyles],
});Basic Usage
With Pre-selected Value
Min / Max Bounds
Disabled Weekends
Pass a JSON array of day-of-week indices (0 = Sunday … 6 = Saturday) to weekend-days.
Colors
Calendar Events
Pass an array of CalendarEvent objects via the events JS property. Each entry requires an id, a date (ISO 8601), and a label. An optional color accepts any CSS color value.
Normal mode — up to 3 colored dots per cell; additional events appear as a +N count.
Expanded mode — up to 3 colored pills with labels; additional events appear as +N more.
Expanded with events
Events overflow
When a day has more than 3 events, the first 3 are shown and the rest are summarised. This applies to both dots (normal mode) and pills (expanded mode).
Expanded Layout
Use expanded for a full-page, calendar-app style layout. Each day cell becomes tall with the day number shown as a circle in the top-left corner, leaving the remaining space for event content. The minimum cell height defaults to var(--size-28) and can be overridden with --calendar-expanded-cell-height.
Disabled
Localised
Inside a Form
Listening for Changes
document.querySelector('sg-calendar').addEventListener('change', (e) => {
console.log(e.detail.isoValue); // '2025-06-15' or null
});API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Selected date in ISO 8601 format (yyyy-MM-dd) |
min | string | — | Earliest selectable date (yyyy-MM-dd, inclusive) |
max | string | — | Latest selectable date (yyyy-MM-dd, inclusive) |
weekend-days | number[] | [] | JSON array of day-of-week indices to disable (0 = Sunday … 6 = Saturday). e.g. weekend-days="[0,6]" |
locale | string | browser locale | BCP 47 locale string for day/month names |
color | string | — | Theme color: primary | secondary | info | success | warning | error |
size | string | md | Component size: sm | md | lg |
rounded | string | — | Border radius override |
disabled | boolean | false | Disable all interaction |
required | boolean | false | Required field (form association) |
name | string | — | Form field name |
events | CalendarEvent[] | [] | Calendar events to display. Dots in normal mode, pills in expanded mode. Set via JS property |
expanded | boolean | false | Expanded calendar-app layout with tall cells and top-aligned day number circles |
fullwidth | boolean | false | Expand calendar to full container width |
CalendarEvent
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Unique identifier | |
date | string | ISO 8601 date the event falls on (yyyy-MM-dd) | |
label | string | Short label shown in the cell (pill text in expanded mode) | |
color | string | — | Any CSS color value. Falls back to the component's theme color |
Events
| Event | Detail | Description |
|---|---|---|
change | { isoValue: string | null } | Fired when a date is selected. isoValue is the ISO 8601 date string or null when cleared |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--calendar-bg | --color-canvas | Calendar background colour |
--calendar-border-color | --color-contrast-200 | Calendar border colour |
--calendar-radius | --rounded-xl | Calendar border radius |
--calendar-shadow | --shadow-md | Calendar drop shadow |
--calendar-day-selected-bg | theme focus color | Background of the selected day cell |
--calendar-day-today-color | theme focus color | Color of today's date number |
--calendar-day-outside-opacity | 0.35 | Opacity of days outside the visible month |
--calendar-expanded-cell-height | var(--size-28) | Minimum height of each day cell in the expanded layout |
Parts
| Part | Description |
|---|---|
calendar | The root calendar panel |
header | Calendar header (nav buttons + label) |
grid | The day grid |
day | Individual day cell |
Accessibility
sg-calendar implements the ARIA Grid Pattern.
- Roles: the host element carries
role="group"andaria-label(the currently visible month/year). The day grid usesrole="grid"withrole="columnheader"for weekday headers androle="gridcell"on each day cell. - Selection state: selected days have
aria-selected="true"; unselected havearia-selected="false". - Today: today's cell has
aria-current="date". - Disabled cells: out-of-range and disabled-weekday cells have
aria-disabled="true"andtabindex="-1", removing them from tab order. - Disabled host: when
disabledis set, the host getsaria-disabled="true", all cells becometabindex="-1", and no interaction is processed.
Keyboard Navigation
Day grid
| Key | Action |
|---|---|
ArrowRight | Move focus to the next day |
ArrowLeft | Move focus to the previous day |
ArrowDown | Move focus one week forward (same weekday) |
ArrowUp | Move focus one week back (same weekday) |
Home | Move focus to the first day of the current row |
End | Move focus to the last day of the current row |
Enter / Space | Select the focused day |
Month and year grids
| Key | Action |
|---|---|
Enter / Space | Select the focused month / year |
Header controls
| Key | Action |
|---|---|
Click / Enter | Previous / Next button — navigate by month or year |
Click / Enter | Label button — cycle view: Day → Month → Year → Day |
View Cycling
The header label button cycles through three views on each click:
- Day — the standard month grid; Previous/Next navigate by month
- Month — a 4×3 grid of abbreviated month names; click a month to return to day view at that month
- Year — a 4×3 grid of year numbers; click a year to go to month view for that year
The calendar panel maintains a stable size across all three views — day view reserves space for a maximum 6-week month, and month/year views fill the same width.
Related Components
- Date Picker — trigger + popup wrapper around the same calendar logic