Component

Month calendar

<DomMonthCalendar>

A large scrolling month calendar that keeps weekday grids aligned, supports continuous date streams, and lets each day render custom UI.

Playground

Try every prop live

Month calendar playground

Edit the range, display, and day styling props to preview a stacked month grid.

June 2026

24
8Mon
9Tue
10Wed
11Thu
12Fri
13Sat
14Sun
25
15Mon
16Tue
17Wed
18Thu
19Fri
20Sat
21Sun
26
22Mon
23Tue
24Wed
25Thu
26Fri
27Sat
28Sun
27
29Mon
30Tue
1Wed
2Thu
3Fri
4Sat
5Sun

July 2026

27
29Mon
30Tue
1Wed
2Thu
3Fri
4Sat
5Sun
28
6Mon
7Tue
8Wed
9Thu
10Fri
11Sat
12Sun
29
13Mon
14Tue
15Wed
16Thu
17Fri
18Sat
19Sun
30
20Mon
21Tue
22Wed
23Thu
24Fri
25Sat
26Sun
31
27Mon
28Tue
29Wed
30Thu
31Fri
1Sat
2Sun
Playground.vuevue
vue
<script setup>
import { reactive } from 'vue';
import { DomMonthCalendar } from '@getdom/studio/vue';

const data = reactive({
	  "startDate": "2026-06-11",
	  "initialMonth": null,
	  "initialYear": null,
	  "months": 2,
	  "locale": "en-GB",
	  "weekStartsOn": 1,
	  "weekdayFormat": "short",
	  "showAdjacentDays": true,
	  "fixedWeeks": false,
	  "continuous": false,
	  "containedContinuousScroll": true,
	  "hoverableDays": true,
	  "showWeekNumbers": true,
	  "mutedBefore": "2026-06-11",
	  "mutedAfter": "",
	  "mutedDayClass": "bg-secondary text-muted-fg",
	  "adjacentDayClass": "bg-secondary/40 text-muted-fg/70",
	  "min": "",
	  "max": "",
	  "clickable": false,
	  "clickableAdjacentDays": false,
	  "dragAndDrop": false,
	  "dropEffect": "move",
	  "disabledDate": null,
	  "dayHeaders": true,
	  "dayClass": "",
	  "dayStyle": ""
	});
</script>

<template>
	<DomMonthCalendar
		v-bind="data"
	/>
</template>

Demo

Continuous calendar

Render one uninterrupted date stream across several months, with draggable milestones, compact month labels in the left rail, and sticky weekday headers while the range scrolls.

Range

June-November 2026

Milestones

12 across 12 days

Owner

Launch operations

Continuous calendar

Continuous launch runway
Jun 26
1Mon
2Tue
Cutover rehearsal
Platform
3Wed
4Thu
5Fri
6Sat
7Sun
8Mon
Partner QA window
Integrations
9Tue
10Wed
11Thu
12Fri
13Sat
14Sun
15Mon
16Tue
17Wed
Billing dry run
Revenue
18Thu
19Fri
20Sat
21Sun
22Mon
23Tue
24Wed
25Thu
Release candidate
Product
26Fri
27Sat
28Sun
Jul 26
29Mon
30Tue
1Wed
EU launch
Launch
2Thu
3Fri
4Sat
5Sun
6Mon
7Tue
8Wed
9Thu
Support review
Success
10Fri
11Sat
12Sun
13Mon
14Tue
15Wed
16Thu
17Fri
18Sat
19Sun
20Mon
Adoption readout
Growth
21Tue
22Wed
23Thu
24Fri
25Sat
26Sun
Aug 26
27Mon
28Tue
29Wed
30Thu
31Fri
1Sat
2Sun
3Mon
4Tue
Scale test
Infrastructure
5Wed
6Thu
7Fri
8Sat
9Sun
10Mon
11Tue
12Wed
13Thu
14Fri
15Sat
16Sun
17Mon
18Tue
Renewal campaign
Revenue
19Wed
20Thu
21Fri
22Sat
23Sun
24Mon
25Tue
26Wed
27Thu
28Fri
29Sat
30Sun
Sept 26
31Mon
1Tue
2Wed
3Thu
Regional enablement
Sales
4Fri
5Sat
6Sun
7Mon
8Tue
9Wed
10Thu
11Fri
12Sat
13Sun
14Mon
15Tue
16Wed
17Thu
18Fri
19Sat
20Sun
21Mon
22Tue
23Wed
24Thu
25Fri
26Sat
27Sun
Oct 26
28Mon
29Tue
30Wed
1Thu
2Fri
3Sat
4Sun
5Mon
6Tue
7Wed
Reliability review
Infrastructure
8Thu
9Fri
10Sat
11Sun
12Mon
13Tue
14Wed
15Thu
16Fri
17Sat
18Sun
19Mon
20Tue
21Wed
22Thu
23Fri
24Sat
25Sun
Nov 26
26Mon
27Tue
28Wed
29Thu
30Fri
31Sat
1Sun
2Mon
3Tue
4Wed
5Thu
6Fri
7Sat
8Sun
9Mon
10Tue
11Wed
12Thu
Expansion decision
Leadership
13Fri
14Sat
15Sun
16Mon
17Tue
18Wed
19Thu
20Fri
21Sat
22Sun
23Mon
24Tue
25Wed
26Thu
27Fri
28Sat
29Sun
30Mon
vue
<script setup>
import { computed, ref } from 'vue';
import { DomMonthCalendar } from '../../../lib/vue';

const milestones = ref([
	{ id: 'cutover-rehearsal', date: '2026-06-02', label: 'Cutover rehearsal', team: 'Platform', status: 'ready' },
	{ id: 'partner-qa-window', date: '2026-06-08', label: 'Partner QA window', team: 'Integrations', status: 'watch' },
	{ id: 'billing-dry-run', date: '2026-06-17', label: 'Billing dry run', team: 'Revenue', status: 'ready' },
	{ id: 'release-candidate', date: '2026-06-25', label: 'Release candidate', team: 'Product', status: 'critical' },
	{ id: 'eu-launch', date: '2026-07-01', label: 'EU launch', team: 'Launch', status: 'critical' },
	{ id: 'support-review', date: '2026-07-09', label: 'Support review', team: 'Success', status: 'watch' },
	{ id: 'adoption-readout', date: '2026-07-20', label: 'Adoption readout', team: 'Growth', status: 'ready' },
	{ id: 'scale-test', date: '2026-08-04', label: 'Scale test', team: 'Infrastructure', status: 'watch' },
	{ id: 'renewal-campaign', date: '2026-08-18', label: 'Renewal campaign', team: 'Revenue', status: 'ready' },
	{ id: 'regional-enablement', date: '2026-09-03', label: 'Regional enablement', team: 'Sales', status: 'ready' },
	{ id: 'reliability-review', date: '2026-10-07', label: 'Reliability review', team: 'Infrastructure', status: 'watch' },
	{ id: 'expansion-decision', date: '2026-11-12', label: 'Expansion decision', team: 'Leadership', status: 'critical' },
]);

const milestonesByDate = computed(() => groupMilestonesByDate(milestones.value));
const activeDates = computed(() => Object.keys(milestonesByDate.value).length);

/**
 * Groups milestone records by their date key.
 *
 * @param {Array<{ date: string }>} records Milestones to group for calendar lookup.
 * @returns {Record<string, Array<object>>} Milestones keyed by YYYY-MM-DD.
 */
function groupMilestonesByDate(records) {
	return records.reduce((groups, record) => {
		groups[record.date] = [...(groups[record.date] || []), record];
		return groups;
	}, {});
}

/**
 * Returns all milestones assigned to a rendered day.
 *
 * @param {string} value YYYY-MM-DD date key from the calendar cell.
 * @returns {Array<object>} Milestones for the requested date.
 */
function milestonesFor(value) {
	return milestonesByDate.value[value] || [];
}

/**
 * Creates the app-owned payload attached to a draggable milestone card.
 *
 * @param {{ id: string }} milestone Milestone being dragged.
 * @returns {{ id: string }} Minimal payload needed to move the milestone.
 */
function dragDataFor(milestone) {
	return { id: milestone.id };
}

/**
 * Moves a milestone to the date that received the drop gesture.
 *
 * @param {{ data?: { id?: string }, targetValue?: string }} payload Calendar drag/drop payload.
 * @returns {void}
 */
function moveMilestone(payload) {
	const milestoneId = payload.data?.id;
	const targetDate = payload.targetValue;
	if (!milestoneId || !targetDate) return;
	milestones.value = milestones.value.map((milestone) => {
		if (milestone.id !== milestoneId) return milestone;
		return {
			...milestone,
			date: targetDate,
		};
	});
}

/**
 * Highlights days that contain operational milestones.
 *
 * @param {import('../../../lib/vue').MonthCalendarCell} cell Rendered calendar cell.
 * @returns {string} Vue class binding for the day cell.
 */
function dayClass(cell) {
	if (!milestonesFor(cell.value).length) return '';
	return 'bg-primary/5';
}

/**
 * Resolves visual treatment for a milestone status.
 *
 * @param {{ status: string }} milestone Milestone record rendered inside a day.
 * @returns {string} Semantic status classes.
 */
function milestoneClass(milestone) {
	return {
		ready: 'border-success/20 bg-success/10 text-success',
		watch: 'border-warning/30 bg-warning/15 text-warning-fg',
		critical: 'border-destructive/20 bg-destructive/10 text-destructive',
	}[milestone.status] || 'border-border bg-secondary/45 text-canvas-fg';
}
</script>

