Calendar
A month-view calendar for date selection. Features navigation, today highlighting, and day selection. Built with vanilla JS rendering into a <table> grid.
Basic Calendar
Interactive calendar with month navigation and date selection.
<div class="calendar">
<div class="calendar-header">
<button class="calendar-nav" data-action="prev-month"
aria-label="Previous month">
<svg>...chevron left...</svg>
</button>
<span class="calendar-heading" aria-live="polite"></span>
<button class="calendar-nav" data-action="next-month"
aria-label="Next month">
<svg>...chevron right...</svg>
</button>
</div>
<table class="calendar-grid" role="grid"></table>
</div>
Side by Side
Place multiple calendars side-by-side for range selection patterns.
CSS view file
Styles for the calendar component. Uses design tokens for colors, spacing, and radius.
@layer components {
.calendar {
display: inline-flex;
flex-direction: column;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background-color: var(--card);
user-select: none;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.25rem 0.5rem;
}
.calendar-heading {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
}
.calendar-nav {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background-color: transparent;
color: var(--foreground);
cursor: pointer;
transition: background-color 150ms ease;
&:hover {
background-color: var(--accent);
}
&:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
& svg {
width: 0.875rem;
height: 0.875rem;
}
}
.calendar-grid {
border-collapse: collapse;
border-spacing: 0;
}
.calendar-day-label {
width: 2.25rem;
height: 2.25rem;
font-size: 0.75rem;
font-weight: 400;
color: var(--muted-foreground);
text-align: center;
vertical-align: middle;
}
.calendar-day {
width: 2.25rem;
height: 2.25rem;
text-align: center;
vertical-align: middle;
padding: 0;
& button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
font-size: 0.8125rem;
border: none;
border-radius: var(--radius-md);
background-color: transparent;
color: var(--foreground);
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
&:hover {
background-color: var(--accent);
}
&:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
}
&[data-today] button {
background-color: var(--accent);
font-weight: 600;
}
&[data-selected] button {
background-color: var(--primary);
color: var(--primary-foreground);
&:hover {
opacity: 0.9;
}
}
&[data-outside] button {
color: var(--muted-foreground);
opacity: 0.5;
}
&[data-disabled] button {
color: var(--muted-foreground);
opacity: 0.35;
cursor: not-allowed;
}
}
}
JavaScript view file
Interaction logic for the calendar component. Uses data attributes for wiring.
// -- Calendar -------------------------------------------------
// Interactive calendar grid with month navigation and day selection.
const DAYS = Array.from({ length: 7 }, (_, i) =>
new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(new Date(2024, 0, i))
);
const MONTHS = Array.from({ length: 12 }, (_, i) =>
new Intl.DateTimeFormat(undefined, { month: 'long' }).format(new Date(2024, i, 1))
);
const daysInMonth = (year, month) => new Date(year, month + 1, 0).getDate();
const firstDayOfMonth = (year, month) => new Date(year, month, 1).getDay();
const isToday = (year, month, day) => {
const now = new Date();
return now.getFullYear() === year && now.getMonth() === month && now.getDate() === day;
};
const renderCalendar = (el, year, month, selectedDay) => {
const total = daysInMonth(year, month);
const startDay = firstDayOfMonth(year, month);
const prevTotal = daysInMonth(year, month - 1);
/* Header */
const heading = el.querySelector('.calendar-heading');
if (heading) heading.textContent = `${MONTHS[month]} ${year}`;
/* Grid */
const grid = el.querySelector('.calendar-grid');
if (!grid) return;
let html = '<thead><tr>';
for (let d = 0; d < 7; d++) {
html += `<th class="calendar-day-label" scope="col">${DAYS[d]}</th>`;
}
html += '</tr></thead><tbody>';
let dayNum = 1;
let nextDayNum = 1;
const rows = Math.ceil((startDay + total) / 7);
for (let r = 0; r < rows; r++) {
html += '<tr>';
for (let c = 0; c < 7; c++) {
const cellIndex = r * 7 + c;
if (cellIndex < startDay) {
const prevDay = prevTotal - startDay + cellIndex + 1;
html += `<td class="calendar-day" data-outside><button tabindex="-1" data-day="${prevDay}" data-outside="prev">${prevDay}</button></td>`;
} else if (dayNum > total) {
html += `<td class="calendar-day" data-outside><button tabindex="-1" data-day="${nextDayNum}" data-outside="next">${nextDayNum}</button></td>`;
nextDayNum++;
} else {
let cls = 'calendar-day';
let attrs = '';
if (isToday(year, month, dayNum)) attrs += ' data-today';
if (dayNum === selectedDay) attrs += ' data-selected';
html += `<td class="${cls}"${attrs}><button data-day="${dayNum}">${dayNum}</button></td>`;
dayNum++;
}
}
html += '</tr>';
}
html += '</tbody>';
grid.innerHTML = html;
};
function init() {
document.querySelectorAll('.calendar:not([data-init])').forEach((cal) => {
cal.dataset.init = '';
const now = new Date();
const state = {
year: now.getFullYear(),
month: now.getMonth(),
selected: null
};
renderCalendar(cal, state.year, state.month, state.selected);
/* Navigation */
cal.addEventListener('click', (e) => {
const nav = e.target.closest('.calendar-nav');
if (nav) {
const action = nav.dataset.action;
if (action === 'prev-month') {
state.month--;
if (state.month < 0) { state.month = 11; state.year--; }
state.selected = null;
} else if (action === 'next-month') {
state.month++;
if (state.month > 11) { state.month = 0; state.year++; }
state.selected = null;
}
renderCalendar(cal, state.year, state.month, state.selected);
return;
}
/* Day selection */
const dayBtn = e.target.closest('.calendar-day button');
if (dayBtn && !dayBtn.closest('[data-disabled]')) {
const day = parseInt(dayBtn.dataset.day, 10);
const outside = dayBtn.dataset.outside;
if (outside === 'prev') {
state.month--;
if (state.month < 0) { state.month = 11; state.year--; }
state.selected = day;
} else if (outside === 'next') {
state.month++;
if (state.month > 11) { state.month = 0; state.year++; }
state.selected = day;
} else {
state.selected = day;
}
renderCalendar(cal, state.year, state.month, state.selected);
/* Dispatch custom event */
cal.dispatchEvent(new CustomEvent('calendar:select', {
detail: { date: new Date(state.year, state.month, state.selected) },
bubbles: true
}));
}
});
/* Keyboard navigation in grid */
cal.addEventListener('keydown', (e) => {
const dayBtn = e.target.closest('.calendar-day button');
if (!dayBtn) return;
const allBtns = Array.from(cal.querySelectorAll('.calendar-day button'));
const idx = allBtns.indexOf(dayBtn);
let next = null;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
next = allBtns[idx + 1];
break;
case 'ArrowLeft':
e.preventDefault();
next = allBtns[idx - 1];
break;
case 'ArrowDown':
e.preventDefault();
next = allBtns[idx + 7];
break;
case 'ArrowUp':
e.preventDefault();
next = allBtns[idx - 7];
break;
}
if (next) next.focus();
});
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });