Github

Calendar

.calendar is a month grid of day buttons. Caption plus .calendar-prev / .calendar-next on outline icon buttons — not classes named left or right. Selected day is data-active. A range uses the same classes: first click is start, second is end (swapped if the second day is earlier). After start is chosen, moving the pointer previews the range with the same data-* (reversed if the pointer is before start). In-range days are data-range; ends are data-range-start and data-range-end. Same-day start and end are both attributes on one cell. Month labels are English (January … December). Native Date builds the cells; there is no date library. A dual-month range is two grids in a .cluster. Outside days are inert .calendar-pad cells (no number, not a button). The second month is always the next month; previous is only on the first grid, next only on the second. The first grid stays on the start month once a start is picked. There is no wrapper. Tiny $state on this page is enough. Inert — no booking backend.

Month grid

August 2026
Su
Mo
Tu
We
Th
Fr
Sa

No day selected.

<script>
	const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ];
	const WEEKDAYS = [ 'Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa' ];
	const now = new Date();
	let view = $state({ year: now.getFullYear(), month: now.getMonth() });
	let selected = $state(null);

	function month_label(year, month) {
		return MONTHS[month] + ' ' + year;
	}
	function month_days(year, month) {
		const weekday = new Date(year, month, 1).getDay();
		const start = new Date(year, month, 1 - weekday);
		const days = [];
		for (let i = 0; i < 42; i++) {
			const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
			days.push({ year: date.getFullYear(), month: date.getMonth(), day: date.getDate(), outside: date.getMonth() !== month });
		}
		return days;
	}
	function is_today(cell) {
		const today = new Date();
		return cell.year === today.getFullYear() && cell.month === today.getMonth() && cell.day === today.getDate();
	}
	function is_selected(cell) {
		return selected && selected.year === cell.year && selected.month === cell.month && selected.day === cell.day;
	}
	function shift(delta) {
		const next = new Date(view.year, view.month + delta, 1);
		view = { year: next.getFullYear(), month: next.getMonth() };
	}
	function pick(cell) {
		selected = { year: cell.year, month: cell.month, day: cell.day };
		if (cell.outside) view = { year: cell.year, month: cell.month };
	}
</script>