<template>
	<div class="w-[78rem] max-w-full space-y-4" data-testid="continuous-operations-calendar-example">
		<section class="grid gap-3 rounded-lg border border-border bg-secondary/25 p-4 text-sm md:grid-cols-3">
			<div>
				<p class="text-xs font-semibold uppercase tracking-wide text-muted-fg">Range</p>
				<p class="mt-1 font-bold text-canvas-fg">June-November 2026</p>
			</div>
			<div>
				<p class="text-xs font-semibold uppercase tracking-wide text-muted-fg">Milestones</p>
				<p class="mt-1 font-bold text-canvas-fg">{{ milestones.length }} across {{ activeDates }} days</p>
			</div>
			<div>
				<p class="text-xs font-semibold uppercase tracking-wide text-muted-fg">Owner</p>
				<p class="mt-1 font-bold text-canvas-fg">Launch operations</p>
			</div>
		</section>

		<DomMonthCalendar
			start-date="2026-06-01"
			:months="6"
			:day-class="dayClass"
			drag-and-drop
			continuous
			@day-drop="moveMilestone"
		>
			<template #month-header>
				<span>Continuous launch runway</span>
			</template>

			<template #default="{ cell, drag, dragAttrs }">
				<div v-if="milestonesFor(cell.value).length" class="space-y-2">
					<article
						v-for="milestone in milestonesFor(cell.value)"
						:key="milestone.id"
						v-bind="dragAttrs(dragDataFor(milestone))"
						class="cursor-grab rounded-lg border px-2.5 py-2 text-xs font-semibold leading-4 shadow-xs active:cursor-grabbing"
						:class="[
							milestoneClass(milestone),
							drag.source && drag.data?.id === milestone.id && 'opacity-60',
						]"
					>
						<div>{{ milestone.label }}</div>
						<div class="mt-1 font-medium opacity-75">{{ milestone.team }}</div>
					</article>
				</div>
			</template>
		</DomMonthCalendar>
	</div>
</template>

Demo

Coparent calendar

Use dayClass and dayStyle to colour full days, then listen for day-click to change future assignments.

June 2026

Steve / Linda care schedule

July 2026

Steve / Linda care schedule

August 2026

Steve / Linda care schedule
vue
<script setup>
import { ref } from 'vue';
import { DomMonthCalendar } from '../../../lib/vue';

const today = '2026-06-11';
const overrides = ref({});
const handovers = {
	'2026-06-12': 'Handover at drama class',
	'2026-06-19': 'School pickup at 3:20',
	'2026-06-26': 'Linda takes kit bag',
	'2026-07-03': 'Steve collects from camp',
	'2026-07-10': 'Handover after swimming',
};

function parseDate(value) {
	const [year, month, day] = value.split('-').map(Number);
	return new Date(year, month - 1, day);
}

function daysBetween(a, b) {
	const start = parseDate(a);
	const end = parseDate(b);
	return Math.round((end - start) / 86400000);
}

function baseParent(value) {
	return Math.floor(daysBetween('2026-06-01', value) / 7) % 2 === 0 ? 'Steve' : 'Linda';
}

function parentFor(value) {
	return overrides.value[value] || baseParent(value);
}

function toggleParent({ cell }) {
	if (!cell.currentMonth || cell.value < today) return;
	const current = parentFor(cell.value);
	overrides.value = {
		...overrides.value,
		[cell.value]: current === 'Steve' ? 'Linda' : 'Steve',
	};
}

function disabledDate(cell) {
	return !cell.currentMonth || cell.value < today;
}

function dayStyle(cell) {
	if (!cell.currentMonth) return '';
	const parent = parentFor(cell.value);
	const past = cell.value < today;
	if (parent === 'Steve') {
		return { backgroundColor: past ? '#fff1f2' : '#fee2e2' };
	}
	return { backgroundColor: past ? '#f0fdf4' : '#dcfce7' };
}

function dayClass(cell) {
	if (!cell.currentMonth) return '';
	return parentFor(cell.value) === 'Steve' ? 'text-red-950' : 'text-emerald-950';
}
</script>

<template>
	<div class="w-[76rem] max-w-full" data-testid="coparent-example">
		<DomMonthCalendar
			start-date="2026-06-11"
			:months="3"
			:min="today"
			:muted-before="today"
			clickable
			:disabled-date="disabledDate"
			:day-style="dayStyle"
			:day-class="dayClass"
			@day-click="toggleParent"
		>
			<template #month-header>
				<span>Steve / Linda care schedule</span>
			</template>

			<template #default="{ cell }">
				<div v-if="cell.currentMonth" class="flex h-[calc(100%-1.75rem)] flex-col justify-between gap-3">
					<div class="text-sm font-bold">
						{{ parentFor(cell.value) }}
					</div>

					<div v-if="handovers[cell.value]" class="rounded-lg border border-current/15 bg-white/70 px-2.5 py-2 text-xs font-semibold leading-4 shadow-xs">
						<div class="uppercase tracking-wide opacity-70">Handover</div>
						<div class="mt-1">{{ handovers[cell.value] }}</div>
					</div>
				</div>
			</template>
		</DomMonthCalendar>
	</div>
</template>

Demo

Customisable days

Keep day artwork in app-owned state: apply image backgrounds, paint freehand strokes directly into a canvas on each date, and set an independent background colour for every event.

Personal calendar canvas

Make each day your own

Select a day to add an image or change event colours. Switch to Draw, then drag directly across any day.

July 2026

Select a day to edit
vue
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { DomButton, DomMonthCalendar } from '../../../../lib/vue';

/**
 * @typedef {object} CalendarPoint
 * @property {number} x Horizontal position normalised between zero and one.
 * @property {number} y Vertical position normalised between zero and one.
 */

/**
 * @typedef {object} CalendarStroke
 * @property {string} id Stable stroke identifier.
 * @property {string} color CSS colour used to draw the stroke.
 * @property {number} width Stroke width in CSS pixels.
 * @property {Array<CalendarPoint>} points Normalised points captured from pointer movement.
 */

/**
 * @typedef {object} DayCustomisation
 * @property {string} image Background image URL or data URL.
 * @property {string} imageLabel Human-readable image name.
 */

/**
 * @typedef {object} CalendarEvent
 * @property {string} id Stable event identifier.
 * @property {string} date YYYY-MM-DD date key.
 * @property {string} title Visible event title.
 * @property {string} color User-selected CSS background colour.
 */

const startDate = '2026-07-01';
const activeDate = ref('2026-07-13');
const activeTool = ref('select');
const brushColor = ref('#2563eb');
const imageInput = ref(null);
const uploadError = ref('');
const drawingRevision = ref(0);
let nextStrokeId = 4;
let nextEventId = 7;
let activePointerId = null;
let activeStroke = null;
let activeCanvas = null;
let activeCanvasBounds = null;
let resizeObserver = null;
const canvasRefs = new Map();
const canvasRefCallbacks = new Map();

const backgroundChoices = [
	{
		label: 'Garden',
		image: createIllustrationBackground({
			sky: '#dbeafe',
			ground: '#4d7c0f',
			foreground: '#14532d',
			accent: '#facc15',
		}),
	},
	{
		label: 'Coast',
		image: createIllustrationBackground({
			sky: '#cffafe',
			ground: '#0e7490',
			foreground: '#164e63',
			accent: '#fef3c7',
		}),
	},
	{
		label: 'Sunset',
		image: createIllustrationBackground({
			sky: '#fed7aa',
			ground: '#c2410c',
			foreground: '#7c2d12',
			accent: '#fef08a',
		}),
	},
];
const brushColours = ['#2563eb', '#db2777', '#16a34a', '#f59e0b', '#ffffff'];
const eventColours = ['#2563eb', '#7c3aed', '#db2777', '#ea580c', '#15803d', '#0f766e'];
const dayCustomisations = reactive({
	'2026-07-08': {
		image: backgroundChoices[0].image,
		imageLabel: backgroundChoices[0].label,
	},
	'2026-07-13': {
		image: backgroundChoices[1].image,
		imageLabel: backgroundChoices[1].label,
	},
	'2026-07-19': {
		image: backgroundChoices[2].image,
		imageLabel: backgroundChoices[2].label,
	},
});
const dayDrawings = new Map([
	['2026-07-13', [
		{
			id: 'stroke-1',
			color: '#ffffff',
			width: 4,
			points: [
				{ x: 0.09, y: 0.72 },
				{ x: 0.22, y: 0.58 },
				{ x: 0.34, y: 0.69 },
				{ x: 0.47, y: 0.47 },
				{ x: 0.63, y: 0.63 },
				{ x: 0.78, y: 0.35 },
				{ x: 0.92, y: 0.51 },
			],
		},
	]],
	['2026-07-19', [
		{
			id: 'stroke-2',
			color: '#fef08a',
			width: 5,
			points: [
				{ x: 0.20, y: 0.50 },
				{ x: 0.28, y: 0.35 },
				{ x: 0.38, y: 0.29 },
				{ x: 0.47, y: 0.43 },
				{ x: 0.50, y: 0.58 },
				{ x: 0.57, y: 0.70 },
				{ x: 0.68, y: 0.68 },
				{ x: 0.80, y: 0.50 },
			],
		},
		{
			id: 'stroke-3',
			color: '#ffffff',
			width: 3,
			points: [
				{ x: 0.32, y: 0.78 },
				{ x: 0.44, y: 0.68 },
				{ x: 0.57, y: 0.68 },
				{ x: 0.69, y: 0.78 },
			],
		},
	]],
]);
const events = ref([
	{ id: 'event-1', date: '2026-07-08', title: 'Garden lunch', color: '#15803d' },
	{ id: 'event-2', date: '2026-07-13', title: 'Coastal walk', color: '#0f766e' },
	{ id: 'event-3', date: '2026-07-13', title: 'Book dinner', color: '#db2777' },
	{ id: 'event-4', date: '2026-07-19', title: 'Sunset picnic', color: '#ea580c' },
	{ id: 'event-5', date: '2026-07-23', title: 'Studio review', color: '#7c3aed' },
	{ id: 'event-6', date: '2026-07-28', title: 'Publish update', color: '#2563eb' },
]);

/**
 * Resolves the selected day's customisation without creating state during render.
 *
 * @returns {DayCustomisation|null} Existing day customisation or null.
 */
const activeCustomisation = computed(() => dayCustomisations[activeDate.value] || null);

/**
 * Filters calendar events down to the selected date.
 *
 * @returns {Array<CalendarEvent>} Events belonging to the selected date.
 */
