Component

Month calendar

<DomMonthCalendar>

A large scrolling month calendar that keeps weekday grids aligned and lets each day render custom UI.

Playground

Try every prop live

Month calendar playground

Edit the range and display 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
<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,
	  "showWeekNumbers": true,
	  "mutedBefore": "2026-06-11",
	  "mutedAfter": "",
	  "min": "",
	  "max": "",
	  "clickable": false,
	  "clickableAdjacentDays": false,
	  "dragAndDrop": false,
	  "dropEffect": "move",
	  "disabledDate": null,
	  "dayHeaders": true,
	  "dayClass": "",
	  "dayStyle": ""
	});
</script>

<template>
	<DomMonthCalendar
		v-bind="data"
	/>
</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
<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

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
<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
<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.

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

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

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

// 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.
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.
	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.
	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.
	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.

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.
	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.
	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.
	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.
	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.
	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.
	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.
	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.
	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.
	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.
	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.
	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.
	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.
	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.
	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.
	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.
	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 the month title.

Keyboard

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