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
July 2026
<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<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
July 2026
August 2026
<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
<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
June 2026
<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
controlled components
tailwind css themes
examples of a sidebar
tailwind css components
component testing
border color in css
switch button css
code splitting
component composition
react loading bar
vue component library
accessibility audit
js progress bar
performance optimization
css variables
component examples
screen reader testing
high contrast mode
accessible dropdown menu
design system tools
July 2026
menu component
accessibility testing tools
color contrast accessibility
command palette
drawer component
<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.
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
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
locale | string | string | 'en-GB' | Locale used for month, weekday, and date labels. |
weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | number | 1 | First day of week. 0 is Sunday, 1 is Monday. |
weekdayFormat | 'narrow' | 'short' | 'long' | string | 'short' | Weekday label length. |
showAdjacentDays | boolean | boolean | true | Show days from the previous and next months so the grid keeps its weekday alignment. |
fixedWeeks | boolean | boolean | false | Render six weeks per month even when the month needs fewer rows. |
continuous | boolean | boolean | false | Render one continuous week grid across the range instead of separated month blocks with repeated adjacent days. |
containedContinuousScroll | boolean | boolean | true | Constrain continuous mode to its own scrollport. Disable when a parent layout already owns the vertical scroll. |
hoverableDays | boolean | boolean | true | Apply the default hover background to clickable day cells. |
showWeekNumbers | boolean | boolean | false | Render ISO-8601 week numbers at the start of each week row. |
mutedBefore | string | string | '' | Dates before this YYYY-MM-DD value render muted. |
mutedAfter | string | string | '' | Dates after this YYYY-MM-DD value render muted. |
dayHeaders | boolean | boolean | true | Render weekday headers. |
dayClass | string | array | object | function | string | Array<unknown> | Record<string, boolean> | ((cell: | '' | Extra classes, or a function that receives a day cell and returns classes for the day cell. |
dayStyle | string | object | function | string | Record<string, string | number> | ((cell: | '' | Extra styles, or a function that receives a day cell and returns styles for the day cell. |
Interaction
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
min | string | string | '' | Minimum clickable date as YYYY-MM-DD. |
max | string | string | '' | Maximum clickable date as YYYY-MM-DD. |
clickable | boolean | boolean | false | Render days as keyboard-reachable buttons and emit day-click. |
clickableAdjacentDays | boolean | boolean | false | Allow day-click on visible days outside the displayed month. |
dragAndDrop | boolean | boolean | false | Expose 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. |
disabledDate | function | (cell: | — | Function that receives a day cell and returns true when the day should not be clickable or droppable. |
Range
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
startDate | string | string | '' | First date in the rendered range as YYYY-MM-DD. The first month starts on the week containing this date. |
initialMonth | number | number | — | Initial month, 1-12. Used when startDate is not set. |
initialYear | number | number | — | Initial year. Used when startDate is not set. |
months | number | number | 3 | Number of consecutive months to render. |
Styling
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
mutedDayClass | string | array | object | function | string | Array<unknown> | Record<string, boolean> | ((cell: | '' | Classes for visible muted days. Replaces the default muted day classes. |
adjacentDayClass | string | array | object | function | string | Array<unknown> | Record<string, boolean> | ((cell: | '' | Classes for visible previous and next month days. Overrides mutedDayClass for adjacent days. |
Auto-generated from Month calendar.props and inline _edit hints.
Events
| Name | Payload | Description |
|---|---|---|
| @day-click | ({ | Fired when a clickable day is activated. |
| @day-drag-start | ({ | Fired when an item using dragAttrs starts dragging from a day. |
| @day-drag-enter | ({ | Fired when a dragged item previews a day as the drop target. |
| @day-drag-leave | ({ | Fired when a dragged item leaves a previewed day. |
| @day-drop | ({ | Fired when a draggable item is dropped onto a day. The calendar does not mutate records. |
| @day-drag-end | ({ | 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
| Name | Scope | Description |
|---|---|---|
| #(default) | { | 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.