<div class="calendar">
	<div class="calendar-caption">
		<button class="button button-outline button-icon button-sm calendar-prev" type="button" aria-label="Previous month" onclick={() => shift(-1)}>
			<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
				<path d="m15 18-6-6 6-6"></path>
			</svg>
		</button>
		<div class="calendar-heading" aria-live="polite">{month_label(view.year, view.month)}</div>
		<button class="button button-outline button-icon button-sm calendar-next" type="button" aria-label="Next month" onclick={() => shift(1)}>
			<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
				<path d="m9 18 6-6-6-6"></path>
			</svg>
		</button>
	</div>
	<div class="calendar-grid">
		{#each WEEKDAYS as day}
			<div class="calendar-weekday">{day}</div>
		{/each}
		{#each month_days(view.year, view.month) as cell}
			<button class="calendar-day" type="button" data-outside={cell.outside ? '' : undefined} data-today={is_today(cell) ? '' : undefined} data-active={is_selected(cell) ? '' : undefined} onclick={() => pick(cell)}>{cell.day}</button>
		{/each}
	</div>
</div>

Range

Same .calendar grid. First click sets start; second click sets end. If the second day is before the start, the two swap. A third click begins a new range. After start and before end, mouseenter on a day paints a temporary range from start to that day (or the reverse). The preview uses the same data-range* as a completed range and stops when the end is picked. In-range days get a muted background track; start and end are rounded on the start and end edges. Same-day start and end is fully rounded. There is no date library.

Single Calendar

One month. Days from the adjacent months stay visible and clickable — picking one navigates to that month.

August 2026
Su
Mo
Tu
We
Th
Fr
Sa

No range selected.

<script>
	const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ];
	const WEEKDAYS = [ 'Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa' ];
	const now = new Date();
	let view = $state({ year: now.getFullYear(), month: now.getMonth() });
	let range_start = $state(null);
	let range_end = $state(null);
	let range_hover = $state(null);

	function month_label(year, month) {
		return MONTHS[month] + ' ' + year;
	}
	function month_days(year, month) {
		const weekday = new Date(year, month, 1).getDay();
		const start = new Date(year, month, 1 - weekday);
		const days = [];
		for (let i = 0; i < 42; i++) {
			const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
			days.push({ year: date.getFullYear(), month: date.getMonth(), day: date.getDate(), outside: date.getMonth() !== month });
		}
		return days;
	}
	function day_time(value) {
		return Date.UTC(value.year, value.month, value.day);
	}
	function same_day(a, b) {
		return a && b && a.year === b.year && a.month === b.month && a.day === b.day;
	}
	function range_span(start, end, hover) {
		if (!start) return { start: null, end: null };
		if (end) return { start, end };
		if (!hover || same_day(hover, start)) return { start, end: start };
		if (day_time(hover) < day_time(start)) return { start: hover, end: start };
		return { start, end: hover };
	}
	function paints_in_range(cell, start, end, hover) {
		if (cell.outside) return false;
		const span = range_span(start, end, hover);
		if (!span.start || !span.end) return false;
		const time = day_time(cell);
		return time > day_time(span.start) && time < day_time(span.end);
	}
	function paints_range_start(cell, start, end, hover) {
		if (cell.outside) return false;
		return same_day(range_span(start, end, hover).start, cell);
	}
	function paints_range_end(cell, start, end, hover) {
		if (cell.outside) return false;
		return same_day(range_span(start, end, hover).end, cell);
	}
	function shift(delta) {
		const next = new Date(view.year, view.month + delta, 1);
		view = { year: next.getFullYear(), month: next.getMonth() };
	}
	function pick_range(cell) {
		const day = { year: cell.year, month: cell.month, day: cell.day };
		if (!range_start || range_end) {
			range_start = day;
			range_end = null;
			range_hover = day;
		} else if (day_time(day) < day_time(range_start)) {
			range_end = range_start;
			range_start = day;
			range_hover = null;
		} else {
			range_end = day;
			range_hover = null;
		}
		if (cell.outside) view = { year: cell.year, month: cell.month };
	}
	function preview_range(cell) {
		if (range_end || !range_start) return;
		range_hover = { year: cell.year, month: cell.month, day: cell.day };
	}
</script>

<div class="calendar" onmouseleave={() => range_hover = null}>
	<div class="calendar-caption">
		<button class="button button-outline button-icon button-sm calendar-prev" type="button" aria-label="Previous month" onclick={() => shift(-1)}>
			<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
				<path d="m15 18-6-6 6-6"></path>
			</svg>
		</button>
		<div class="calendar-heading" aria-live="polite">{month_label(view.year, view.month)}</div>
		<button class="button button-outline button-icon button-sm calendar-next" type="button" aria-label="Next month" onclick={() => shift(1)}>
			<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
				<path d="m9 18 6-6-6-6"></path>
			</svg>
		</button>
	</div>
	<div class="calendar-grid">
		{#each WEEKDAYS as day}
			<div class="calendar-weekday">{day}</div>
		{/each}
		{#each month_days(view.year, view.month) as cell}
			<button class="calendar-day" type="button" data-outside={cell.outside ? '' : undefined} data-range={paints_in_range(cell, range_start, range_end, range_hover) ? '' : undefined} data-range-start={paints_range_start(cell, range_start, range_end, range_hover) ? '' : undefined} data-range-end={paints_range_end(cell, range_start, range_end, range_hover) ? '' : undefined} onmouseenter={() => preview_range(cell)} onclick={() => pick_range(cell)}>{cell.day}</button>
		{/each}
	</div>
</div>

Two Calendars

Two .calendar grids side by side in a .cluster (flex wrap — not classes named left or right). Same clicks as Range: first day is start, second is end, reverse swap if the second day is earlier. After start and before end, hovering a day on either grid previews the range across both. Sibling-month numbers are not shown — those cells are .calendar-pad (inert, no data-range*). The second month is always the next month. Previous is only on the first grid; next is only on the second. Once a start is picked, the first grid stays on that month. In-range fill uses data-range across both grids.

August 2026
Su
Mo
Tu
We
Th
Fr
Sa
September 2026
Su
Mo
Tu
We
Th
Fr
Sa

No range selected.

<script>
	const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ];
	const WEEKDAYS = [ 'Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa' ];
	const now = new Date();
	let view = $state({ year: now.getFullYear(), month: now.getMonth() });
	let range_start = $state(null);
	let range_end = $state(null);
	let range_hover = $state(null);

	function month_label(year, month) {
		return MONTHS[month] + ' ' + year;
	}
	function month_days(year, month) {
		const weekday = new Date(year, month, 1).getDay();
		const start = new Date(year, month, 1 - weekday);
		const days = [];
		for (let i = 0; i < 42; i++) {
			const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
			days.push({ year: date.getFullYear(), month: date.getMonth(), day: date.getDate(), outside: date.getMonth() !== month });
		}
		return days;
	}
	function following_month(view) {
		const next = new Date(view.year, view.month + 1, 1);
		return { year: next.getFullYear(), month: next.getMonth() };
	}
	function day_time(value) {
		return Date.UTC(value.year, value.month, value.day);
	}
	function same_day(a, b) {
		return a && b && a.year === b.year && a.month === b.month && a.day === b.day;
	}
	function range_span(start, end, hover) {
		if (!start) return { start: null, end: null };
		if (end) return { start, end };
		if (!hover || same_day(hover, start)) return { start, end: start };
		if (day_time(hover) < day_time(start)) return { start: hover, end: start };
		return { start, end: hover };
	}
	function paints_in_range(cell, start, end, hover) {
		if (cell.outside) return false;
		const span = range_span(start, end, hover);
		if (!span.start || !span.end) return false;
		const time = day_time(cell);
		return time > day_time(span.start) && time < day_time(span.end);
	}
	function paints_range_start(cell, start, end, hover) {
		if (cell.outside) return false;
		return same_day(range_span(start, end, hover).start, cell);
	}
	function paints_range_end(cell, start, end, hover) {
		if (cell.outside) return false;
		return same_day(range_span(start, end, hover).end, cell);
	}
	function shift(delta) {
		const next = new Date(view.year, view.month + delta, 1);
		view = { year: next.getFullYear(), month: next.getMonth() };
	}
	function pick_range(cell) {
		if (cell.outside) return;
		const day = { year: cell.year, month: cell.month, day: cell.day };
		if (!range_start || range_end) {
			range_start = day;
			range_end = null;
			range_hover = day;
		} else if (day_time(day) < day_time(range_start)) {
			range_end = range_start;
			range_start = day;
			range_hover = null;
		} else {
			range_end = day;
			range_hover = null;
		}
		view = { year: range_start.year, month: range_start.month };
	}
	function preview_range(cell) {
		if (cell.outside || range_end || !range_start) return;
		range_hover = { year: cell.year, month: cell.month, day: cell.day };
	}
</script>

<div class="cluster" style="--cluster-gap: 0.75rem; align-items: start" onmouseleave={() => range_hover = null}>
	<div class="calendar">
		<div class="calendar-caption">
			<button class="button button-outline button-icon button-sm calendar-prev" type="button" aria-label="Previous month" onclick={() => shift(-1)}>
				<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
					<path d="m15 18-6-6 6-6"></path>
				</svg>
			</button>
			<div class="calendar-heading" aria-live="polite">{month_label(view.year, view.month)}</div>
			<div class="calendar-caption-pad" aria-hidden="true"></div>
		</div>
		<div class="calendar-grid">
			{#each WEEKDAYS as day}
				<div class="calendar-weekday">{day}</div>
			{/each}
			{#each month_days(view.year, view.month) as cell}
				{#if cell.outside}
					<div class="calendar-pad" aria-hidden="true"></div>
				{:else}
					<button class="calendar-day" type="button" data-range={paints_in_range(cell, range_start, range_end, range_hover) ? '' : undefined} data-range-start={paints_range_start(cell, range_start, range_end, range_hover) ? '' : undefined} data-range-end={paints_range_end(cell, range_start, range_end, range_hover) ? '' : undefined} onmouseenter={() => preview_range(cell)} onclick={() => pick_range(cell)}>{cell.day}</button>
				{/if}
			{/each}
		</div>
	</div>
	<div class="calendar">
		<div class="calendar-caption">
			<div class="calendar-caption-pad" aria-hidden="true"></div>
			<div class="calendar-heading" aria-live="polite">{month_label(following_month(view).year, following_month(view).month)}</div>
			<button class="button button-outline button-icon button-sm calendar-next" type="button" aria-label="Next month" onclick={() => shift(1)}>
				<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
					<path d="m9 18 6-6-6-6"></path>
				</svg>
			</button>
		</div>
		<div class="calendar-grid">
			{#each WEEKDAYS as day}
				<div class="calendar-weekday">{day}</div>
			{/each}
			{#each month_days(following_month(view).year, following_month(view).month) as cell}
				{#if cell.outside}
					<div class="calendar-pad" aria-hidden="true"></div>
				{:else}
					<button class="calendar-day" type="button" data-range={paints_in_range(cell, range_start, range_end, range_hover) ? '' : undefined} data-range-start={paints_range_start(cell, range_start, range_end, range_hover) ? '' : undefined} data-range-end={paints_range_end(cell, range_start, range_end, range_hover) ? '' : undefined} onmouseenter={() => preview_range(cell)} onclick={() => pick_range(cell)}>{cell.day}</button>
				{/if}
			{/each}
		</div>
	</div>
</div>

Native date

A sibling to the month grid: native <input type="date"> with Vega .input. Pair with a label. There is no wrapper.

<div class="field">
	<label class="label" for="when">Date</label>
	<input class="input" id="when" type="date">
</div>