const activeEvents = computed(() => events.value.filter((event) => event.date === activeDate.value));

/**
 * Formats the selected date for the editor heading.
 *
 * @returns {string} Localised long date label.
 */
const activeDateLabel = computed(() => formatDateLabel(activeDate.value));

/**
 * Reports whether the selected day has at least one stored canvas stroke.
 *
 * drawingRevision intentionally updates only after a stroke completes or is
 * cleared, so live pointer movement never enters Vue's reactive render cycle.
 *
 * @returns {boolean} True when the selected day has stored drawing content.
 */
const activeHasDrawing = computed(() => {
	return drawingRevision.value >= 0 && Boolean(dayDrawings.get(activeDate.value)?.length);
});

onMounted(mountCanvasLayer);
onBeforeUnmount(unmountCanvasLayer);

/**
 * Creates an embedded SVG landscape suitable for an offline image preset.
 *
 * @param {{ sky: string, ground: string, foreground: string, accent: string }} colours Illustration colours.
 * @returns {string} Encoded SVG data URL.
 */
function createIllustrationBackground(colours) {
	const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">
		<defs>
			<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
				<stop offset="0" stop-color="${colours.sky}"/>
				<stop offset="1" stop-color="${colours.ground}"/>
			</linearGradient>
		</defs>
		<rect width="800" height="600" fill="url(#sky)"/>
		<circle cx="620" cy="145" r="76" fill="${colours.accent}" fill-opacity=".9"/>
		<path d="M0 420 C150 300 270 360 390 410 C520 465 655 305 800 350 V600 H0Z" fill="${colours.ground}" fill-opacity=".86"/>
		<path d="M0 500 C175 405 315 520 455 470 C590 420 700 455 800 410 V600 H0Z" fill="${colours.foreground}" fill-opacity=".9"/>
	</svg>`;
	return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}

/**
 * Formats a YYYY-MM-DD key without introducing UTC date drift.
 *
 * @param {string} value Date key to format.
 * @returns {string} Long date label.
 */
function formatDateLabel(value) {
	const [year, month, day] = value.split('-').map(Number);
	return new Intl.DateTimeFormat('en-GB', {
		weekday: 'long',
		day: 'numeric',
		month: 'long',
		year: 'numeric',
	}).format(new Date(year, month - 1, day));
}

/**
 * Returns existing customisation state or creates an empty record for a date.
 *
 * @param {string} value Date key that should own the customisation.
 * @returns {DayCustomisation} Mutable reactive customisation record.
 */
function customisationFor(value) {
	if (!dayCustomisations[value]) {
		dayCustomisations[value] = {
			image: '',
			imageLabel: '',
		};
	}
	return dayCustomisations[value];
}

/**
 * Selects a current-month day from the calendar's app-owned click payload.
 *
 * @param {{ cell: { currentMonth: boolean, value: string } }} payload Calendar day click payload.
 * @returns {void}
 */
function selectDay({ cell }) {
	if (!cell.currentMonth) return;
	activeDate.value = cell.value;
}

/**
 * Switches between selecting days and drawing directly on them.
 *
 * @param {'select'|'draw'} tool Tool to activate.
 * @returns {void}
 */
function setTool(tool) {
	activeTool.value = tool;
}

/**
 * Resolves calendar cell classes that expose the selected and image-backed states.
 *
 * @param {{ currentMonth: boolean, value: string }} cell Calendar day cell.
 * @returns {Array<string|boolean>} Classes accepted by Vue's class binding.
 */
function dayClass(cell) {
	if (!cell.currentMonth) return [];
	const hasImage = Boolean(dayCustomisations[cell.value]?.image);
	return [
		'relative isolate overflow-hidden',
		hasImage && 'text-white',
		cell.value === activeDate.value && 'ring-2 ring-inset ring-primary',
	];
}

/**
 * Builds the layered background style for an image-backed calendar day.
 *
 * @param {{ currentMonth: boolean, value: string }} cell Calendar day cell.
 * @returns {Record<string, string>} Inline style object for the day cell.
 */
function dayStyle(cell) {
	const image = cell.currentMonth ? dayCustomisations[cell.value]?.image : '';
	if (!image) return {};
	return {
		backgroundImage: `linear-gradient(rgb(15 23 42 / 18%), rgb(15 23 42 / 42%)), url("${image}")`,
		backgroundPosition: 'center',
		backgroundRepeat: 'no-repeat',
		backgroundSize: 'cover',
		textShadow: '0 1px 2px rgb(15 23 42 / 70%)',
	};
}

/**
 * Finds the events assigned to a date.
 *
 * @param {string} value Date key to inspect.
 * @returns {Array<CalendarEvent>} Events assigned to the date.
 */
function eventsFor(value) {
	return events.value.filter((event) => event.date === value);
}

/**
 * Applies a preset image to the selected day.
 *
 * @param {{ label: string, image: string }} choice Selected background preset.
 * @returns {void}
 */
function setBackground(choice) {
	const customisation = customisationFor(activeDate.value);
	customisation.image = choice.image;
	customisation.imageLabel = choice.label;
	uploadError.value = '';
}

/**
 * Removes the background image from the selected day without touching drawings.
 *
 * @returns {void}
 */
function clearBackground() {
	const customisation = customisationFor(activeDate.value);
	customisation.image = '';
	customisation.imageLabel = '';
	uploadError.value = '';
}

/**
 * Opens the isolated file input used for per-day image uploads.
 *
 * @returns {void}
 */
function chooseBackgroundFile() {
	imageInput.value?.click();
}

/**
 * Reads an image file and stores it as a self-contained data URL on the active day.
 *
 * @param {Event} event Native file-input change event.
 * @returns {Promise<void>}
 */
async function uploadBackground(event) {
	const input = event.target;
	const file = input.files?.[0];
	if (!file) return;

	try {
		const image = await readFileAsDataUrl(file);
		const customisation = customisationFor(activeDate.value);
		customisation.image = image;
		customisation.imageLabel = file.name;
		uploadError.value = '';
	} catch {
		uploadError.value = 'That image could not be read. Please try another file.';
	} finally {
		input.value = '';
	}
}

/**
 * Converts a local file into a data URL without introducing server or shared state.
 *
 * @param {File} file Image file to read.
 * @returns {Promise<string>} Data URL containing the file contents.
 */
function readFileAsDataUrl(file) {
	return new Promise((resolve, reject) => {
		const reader = new FileReader();
		reader.addEventListener('load', () => resolve(String(reader.result || '')), { once: true });
		reader.addEventListener('error', () => reject(reader.error), { once: true });
		reader.readAsDataURL(file);
	});
}

/**
 * Builds a preview style for a background preset button.
 *
 * @param {{ image: string }} choice Background preset.
 * @returns {Record<string, string>} Inline preview style.
 */
function backgroundPreviewStyle(choice) {
	return {
		backgroundImage: `linear-gradient(rgb(15 23 42 / 8%), rgb(15 23 42 / 28%)), url("${choice.image}")`,
		backgroundPosition: 'center',
		backgroundSize: 'cover',
	};
}

/**
 * Selects the active freehand brush colour.
 *
 * @param {string} colour CSS colour value.
 * @returns {void}
 */
function setBrushColour(colour) {
	brushColor.value = colour;
}

/**
 * Returns a stable Vue function ref for one day's canvas.
 *
 * @param {string} value Date key represented by the canvas.
 * @returns {(element: HTMLCanvasElement|null) => void} Stable canvas ref callback.
 */
function canvasRefFor(value) {
	if (!canvasRefCallbacks.has(value)) {
		canvasRefCallbacks.set(value, (element) => setCanvasRef(value, element));
	}
	return canvasRefCallbacks.get(value);
}

/**
 * Registers or removes a day canvas without placing the element in reactive state.
 *
 * @param {string} value Date key represented by the canvas.
 * @param {HTMLCanvasElement|null} element Mounted canvas element or null on removal.
 * @returns {void}
 */
function setCanvasRef(value, element) {
	const previous = canvasRefs.get(value);
	if (!element) {
		if (previous) resizeObserver?.unobserve(previous);
		canvasRefs.delete(value);
		return;
	}

	canvasRefs.set(value, element);
	resizeObserver?.observe(element);
	resizeCanvas(value, element);
}

/**
 * Starts responsive canvas observation and paints all seeded drawings.
 *
 * @returns {void}
 */
function mountCanvasLayer() {
	if (typeof ResizeObserver !== 'undefined') {
		resizeObserver = new ResizeObserver(handleCanvasResize);
	}
	for (const [value, canvas] of canvasRefs) {
		resizeObserver?.observe(canvas);
		resizeCanvas(value, canvas);
	}
}

/**
 * Disconnects canvas observation and releases imperative element references.
 *
 * @returns {void}
 */
function unmountCanvasLayer() {
	resizeObserver?.disconnect();
	resizeObserver = null;
	canvasRefs.clear();
	canvasRefCallbacks.clear();
	activeCanvas = null;
	activeCanvasBounds = null;
	activeStroke = null;
	activePointerId = null;
}

/**
 * Repaints canvases whose CSS dimensions changed.
 *
 * @param {Array<ResizeObserverEntry>} entries Canvas resize observations.
 * @returns {void}
 */
function handleCanvasResize(entries) {
	for (const entry of entries) {
		const canvas = entry.target;
		const value = canvas.dataset.drawingDate;
		if (value) resizeCanvas(value, canvas);
	}
}

/**
 * Sizes a canvas for its CSS box and current device pixel ratio, then repaints it.
 *
 * @param {string} value Date key represented by the canvas.
 * @param {HTMLCanvasElement} canvas Canvas element to size.
 * @returns {void}
 */
function resizeCanvas(value, canvas) {
	const bounds = canvas.getBoundingClientRect();
	if (!bounds.width || !bounds.height) return;

	const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
	const width = Math.max(1, Math.round(bounds.width * pixelRatio));
	const height = Math.max(1, Math.round(bounds.height * pixelRatio));
	if (canvas.width !== width || canvas.height !== height) {
		canvas.width = width;
		canvas.height = height;
	}

	const context = canvas.getContext('2d');
	if (!context) return;
	context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
	paintDayCanvas(value, canvas, bounds);
}

/**
 * Clears and repaints every stored stroke for a day.
 *
 * @param {string} value Date key represented by the canvas.
 * @param {HTMLCanvasElement} canvas Canvas receiving the drawing.
 * @param {DOMRect|DOMRectReadOnly} bounds Current CSS bounds for the canvas.
 * @returns {void}
 */
function paintDayCanvas(value, canvas, bounds) {
	const context = canvas.getContext('2d');
	if (!context) return;
	context.clearRect(0, 0, bounds.width, bounds.height);
	for (const stroke of dayDrawings.get(value) || []) {
		paintStoredStroke(context, stroke, bounds);
	}
}

/**
 * Paints one stored stroke using normalised coordinates.
 *
 * @param {CanvasRenderingContext2D} context Canvas rendering context.
 * @param {CalendarStroke} stroke Stroke to paint.
 * @param {DOMRect|DOMRectReadOnly} bounds Current CSS bounds for the canvas.
 * @returns {void}
 */
function paintStoredStroke(context, stroke, bounds) {
	const firstPoint = stroke.points[0];
	if (!firstPoint) return;
	if (stroke.points.length === 1) {
		paintPoint(context, firstPoint, stroke, bounds);
		return;
	}

	context.beginPath();
	context.moveTo(firstPoint.x * bounds.width, firstPoint.y * bounds.height);
	for (const point of stroke.points.slice(1)) {
		context.lineTo(point.x * bounds.width, point.y * bounds.height);
	}
	applyStrokeStyle(context, stroke);
	context.stroke();
}

/**
 * Applies the shared round freehand style to a canvas context.
 *
 * @param {CanvasRenderingContext2D} context Canvas rendering context.
 * @param {CalendarStroke} stroke Stroke supplying colour and width.
 * @returns {void}
 */
function applyStrokeStyle(context, stroke) {
	context.strokeStyle = stroke.color;
	context.fillStyle = stroke.color;
	context.lineWidth = stroke.width;
	context.lineCap = 'round';
	context.lineJoin = 'round';
}

/**
 * Paints a single-point stroke as a round dot.
 *
 * @param {CanvasRenderingContext2D} context Canvas rendering context.
 * @param {CalendarPoint} point Point to paint.
 * @param {CalendarStroke} stroke Stroke supplying colour and width.
 * @param {DOMRect|DOMRectReadOnly} bounds Current CSS bounds for the canvas.
 * @returns {void}
 */
function paintPoint(context, point, stroke, bounds) {
	applyStrokeStyle(context, stroke);
	context.beginPath();
	context.arc(
		point.x * bounds.width,
		point.y * bounds.height,
		Math.max(1, stroke.width / 2),
		0,
		Math.PI * 2,
	);
	context.fill();
}

/**
 * Paints one new segment directly into the active canvas bitmap.
 *
 * @param {CanvasRenderingContext2D} context Canvas rendering context.
 * @param {CalendarPoint} from Segment starting point.
 * @param {CalendarPoint} to Segment ending point.
 * @param {CalendarStroke} stroke Stroke supplying colour and width.
 * @param {DOMRect|DOMRectReadOnly} bounds Current CSS bounds for the canvas.
 * @returns {void}
 */
function paintSegment(context, from, to, stroke, bounds) {
	applyStrokeStyle(context, stroke);
	context.beginPath();
	context.moveTo(from.x * bounds.width, from.y * bounds.height);
	context.lineTo(to.x * bounds.width, to.y * bounds.height);
	context.stroke();
}

/**
 * Starts a freehand canvas stroke for the day under the pointer.
 *
 * @param {{ currentMonth: boolean, value: string }} cell Calendar day cell.
 * @param {PointerEvent} event Native pointer event from the drawing surface.
 * @returns {void}
 */
function startDrawing(cell, event) {
	if (activeTool.value !== 'draw' || !cell.currentMonth) return;
	const canvas = event.currentTarget;
	const bounds = canvas.getBoundingClientRect();
	const point = pointerPosition(event, bounds);
	const stroke = {
		id: `stroke-${nextStrokeId++}`,
		color: brushColor.value,
		width: 4,
		points: [point],
	};
	const strokes = dayDrawings.get(cell.value) || [];
	strokes.push(stroke);
	dayDrawings.set(cell.value, strokes);
	activeStroke = {
		date: cell.value,
		stroke,
	};
	activeCanvas = canvas;
	activeCanvasBounds = bounds;
	activePointerId = event.pointerId;
	canvas.setPointerCapture?.(event.pointerId);

	const context = canvas.getContext('2d');
	if (context) paintPoint(context, point, stroke, bounds);
}

/**
 * Paints pointer samples directly into the active canvas without reactive writes.
 *
 * @param {PointerEvent} event Native pointer event from the drawing surface.
 * @returns {void}
 */
function continueDrawing(event) {
	if (!activeStroke || !activeCanvas || !activeCanvasBounds || event.pointerId !== activePointerId) return;
	const context = activeCanvas.getContext('2d');
	if (!context) return;

	const samples = typeof event.getCoalescedEvents === 'function'
		? event.getCoalescedEvents()
		: [event];
	for (const sample of samples.length ? samples : [event]) {
		const point = pointerPosition(sample, activeCanvasBounds);
		const points = activeStroke.stroke.points;
		const previous = points[points.length - 1];
		if (pointsAreTooClose(previous, point, activeCanvasBounds)) continue;
		points.push(point);
		paintSegment(context, previous, point, activeStroke.stroke, activeCanvasBounds);
	}
}

/**
 * Finishes the active freehand stroke and releases pointer capture.
 *
 * @param {PointerEvent} event Native pointer event from the drawing surface.
 * @returns {void}
 */
function finishDrawing(event) {
	if (event.pointerId !== activePointerId) return;
	if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
		event.currentTarget.releasePointerCapture(event.pointerId);
	}
	const completedDate = activeStroke?.date || '';
	activeStroke = null;
	activeCanvas = null;
	activeCanvasBounds = null;
	activePointerId = null;
	if (completedDate) {
		activeDate.value = completedDate;
		drawingRevision.value += 1;
	}
}

/**
 * Converts a pointer position into normalised canvas coordinates.
 *
 * @param {PointerEvent} event Native pointer event from a canvas drawing surface.
 * @param {DOMRect|DOMRectReadOnly} bounds Cached canvas bounds from pointerdown.
 * @returns {CalendarPoint} Clamped normalised canvas coordinates.
 */
function pointerPosition(event, bounds) {
	const x = (event.clientX - bounds.left) / bounds.width;
	const y = (event.clientY - bounds.top) / bounds.height;
	return {
		x: clampCoordinate(x),
		y: clampCoordinate(y),
	};
}

/**
 * Keeps a drawing coordinate inside the normalised canvas range.
 *
 * @param {number} value Candidate coordinate.
 * @returns {number} Coordinate clamped between zero and one.
 */
function clampCoordinate(value) {
	return Math.min(1, Math.max(0, value));
}

/**
 * Avoids painting points whose movement is visually insignificant.
 *
 * @param {CalendarPoint} previous Previous stored point.
 * @param {CalendarPoint} next Candidate next point.
 * @param {DOMRect|DOMRectReadOnly} bounds Current CSS bounds for the canvas.
 * @returns {boolean} True when the points are less than one CSS pixel apart.
 */
function pointsAreTooClose(previous, next, bounds) {
	const horizontalDistance = (next.x - previous.x) * bounds.width;
	const verticalDistance = (next.y - previous.y) * bounds.height;
	return (horizontalDistance ** 2) + (verticalDistance ** 2) < 1;
}

/**
 * Removes every freehand stroke from the selected day.
 *
 * @returns {void}
 */
function clearDrawing() {
	dayDrawings.delete(activeDate.value);
	const canvas = canvasRefs.get(activeDate.value);
	if (canvas) resizeCanvas(activeDate.value, canvas);
	drawingRevision.value += 1;
}

/**
 * Adds a sample event to the selected day so its colour can be customised.
 *
 * @returns {void}
 */
function addEvent() {
	events.value = [
		...events.value,
		{
			id: `event-${nextEventId++}`,
			date: activeDate.value,
			title: 'New event',
			color: eventColours[0],
		},
	];
}

/**
 * Updates an event's background colour while keeping its other data intact.
 *
 * @param {string} eventId Event identifier to update.
 * @param {string} colour New CSS background colour.
 * @returns {void}
 */
function setEventColour(eventId, colour) {
	events.value = events.value.map((event) => event.id === eventId
		? { ...event, color: colour }
		: event);
}

/**
 * Builds readable foreground and background styles for a coloured event.
 *
 * @param {CalendarEvent} event Calendar event to style.
 * @returns {{ backgroundColor: string, color: string }} Inline event styles.
 */
function eventStyle(event) {
	return {
		backgroundColor: event.color,
		color: readableTextColour(event.color),
	};
}

/**
 * Chooses black or white text from a six-digit hexadecimal background colour.
 *
 * @param {string} colour Hexadecimal background colour.
 * @returns {string} Accessible high-contrast text colour.
 */
function readableTextColour(colour) {
	const hex = colour.replace('#', '');
	if (!/^[0-9a-f]{6}$/i.test(hex)) return '#ffffff';
	const red = Number.parseInt(hex.slice(0, 2), 16);
	const green = Number.parseInt(hex.slice(2, 4), 16);
	const blue = Number.parseInt(hex.slice(4, 6), 16);
	const luminance = ((red * 299) + (green * 587) + (blue * 114)) / 1000;
	return luminance > 150 ? '#111827' : '#ffffff';
}
</script>

<template>
	<div class="w-[96rem] max-w-full space-y-5" data-testid="customisable-days-calendar-example">
		<header class="rounded-2xl border border-border bg-canvas p-4 shadow-sm">
			<div class="flex flex-wrap items-start justify-between gap-4">
				<div class="max-w-2xl">
					<p class="text-xs font-semibold uppercase tracking-wide text-muted-fg">Personal calendar canvas</p>
					<h3 class="mt-1 text-2xl font-bold tracking-tight text-canvas-fg">Make each day your own</h3>
					<p class="mt-1 text-sm leading-6 text-muted-fg">Select a day to add an image or change event colours. Switch to Draw, then drag directly across any day.</p>
				</div>

				<div class="flex items-center gap-2 rounded-full border border-border bg-secondary/40 p-1" role="group" aria-label="Calendar editing tool">
					<DomButton
						size="sm"
						:variant="activeTool === 'select' ? 'primary' : 'ghost'"
						:aria-pressed="activeTool === 'select'"
						@click="setTool('select')"
					>
						Select days
					</DomButton>
					<DomButton
						size="sm"
						:variant="activeTool === 'draw' ? 'primary' : 'ghost'"
						:aria-pressed="activeTool === 'draw'"
						@click="setTool('draw')"
					>
						Draw on days
					</DomButton>
				</div>
			</div>
		</header>

		<div class="grid items-start gap-5 2xl:grid-cols-[minmax(0,1fr)_21rem]">
			<div class="min-w-0">
				<DomMonthCalendar
					:start-date="startDate"
					:months="1"
					:fixed-weeks="true"
					:show-adjacent-days="true"
					:hoverable-days="false"
					:day-class="dayClass"
					:day-style="dayStyle"
					:clickable="activeTool === 'select'"
					@day-click="selectDay"
				>
					<template #month-header>
						<span>{{ activeTool === 'draw' ? 'Drag to draw' : 'Select a day to edit' }}</span>
					</template>

					<template #default="{ cell }">
						<div v-if="cell.currentMonth" class="relative flex h-full min-h-[6.5rem] flex-col">
							<canvas
								:ref="canvasRefFor(cell.value)"
								:data-drawing-date="cell.value"
								class="absolute inset-0 h-full w-full"
								:class="activeTool === 'draw' ? 'z-20 cursor-crosshair touch-none pointer-events-auto' : 'z-0 pointer-events-none'"
								:role="activeTool === 'draw' ? 'application' : undefined"
								:aria-hidden="activeTool === 'draw' ? undefined : 'true'"
								:aria-label="activeTool === 'draw' ? `Drawing surface for ${cell.label}` : undefined"
								@pointerdown.stop.prevent="startDrawing(cell, $event)"
								@pointermove.stop.prevent="continueDrawing"
								@pointerup.stop.prevent="finishDrawing"
								@pointercancel.stop.prevent="finishDrawing"
								@click.stop.prevent
							></canvas>

							<div class="relative z-10 mt-auto space-y-1.5 pointer-events-none">
								<article
									v-for="event in eventsFor(cell.value)"
									:key="event.id"
									class="truncate rounded-md px-2 py-1.5 text-xs font-semibold shadow-sm"
									:style="eventStyle(event)"
								>
									{{ event.title }}
								</article>
								<span
									v-if="cell.value === activeDate"
									class="inline-flex w-fit rounded-full bg-canvas/90 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-canvas-fg shadow-sm"
								>
									Selected
								</span>
							</div>
						</div>
					</template>
				</DomMonthCalendar>
			</div>

			<aside class="overflow-hidden rounded-2xl border border-border bg-canvas shadow-sm">
				<header class="border-b border-border bg-secondary/30 px-4 py-3">
					<p class="text-xs font-semibold uppercase tracking-wide text-muted-fg">Editing</p>
					<h4 class="mt-1 text-base font-bold text-canvas-fg">{{ activeDateLabel }}</h4>
				</header>

				<section class="border-b border-border p-4">
					<div class="flex items-center justify-between gap-3">
						<div>
							<h5 class="text-sm font-bold text-canvas-fg">Background image</h5>
							<p class="mt-0.5 max-w-[15rem] truncate text-xs text-muted-fg">{{ activeCustomisation?.imageLabel || 'No image selected' }}</p>
						</div>
						<DomButton v-if="activeCustomisation?.image" size="xs" variant="ghost" @click="clearBackground">Remove</DomButton>
					</div>

					<div class="mt-3 grid grid-cols-3 gap-2">
						<button
							v-for="choice in backgroundChoices"
							:key="choice.label"
							type="button"
							class="group relative aspect-[4/3] overflow-hidden rounded-lg border border-border bg-secondary shadow-xs outline-none transition hover:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-ring/60"
							:class="activeCustomisation?.image === choice.image && 'ring-2 ring-primary'"
							:style="backgroundPreviewStyle(choice)"
							:aria-label="`Use ${choice.label} background`"
							:aria-pressed="activeCustomisation?.image === choice.image"
							@click="setBackground(choice)"
						>
							<span class="absolute inset-x-0 bottom-0 bg-canvas/85 px-1.5 py-1 text-[10px] font-bold text-canvas-fg backdrop-blur-sm">{{ choice.label }}</span>
						</button>
					</div>

					<input ref="imageInput" type="file" class="sr-only" accept="image/*" @change="uploadBackground">
					<DomButton class="mt-3 w-full" size="sm" variant="secondary" @click="chooseBackgroundFile">Upload your own image</DomButton>
					<p v-if="uploadError" class="mt-2 text-xs font-medium text-destructive" role="alert">{{ uploadError }}</p>
				</section>

				<section class="border-b border-border p-4">
					<div class="flex items-center justify-between gap-3">
						<div>
							<h5 class="text-sm font-bold text-canvas-fg">Freehand drawing</h5>
							<p class="mt-0.5 text-xs text-muted-fg">Choose a brush, then use Draw on days.</p>
						</div>
						<DomButton v-if="activeHasDrawing" size="xs" variant="ghost" @click="clearDrawing">Clear</DomButton>
					</div>

					<div class="mt-3 flex flex-wrap items-center gap-2">
						<button
							v-for="colour in brushColours"
							:key="colour"
							type="button"
							class="size-7 rounded-full border-2 border-canvas shadow-sm outline outline-1 outline-border transition hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring/60"
							:class="brushColor === colour && 'ring-2 ring-primary ring-offset-2 ring-offset-canvas'"
							:style="{ backgroundColor: colour }"
							:aria-label="`Use ${colour} brush`"
							:aria-pressed="brushColor === colour"
							@click="setBrushColour(colour)"
						></button>
						<label class="relative size-7 overflow-hidden rounded-full border-2 border-canvas bg-[conic-gradient(red,yellow,lime,aqua,blue,magenta,red)] shadow-sm outline outline-1 outline-border" aria-label="Choose custom brush colour">
							<input v-model="brushColor" type="color" class="absolute inset-0 size-10 cursor-pointer opacity-0">
						</label>
					</div>
				</section>

				<section class="p-4">
					<div class="flex items-center justify-between gap-3">
						<div>
							<h5 class="text-sm font-bold text-canvas-fg">Event colours</h5>
							<p class="mt-0.5 text-xs text-muted-fg">Each event keeps its own background.</p>
						</div>
						<DomButton size="xs" variant="secondary" @click="addEvent">Add event</DomButton>
					</div>

					<div v-if="activeEvents.length" class="mt-3 space-y-3">
						<div v-for="event in activeEvents" :key="event.id" class="rounded-xl border border-border p-3">
							<div class="rounded-md px-2 py-1.5 text-xs font-semibold" :style="eventStyle(event)">{{ event.title }}</div>
							<div class="mt-2 flex flex-wrap items-center gap-1.5">
								<button
									v-for="colour in eventColours"
									:key="colour"
									type="button"
									class="size-6 rounded-full border-2 border-canvas shadow-sm outline outline-1 outline-border transition hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring/60"
									:class="event.color === colour && 'ring-2 ring-primary ring-offset-1 ring-offset-canvas'"
									:style="{ backgroundColor: colour }"
									:aria-label="`Set ${event.title} to ${colour}`"
									:aria-pressed="event.color === colour"
									@click="setEventColour(event.id, colour)"
								></button>
								<label class="relative size-6 overflow-hidden rounded-full border-2 border-canvas bg-[conic-gradient(red,yellow,lime,aqua,blue,magenta,red)] shadow-sm outline outline-1 outline-border" :aria-label="`Choose custom colour for ${event.title}`">
									<input :value="event.color" type="color" class="absolute inset-0 size-9 cursor-pointer opacity-0" @input="setEventColour(event.id, $event.target.value)">
								</label>
							</div>
						</div>
					</div>
					<p v-else class="mt-3 rounded-lg bg-secondary/45 px-3 py-2 text-xs text-muted-fg">No events on this day yet.</p>
				</section>
			</aside>
		</div>
	</div>
</template>

Demo

Server-loaded navigation

Navigate month by month while a simulated server request reloads records, then drag one record to a new day without changing existing records on that day.

Server-loaded schedule

June 2026

/api/calendar-records?month=2026-06

0 records loadedWaiting for response
Week numbers

June 2026

0 records available
23
1Mon
No records
2Tue
No records
3Wed
No records
4Thu
No records
5Fri
No records
6Sat
No records
7Sun
No records
24
8Mon
No records
9Tue
No records
10Wed
No records
11Thu
No records
12Fri
No records
13Sat
No records
14Sun
No records
25
15Mon
No records
16Tue
No records
17Wed
No records
18Thu
No records
19Fri
No records
20Sat
No records
21Sun
No records
26
22Mon
No records
23Tue
No records
24Wed
No records
25Thu
No records
26Fri
No records
27Sat
No records
28Sun
No records
27
29Mon
No records
30Tue
No records
1Wed
2Thu
3Fri
4Sat
5Sun
28
6Mon
7Tue
8Wed
9Thu
10Fri
11Sat
12Sun
vue
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { DomIconButton, DomMonthCalendar, DomStatusPill, DomToggle } from '../../../lib/vue';

const cursor = ref(new Date(2026, 5, 1));
const recordsByDate = ref({});
const loading = ref(false);
const weekNumbers = ref(true);
const activeRequestId = ref(0);
const loadedAt = ref('');
const requestLog = ref([]);
let mounted = false;
let logId = 0;

const monthFormatter = new Intl.DateTimeFormat('en-GB', {
	month: 'long',
	year: 'numeric',
});
const timeFormatter = new Intl.DateTimeFormat('en-GB', {
	hour: '2-digit',
	minute: '2-digit',
	second: '2-digit',
});

const visibleMonth = computed(() => monthValue(cursor.value));
const calendarStartDate = computed(() => `${visibleMonth.value}-01`);
const monthLabel = computed(() => monthFormatter.format(cursor.value));
const recordCount = computed(() => Object.values(recordsByDate.value).reduce((total, records) => total + records.length, 0));
const statusTone = computed(() => loading.value ? 'info' : 'success');
const statusLabel = computed(() => loading.value ? `Loading ${visibleMonth.value}` : `${recordCount.value} records loaded`);

onMounted(() => {
	mounted = true;
	loadMonth(visibleMonth.value);
});

onBeforeUnmount(() => {
	mounted = false;
	activeRequestId.value += 1;
});

watch(visibleMonth, (value) => {
	loadMonth(value);
});

function navigateMonth(delta) {
	cursor.value = new Date(cursor.value.getFullYear(), cursor.value.getMonth() + delta, 1);
}

async function loadMonth(month) {
	const requestId = activeRequestId.value + 1;
	activeRequestId.value = requestId;
	loading.value = true;
	loadedAt.value = '';
	recordsByDate.value = {};
	addRequestLog(`GET /api/calendar-records?month=${month}`, 'info');

	const records = await emulateServerFetch(month);
	if (!mounted || requestId !== activeRequestId.value) {
		if (mounted) addRequestLog(`Ignored stale response for ${month}`, 'warning');
		return;
	}

	recordsByDate.value = groupRecords(records);
	loadedAt.value = timeFormatter.format(new Date());
	loading.value = false;
	addRequestLog(`Loaded ${records.length} records for ${month}`, 'success');
}

function emulateServerFetch(month) {
	const delay = 460 + ((month.charCodeAt(5) + month.charCodeAt(6)) % 5) * 170;
	return new Promise((resolve) => {
		window.setTimeout(() => resolve(buildServerRecords(month)), delay);
	});
}

function buildServerRecords(month) {
	const [year, monthNumber] = month.split('-').map(Number);
	const daysInMonth = new Date(year, monthNumber, 0).getDate();
	const titles = ['Install window', 'Support cover', 'Renewal review', 'QA handoff', 'Partner launch'];
	const teams = ['Field ops', 'Success', 'Revenue', 'Platform', 'Marketing'];
	const tones = ['primary', 'success', 'warning', 'info', 'neutral'];
	const records = [];

	for (let day = 1; day <= daysInMonth; day += 1) {
		const seed = (year + monthNumber * 19 + day * 7) % 23;
		const date = `${year}-${pad(monthNumber)}-${pad(day)}`;
		if ([2, 6, 9, 14, 18].includes(seed)) {
			records.push(createRecord(date, day, seed, titles, teams, tones));
		}
		if ([4, 17].includes(seed)) {
			records.push(createRecord(date, day + 3, seed + 5, titles, teams, tones));
		}
	}

	return records;
}

function createRecord(date, day, seed, titles, teams, tones) {
	const index = Math.abs(seed + day) % titles.length;
	return {
		id: `${date}-${seed}-${day}`,
		date,
		title: titles[index],
		team: teams[(index + seed) % teams.length],
		tone: tones[(index + day) % tones.length],
		time: `${pad(8 + (seed % 8))}:00`,
	};
}

function groupRecords(records) {
	return records.reduce((groups, record) => {
		groups[record.date] = [...(groups[record.date] || []), record];
		return groups;
	}, {});
}

function recordsFor(value) {
	return recordsByDate.value[value] || [];
}

function disabledDate() {
	return loading.value;
}

function dayClass(cell) {
	if (!cell.currentMonth) return '';
	if (loading.value) return 'bg-secondary/25';
	if (recordsFor(cell.value).length) return 'bg-primary/5';
	return '';
}

function dragDataFor(record) {
	return {
		id: record.id,
		title: record.title,
		sourceDate: record.date,
	};
}

function moveRecord({ data, targetValue }) {
	if (loading.value || !data?.id || !targetValue) return;
	const sourceDate = data.sourceDate || recordDate(data.id);
	if (!sourceDate || sourceDate === targetValue) return;
	const record = recordsByDate.value[sourceDate]?.find((item) => item.id === data.id);
	if (!record) return;

	const next = { ...recordsByDate.value };
	next[sourceDate] = (next[sourceDate] || []).filter((item) => item.id !== record.id);
	if (!next[sourceDate].length) delete next[sourceDate];
	next[targetValue] = [
		...(next[targetValue] || []),
		{
			...record,
			date: targetValue,
		},
	].sort((a, b) => a.time.localeCompare(b.time));

	recordsByDate.value = next;
	loadedAt.value = timeFormatter.format(new Date());
	addRequestLog(`PATCH /api/calendar-records/${record.id} date=${targetValue}`, 'info');
}

function recordDate(id) {
	for (const [date, records] of Object.entries(recordsByDate.value)) {
		if (records.some((record) => record.id === id)) return date;
	}
	return '';
}

function toneClass(record) {
	return {
		primary: 'border-primary/20 bg-primary/10 text-primary',
		success: 'border-success/20 bg-success/10 text-success',
		warning: 'border-warning/30 bg-warning/15 text-warning-fg',
		info: 'border-primary/20 bg-primary/10 text-primary',
		neutral: 'border-border bg-secondary/45 text-canvas-fg',
	}[record.tone] || 'border-border bg-secondary/45 text-canvas-fg';
}

function logToneClass(tone) {
	return {
		info: 'bg-primary',
		success: 'bg-success',
		warning: 'bg-warning',
	}[tone] || 'bg-muted-fg';
}

function addRequestLog(message, tone) {
	requestLog.value = [
		{ id: ++logId, message, tone },
		...requestLog.value,
	].slice(0, 4);
}

function monthValue(date) {
	return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
}

function pad(value) {
	return String(value).padStart(2, '0');
}
</script>

<template>
	<div class="w-[78rem] max-w-full space-y-4" data-testid="server-loaded-navigation-example">
		<section class="rounded-2xl border border-border bg-canvas p-4 shadow-sm">
			<div class="flex flex-wrap items-start justify-between gap-4">
				<div class="min-w-0">
					<p class="text-xs font-semibold uppercase tracking-wide text-muted-fg">Server-loaded schedule</p>
					<h3 class="mt-1 text-2xl font-bold tracking-tight text-canvas-fg">{{ monthLabel }}</h3>
					<p class="mt-1 text-sm text-muted-fg">/api/calendar-records?month={{ visibleMonth }}</p>
				</div>

				<div class="flex items-center gap-2">
					<DomIconButton
						label="Previous month"
						icon="M15 18l-6-6 6-6"
						variant="secondary"
						@click="navigateMonth(-1)"
					/>
					<DomIconButton
						label="Next month"
						icon="M9 18l6-6-6-6"
						variant="secondary"
						@click="navigateMonth(1)"
					/>
				</div>
			</div>

			<div class="mt-4 grid gap-4 lg:grid-cols-[1fr_auto] lg:items-center">
				<div class="flex flex-wrap items-center gap-3">
					<DomStatusPill
						:tone="statusTone"
						:label="statusLabel"
						:pulse="loading"
						size="sm"
					/>
					<span class="text-sm text-muted-fg">
						<span v-if="loadedAt">Updated {{ loadedAt }}</span>
						<span v-else>Waiting for response</span>
					</span>
				</div>

				<DomToggle v-model="weekNumbers" label="Week numbers" />
			</div>

			<ul class="mt-4 grid gap-2 text-xs text-muted-fg" aria-live="polite">
				<li
					v-for="entry in requestLog"
					:key="entry.id"
					class="flex min-w-0 items-center gap-2 rounded-lg bg-secondary/35 px-3 py-2"
				>
					<span class="size-1.5 shrink-0 rounded-full" :class="logToneClass(entry.tone)" aria-hidden="true"></span>
					<span class="truncate">{{ entry.message }}</span>
				</li>
			</ul>
		</section>

		<DomMonthCalendar
			:start-date="calendarStartDate"
			:months="1"
			:fixed-weeks="true"
			:show-week-numbers="weekNumbers"
			:day-class="dayClass"
			:disabled-date="disabledDate"
			drag-and-drop
			@day-drop="moveRecord"
		>
			<template #month-header>
				<span>{{ loading ? 'Fetching records' : `${recordCount} records available` }}</span>
			</template>

			<template #default="{ cell, drag, dragAttrs }">
				<div v-if="cell.currentMonth" class="min-h-[6.25rem]">
					<div v-if="loading" class="space-y-2" aria-hidden="true">
						<div class="h-3 w-11/12 animate-pulse rounded-full bg-secondary"></div>
						<div class="h-3 w-7/12 animate-pulse rounded-full bg-secondary"></div>
						<div class="h-8 w-full animate-pulse rounded-lg bg-secondary/70"></div>
					</div>

					<div v-else-if="recordsFor(cell.value).length" class="space-y-2">
						<article
							v-for="record in recordsFor(cell.value)"
							:key="record.id"
							v-bind="dragAttrs(dragDataFor(record))"
							class="cursor-grab rounded-lg border px-2 py-1.5 text-xs font-semibold leading-4 transition active:cursor-grabbing"
							:class="[
								toneClass(record),
								drag.source && drag.data?.id === record.id && 'opacity-60 ring-2 ring-ring/40',
							]"
						>
							<div class="flex min-w-0 items-center justify-between gap-2">
								<span class="min-w-0 truncate">{{ record.title }}</span>
								<span class="shrink-0 opacity-75">{{ record.time }}</span>
							</div>
							<div class="mt-1 truncate text-[11px] font-medium opacity-75">{{ record.team }}</div>
						</article>
					</div>

					<div v-else class="text-xs text-muted-fg">No records</div>
				</div>
			</template>
		</DomMonthCalendar>
	</div>
</template>

Demo

Content planner

Render article planning cards through the default day slot, then use day-drop payloads to move or swap planned content between days.

June 2026

Articles are generated and published at 7AM-9AM UTC.
8Mon
9Tue
10Wed
11Thu
Guide: Explainer

controlled components

Vol: 170Diff: 14
Visit Article
12Fri
Guide: How-to

tailwind css themes

Vol: 390Diff: 24
View Article
13Sat
List: Examples

examples of a sidebar

Vol: 590Diff: 22
14Sun
List: Resources

tailwind css components

Vol: 390Diff: 45
15Mon
Guide: How-to

component testing

Vol: 480Diff: 26
16Tue
Guide: Explainer

border color in css

Vol: 590Diff: 16
17Wed
Guide: How-to

switch button css

Vol: 590Diff: 10
18Thu
Guide: Explainer

code splitting

Vol: 210Diff: 30
19Fri
Guide: Explainer

component composition

Vol: 50Diff: 18
20Sat
Guide: How-to

react loading bar

Vol: 590Diff: 17
21Sun
Guide: Explainer

vue component library

Vol: 320Diff: 30
22Mon
Guide: How-to

accessibility audit

Vol: 150Diff: 26
23Tue
Guide: How-to

js progress bar

Vol: 590Diff: 29
24Wed
Guide: Explainer

performance optimization

Vol: 320Diff: 33
25Thu
Guide: Explainer

css variables

Vol: 2,900Diff: 0
26Fri
List: Examples

component examples

Vol: 390Diff: 39
27Sat
Guide: How-to

screen reader testing

Vol: 260Diff: 18
28Sun
Guide: Explainer

high contrast mode

Vol: 920Diff: 24
29Mon
Guide: How-to

accessible dropdown menu

Vol: 70Diff: 15
30Tue
List: Resources

design system tools

Vol: 210Diff: 24
1Wed
2Thu
3Fri
4Sat
5Sun

July 2026

Articles are generated and published at 7AM-9AM UTC.
29Mon
30Tue
1Wed
Guide: Explainer

menu component

Vol: 70Diff: 38
2Thu
List: Resources

accessibility testing tools

Vol: 50Diff: 40
3Fri
Guide: Explainer

color contrast accessibility

Vol: 390Diff: 52
4Sat
Guide: Explainer

command palette

Vol: 760Diff: 24
5Sun
Guide: Explainer

drawer component

Vol: 140Diff: 30
6Mon
7Tue
8Wed
9Thu
10Fri
11Sat
12Sun
13Mon
14Tue
15Wed
16Thu
17Fri
18Sat
19Sun
20Mon
21Tue
22Wed
23Thu
24Fri
25Sat
26Sun
27Mon
28Tue
29Wed
30Thu
31Fri
1Sat
2Sun
vue
<script setup>
import { computed, ref } from 'vue';
import { DomMonthCalendar } from '../../../lib/vue';

const startDate = '2026-06-11';
const initialArticles = [
	{ date: '2026-06-11', type: 'Guide: Explainer', title: 'controlled components', volume: '170', difficulty: 14, cta: 'Visit Article' },
	{ date: '2026-06-12', type: 'Guide: How-to', title: 'tailwind css themes', volume: '390', difficulty: 24, cta: 'View Article' },
	{ date: '2026-06-13', type: 'List: Examples', title: 'examples of a sidebar', volume: '590', difficulty: 22 },
	{ date: '2026-06-14', type: 'List: Resources', title: 'tailwind css components', volume: '390', difficulty: 45 },
	{ date: '2026-06-15', type: 'Guide: How-to', title: 'component testing', volume: '480', difficulty: 26 },
	{ date: '2026-06-16', type: 'Guide: Explainer', title: 'border color in css', volume: '590', difficulty: 16 },
	{ date: '2026-06-17', type: 'Guide: How-to', title: 'switch button css', volume: '590', difficulty: 10 },
	{ date: '2026-06-18', type: 'Guide: Explainer', title: 'code splitting', volume: '210', difficulty: 30 },
	{ date: '2026-06-19', type: 'Guide: Explainer', title: 'component composition', volume: '50', difficulty: 18 },
	{ date: '2026-06-20', type: 'Guide: How-to', title: 'react loading bar', volume: '590', difficulty: 17 },
	{ date: '2026-06-21', type: 'Guide: Explainer', title: 'vue component library', volume: '320', difficulty: 30 },
	{ date: '2026-06-22', type: 'Guide: How-to', title: 'accessibility audit', volume: '150', difficulty: 26 },
	{ date: '2026-06-23', type: 'Guide: How-to', title: 'js progress bar', volume: '590', difficulty: 29 },
	{ date: '2026-06-24', type: 'Guide: Explainer', title: 'performance optimization', volume: '320', difficulty: 33 },
	{ date: '2026-06-25', type: 'Guide: Explainer', title: 'css variables', volume: '2,900', difficulty: 0 },
	{ date: '2026-06-26', type: 'List: Examples', title: 'component examples', volume: '390', difficulty: 39 },
	{ date: '2026-06-27', type: 'Guide: How-to', title: 'screen reader testing', volume: '260', difficulty: 18 },
	{ date: '2026-06-28', type: 'Guide: Explainer', title: 'high contrast mode', volume: '920', difficulty: 24 },
	{ date: '2026-06-29', type: 'Guide: How-to', title: 'accessible dropdown menu', volume: '70', difficulty: 15 },
	{ date: '2026-06-30', type: 'List: Resources', title: 'design system tools', volume: '210', difficulty: 24 },
	{ date: '2026-07-01', type: 'Guide: Explainer', title: 'menu component', volume: '70', difficulty: 38 },
	{ date: '2026-07-02', type: 'List: Resources', title: 'accessibility testing tools', volume: '50', difficulty: 40 },
	{ date: '2026-07-03', type: 'Guide: Explainer', title: 'color contrast accessibility', volume: '390', difficulty: 52 },
	{ date: '2026-07-04', type: 'Guide: Explainer', title: 'command palette', volume: '760', difficulty: 24 },
	{ date: '2026-07-05', type: 'Guide: Explainer', title: 'drawer component', volume: '140', difficulty: 30 },
];

const articles = ref(initialArticles.map((article, index) => ({
	id: `article-${index + 1}`,
	...article,
})));
const articleByDate = computed(() => Object.fromEntries(articles.value.map((article) => [article.date, article])));

function articleFor(value) {
	return articleByDate.value[value];
}

function dragDataFor(article) {
	return {
		id: article.id,
		title: article.title,
		sourceDate: article.date,
	};
}

function moveArticle({ data, sourceValue, targetValue }) {
	if (!data?.id || !targetValue || sourceValue === targetValue) return;
	const sourceArticle = articles.value.find((article) => article.id === data.id);
	if (!sourceArticle) return;
	const sourceDate = sourceValue || sourceArticle.date;
	const targetArticle = articleFor(targetValue);

	articles.value = articles.value.map((article) => {
		if (article.id === sourceArticle.id) {
			return {
				...article,
				date: targetValue,
			};
		}
		if (targetArticle && article.id === targetArticle.id) {
			return {
				...article,
				date: sourceDate,
			};
		}
		return article;
	});
}

function badgeClass(article) {
	return article.type.startsWith('List:')
		? 'border-amber-300 bg-amber-50 text-amber-800'
		: 'border-sky-300 bg-sky-50 text-sky-800';
}
</script>

<template>
	<div class="w-[78rem] max-w-full" data-testid="content-planner-example">
		<DomMonthCalendar
			:start-date="startDate"
			:months="2"
			:muted-before="startDate"
			drag-and-drop
			@day-drop="moveArticle"
		>
			<template #month-header>
				<span>Articles are generated and published at 7AM-9AM UTC.</span>
			</template>

			<template #default="{ cell, drag, dragAttrs }">
				<article
					v-for="article in cell.currentMonth && articleFor(cell.value) ? [articleFor(cell.value)] : []"
					:key="article.id"
					v-bind="dragAttrs(dragDataFor(article))"
					class="flex min-h-[7.4rem] cursor-grab flex-col rounded-lg border border-violet-200 bg-violet-50/35 p-3 text-sm shadow-xs transition active:cursor-grabbing"
					:class="drag.source && drag.data?.id === article.id && 'opacity-60 ring-2 ring-ring/40'"
				>
					<div
						class="mb-2 w-fit rounded-full border px-1.5 py-0.5 text-[11px] font-bold leading-none"
						:class="badgeClass(article)"
					>
						{{ article.type }}
					</div>

					<h3 class="text-sm font-bold leading-5 text-canvas-fg">
						{{ article.title }}
					</h3>

					<div class="mt-auto flex items-center justify-between border-t border-violet-100 pt-2 text-xs text-muted-fg">
						<span>Vol: <strong class="text-canvas-fg">{{ article.volume }}</strong></span>
						<span>Diff: <strong class="text-canvas-fg">{{ article.difficulty }}</strong></span>
					</div>

					<a
						v-if="article.cta"
						href="#"
						class="mt-2 inline-flex h-8 items-center justify-center rounded-md bg-primary px-3 text-xs font-bold text-primary-fg transition hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
						@click.prevent
					>
						{{ article.cta }}
					</a>
				</article>
			</template>
		</DomMonthCalendar>
	</div>
</template>

Architecture

Server calendar API

The month calendar is transport-free: it renders date cells and exposes slots. The application owns fetching, loading, empty, and mutation states, then looks up records for each cell.value. For backend work, make the endpoint range-based so one contract supports single-month, multi-month, fixed-week, and adjacent-day displays.

Set continuous when the calendar should read as one uninterrupted date stream. That mode removes repeated previous/next-month filler days between months and exposes month labels through the week rail, so the server should fetch the exact visible date range rather than assuming each visual row belongs to one month block.

Use date-only strings for all-day placement, and include local times only when the record needs them. That keeps API authors out of timezone edge cases while still allowing the UI to render appointments, availability, content plans, rota assignments, or booking inventory.

For drag and drop, keep the mutation policy in the app layer. The day slot exposes dragAttrs(data) for draggable records, and day-drop emits the source day, target day, transfer data, and native event so a planner can swap records while a server-backed schedule can update only the dragged record.

Param
Use
Notes
start
Required
Inclusive visible-range start as YYYY-MM-DD. Send the first rendered cell, not only the first day of the month, when adjacent days matter.
end
Required
Inclusive visible-range end as YYYY-MM-DD. The server can clamp maximum span, commonly 93 days for a three-month calendar.
timezone
Recommended
IANA timezone used to interpret all-day records and local times. Keep dates as date strings to avoid UTC midnight drift.
resource
Optional
Calendar owner, room, team, user, project, or schedule key. Use repeated params or comma-separated values for multi-resource views.
cursor
Optional
Only needed when a day can contain more records than the first response should render.

Read endpoint

js
GET /api/calendar-records?start=2026-06-01&end=2026-06-30&timezone=Europe/London&resource=field-ops

// Response
{
	range: {
		start: '2026-06-01',
		end: '2026-06-30',
		timezone: 'Europe/London',
		resource: 'field-ops',
	},
	data: [
		{
			id: 'evt_1042',
			date: '2026-06-11',
			title: 'Install window',
			startTime: '09:00',
			endTime: '11:00',
			status: 'confirmed',
			tone: 'primary',
			resourceId: 'field-ops',
			meta: {
				customerId: 'cus_8821',
				location: 'Dock 4',
			},
		},
	],
	byDate: {
		'2026-06-11': ['evt_1042'],
	},
	meta: {
		requestedAt: '2026-06-18T09:30:00.000Z',
		etag: 'calendar-records:field-ops:2026-06',
		total: 1,
	},
}

Mutation endpoint

js
POST /api/calendar-records

// Create or update an item
{
	id: 'evt_1042',
	date: '2026-06-12',
	title: 'Install window',
	startTime: '10:00',
	endTime: '12:00',
	status: 'confirmed',
	resourceId: 'field-ops',
	clientMutationId: 'calendar-edit-8f83',
}

// Response
{
	data: {
		id: 'evt_1042',
		date: '2026-06-12',
		title: 'Install window',
		startTime: '10:00',
		endTime: '12:00',
		status: 'confirmed',
		resourceId: 'field-ops',
		updatedAt: '2026-06-18T09:31:00.000Z',
	},
	meta: {
		clientMutationId: 'calendar-edit-8f83',
		invalidatedRange: {
			start: '2026-06-01',
			end: '2026-06-30',
		},
	},
}

Now route sketch

js
// server/api/calendar-records.get.js
export async function get(req, res) {
	const range = normalizeCalendarRange(req.query);
	if (!range) {
		res.status(400);
		return { error: 'Use YYYY-MM-DD start and end query parameters.' };
	}

	const records = await listCalendarRecords({
		start: range.start,
		end: range.end,
		timezone: range.timezone,
		resource: req.query.resource || '',
	});

	return {
		range,
		data: records,
		byDate: groupRecordIdsByDate(records),
		meta: {
			requestedAt: new Date().toISOString(),
			total: records.length,
		},
	};
}

Reference

Props

Control props

NameTypeTSDefaultDescription
localestringstring'en-GB'Locale used for month, weekday, and date labels.
weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 6number1First day of week. 0 is Sunday, 1 is Monday.
weekdayFormat'narrow' | 'short' | 'long'string'short'Weekday label length.
showAdjacentDaysbooleanbooleantrueShow days from the previous and next months so the grid keeps its weekday alignment.
fixedWeeksbooleanbooleanfalseRender six weeks per month even when the month needs fewer rows.
continuousbooleanbooleanfalseRender one continuous week grid across the range instead of separated month blocks with repeated adjacent days.
containedContinuousScrollbooleanbooleantrueConstrain continuous mode to its own scrollport. Disable when a parent layout already owns the vertical scroll.
hoverableDaysbooleanbooleantrueApply the default hover background to clickable day cells.
showWeekNumbersbooleanbooleanfalseRender ISO-8601 week numbers at the start of each week row.
mutedBeforestringstring''Dates before this YYYY-MM-DD value render muted.
mutedAfterstringstring''Dates after this YYYY-MM-DD value render muted.
dayHeadersbooleanbooleantrueRender weekday headers.
dayClassstring | array | object | functionstring | Array<unknown> | Record<string, boolean> | ((cell: MonthCalendarCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
) => unknown)
''Extra classes, or a function that receives a day cell and returns classes for the day cell.
dayStylestring | object | functionstring | Record<string, string | number> | ((cell: MonthCalendarCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
) => string | Record<string, string | number> | null | undefined)
''Extra styles, or a function that receives a day cell and returns styles for the day cell.

Interaction

NameTypeTSDefaultDescription
minstringstring''Minimum clickable date as YYYY-MM-DD.
maxstringstring''Maximum clickable date as YYYY-MM-DD.
clickablebooleanbooleanfalseRender days as keyboard-reachable buttons and emit day-click.
clickableAdjacentDaysbooleanbooleanfalseAllow day-click on visible days outside the displayed month.
dragAndDropbooleanbooleanfalseExpose dragAttrs in the day slot and emit day-drop when a draggable item is dropped onto a day.
dropEffect'copy' | 'move' | 'link'string'move'Native drop effect shown when a day can receive a dragged item.
disabledDatefunction(cell: MonthCalendarCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
) => boolean
Function that receives a day cell and returns true when the day should not be clickable or droppable.

Range

NameTypeTSDefaultDescription
startDatestringstring''First date in the rendered range as YYYY-MM-DD. The first month starts on the week containing this date.
initialMonthnumbernumberInitial month, 1-12. Used when startDate is not set.
initialYearnumbernumberInitial year. Used when startDate is not set.
monthsnumbernumber3Number of consecutive months to render.

Styling

NameTypeTSDefaultDescription
mutedDayClassstring | array | object | functionstring | Array<unknown> | Record<string, boolean> | ((cell: MonthCalendarCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
) => unknown)
''Classes for visible muted days. Replaces the default muted day classes.
adjacentDayClassstring | array | object | functionstring | Array<unknown> | Record<string, boolean> | ((cell: MonthCalendarCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
) => unknown)
''Classes for visible previous and next month days. Overrides mutedDayClass for adjacent days.

Auto-generated from Month calendar.props and inline _edit hints.

Events

NamePayloadDescription
@day-click({ cell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, event
event: MouseEvent;
})
Fired when a clickable day is activated.
@day-drag-start({ cell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, sourceCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, sourceValue
sourceValue: string;
, data
data: unknown;
, event
event: DragEvent;
})
Fired when an item using dragAttrs starts dragging from a day.
@day-drag-enter({ cell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, sourceCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, targetCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, sourceValue
sourceValue: string;
, targetValue
targetValue: string;
, data
data: unknown;
, event
event: DragEvent;
})
Fired when a dragged item previews a day as the drop target.
@day-drag-leave({ cell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, sourceCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, targetCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, sourceValue
sourceValue: string;
, targetValue
targetValue: string;
, data
data: unknown;
, event
event: DragEvent;
})
Fired when a dragged item leaves a previewed day.
@day-drop({ cell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, sourceCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, targetCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, sourceValue
sourceValue: string;
, targetValue
targetValue: string;
, data
data: unknown;
, event
event: DragEvent;
})
Fired when a draggable item is dropped onto a day. The calendar does not mutate records.
@day-drag-end({ cell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, sourceCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, targetCell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, sourceValue
sourceValue: string;
, targetValue
targetValue: string;
, data
data: unknown;
, event
event: DragEvent;
})
Fired when a drag started with dragAttrs ends.

Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.

Slots

NameScopeDescription
#(default){ cell
type MonthCalendarCell = {
	date: Date; // Local Date instance for the cell.
	value: string; // YYYY-MM-DD date key.
	day: number; // Day of the month.
	weekday: string; // Formatted weekday label.
	label: string; // Full accessible date label.
	currentMonth: boolean; // True when the date belongs to the rendered month.
	previousMonth: boolean; // True when the date belongs to the previous month relative to the rendered month.
	nextMonth: boolean; // True when the date belongs to the next month relative to the rendered month.
	adjacentMonth: boolean; // True when the date belongs to either adjacent month outside the rendered month.
	hidden: boolean; // True for adjacent dates when showAdjacentDays is false.
	monthValue: string; // YYYY-MM month key for the rendered month.
	row: number; // Zero-based week row in the grid.
	column: number; // Zero-based weekday column in the grid.
	today: boolean;
	past: boolean;
	future: boolean;
	muted: boolean;
	disabled: boolean;
};
, month
type MonthCalendarMonth = {
	date: Date; // Local Date instance for the first day of the rendered month.
	value: string; // YYYY-MM month key.
	id: string; // Element id used to label the month grid.
	month: number; // Month number, 1-12.
	year: number; // Four-digit year.
	label: string; // Formatted month heading.
	cells: Array<MonthCalendarCell
>; // Flat list of visible day cells. weeks: Array<MonthCalendarWeek
>; // Day cells grouped into week rows. };
, drag
type MonthCalendarDragState = {
	active: boolean; // True while a calendar drag is in progress.
	source: boolean; // True for the day where the drag started.
	over: boolean; // True for the day currently previewed as the drop target.
	sourceCell: MonthCalendarCell
| null; // Cell where the drag started, when known. overCell: MonthCalendarCell
| null; // Cell currently previewed as the drop target, when known. data: unknown; // App-owned data passed to dragAttrs. };
, dragAttrs
type MonthCalendarDragAttrs = (data?: unknown, options?: MonthCalendarDragAttrsOptions
) => Record<string, unknown>;
}
Custom content inside each visible calendar day. Use dragAttrs(data) on items that should be draggable.
#month-header{ month }Custom content rendered opposite separated month titles or above a continuous range.

Keyboard

  • TabMove through clickable days when clickable is enabled.
  • Enter / SpaceActivate the focused day.