Skip to content

Calendar

A lightweight date / range picker. Markup and CSS are yours. The package fills the grid and handles selection. <cinq-calendar> follows the WAI-ARIA Authoring Practices date picker grid.

Inspired by @19h47/calendar.

Terminal window
pnpm add @agencecinq/calendar

Import once:

import "@agencecinq/calendar";

Then write the grid markup. The host <cinq-calendar> fills weekday headers and day cells, and wires selection + keyboard navigation.

HTML is the source of truth. The component does not invent missing ARIA attributes. Provide role="grid", labels, and live regions yourself.

<cinq-calendar locale="fr" deselect>
<header>
<button type="button" class="js-previous" aria-label="Previous month">Previous</button>
<button type="button" class="js-title" id="calendar-label" aria-live="polite"></button>
<button type="button" class="js-next" aria-label="Next month">Next</button>
</header>
<table role="grid" aria-labelledby="calendar-label">
<thead>
<tr class="js-days"></tr>
</thead>
<tbody class="js-body"></tbody>
</table>
</cinq-calendar>

The component upgrades automatically on connectedCallback.

Interactive demos below cover single date, range, multiple dates, preselection, future-only, locale / week start, and an advanced renderInner events calendar.

Single date

Locale fr, deselect and allow-past. Click the selected day again to clear. Try the keyboard once a day is focused: arrows, Home/End, Page Up/Page Down.

Selection : Aucune date

Date range

mode="range". Pick a start day, then an end day. Hover (or move focus) paints the in-between preview. A third click resets the range.

Selection : Aucune date

Multiple dates

mode="multiple". Toggle discrete days in and out of the selection. No range painting. Click a selected day again to remove it. Cap the count in your app if needed (no built-in max).

Selection : Aucune date

Preselected dates

Seed the view with data-month, data-year, and data-picked. Here a range covering mid-November 2025.

Selection : 2025-11-10 to 2025-11-18

Future only

Without allow-past, days before today are aria-disabled and cannot be selected (still in the keyboard grid).

Selection : Aucune date

Locale & week start

Change the locale. Labels update via Intl, and week start follows getWeekStart(locale) unless you override first-day.

firstDay = 1(0=Sun ... 6=Sat)

Selection : Aucune date

Advanced: event markers

Override renderInner to wrap each day in <time>, mark weekends, and paint dots on days that have events. Only event days are selectable. Others get aria-disabled.

Selection : Aucune date

Default mode="single". Add deselect to clear by clicking the selected day again.

<cinq-calendar locale="fr" deselect allow-past name="arrival">
<!-- header + table -->
</cinq-calendar>

Set mode="range". First click is the start, second is the end. Hover or keyboard focus previews the range. A third click resets.

<cinq-calendar locale="fr" mode="range" allow-past name="stay">
<!-- header + table -->
</cinq-calendar>

Set mode="multiple". Each click toggles a day in or out of the selection. No range preview.

<cinq-calendar locale="fr" mode="multiple" allow-past name="availability">
<!-- header + table -->
</cinq-calendar>

The package does not enforce a maximum number of days. That is application logic. Listen to calendar:change and trim picked yourself. Pass emit: false to setPicked so the trim does not fire a second calendar:change:

import { EVENTS } from "@agencecinq/utils";
const MAX = 3;
el.addEventListener(EVENTS.CALENDAR_CHANGE, () => {
if (el.picked.length <= MAX) return;
el.setPicked(el.picked.slice(0, MAX), false);
el.render();
});

Seed the viewed month and selection from attributes:

<cinq-calendar
locale="fr"
mode="range"
allow-past
data-month="10"
data-year="2025"
data-picked='["2025-11-10","2025-11-18"]'
>
<!-- header + table -->
</cinq-calendar>

Omit allow-past (default). Past days stay in the grid with aria-disabled="true" and cannot be selected.

<cinq-calendar locale="fr" deselect>
<!-- header + table -->
</cinq-calendar>

locale drives labels via Intl. Week start follows the locale. Override with first-day (0=Sun … 6=Sat). Use the select in the playground to try en, fr, de, es, ja, and ar.

<!-- French to week starts Monday -->
<cinq-calendar locale="fr">...</cinq-calendar>
<!-- English labels, force Monday -->
<cinq-calendar locale="en" first-day="1">...</cinq-calendar>

Update at runtime:

import { getWeekStart } from "@agencecinq/calendar";
const el = document.querySelector("cinq-calendar");
el.options.locale = "ja";
el.options.firstDay = getWeekStart("ja");
el.render();

Override renderInner to enrich each day cell. Keep .js-day and data-day. The playground Advanced: event markers example uses it to add <time>, weekend styling, event dots, and to disable days without events.

el.renderInner = (inner, date) => {
const button = inner.querySelector(".js-day");
if (!button) return;
button.classList.add("MyDay");
button.innerHTML = `<time datetime="${date.toISOString().slice(0, 10)}">${date.getDate()}</time>`;
};
Selector Required Role
<cinq-calendar> Yes Host element
.js-body Yes Day cells container (tbody)
.js-days Yes Weekday headers row
.js-previous / .js-next Optional Month navigation
.js-title Optional Month / year label (click = next month)
.js-day Generated Day button
Attribute Description
locale / data-locale BCP 47 locale for labels
mode single (default), range, or multiple
deselect Allow clearing selection in mode="single"
allow-past Allow selecting past dates
first-day Week start (0=Sun … 6=Sat)
button-class Extra classes on day buttons
name Forwarded in change event detail
data-month / data-year Initial view (month is 0-11)
data-picked JSON array of preselected YYYY-MM-DD days
Event Constant Detail
calendar:change CALENDAR_CHANGE { values: string[], name?: string }
import { EVENTS } from "@agencecinq/utils";
document.querySelector("cinq-calendar")?.addEventListener(
EVENTS.CALENDAR_CHANGE,
({ detail }) => console.log(detail.values, detail.name),
);
Key Action
Arrow keys Move by day / week
Home / End First / last day of the week
Page Up / Page Down Previous / next month
Shift + Page Up / Down Previous / next year
Enter / Space Select the focused day
Method / property Description
options Runtime options object
picked Selected days as YYYY-MM-DD
current Viewed { month, year, day }
render() Rebuild the grid
setPicked(picked, emit?) Assign selection, mirror data-picked. Emits calendar:change when emit is true (default). Pass false to sync without re-firing the event.
move(delta) Navigate by months (-1 = previous, 1 = next)
destroy() Detach listeners and clear the grid
renderInner(inner, date) Per-cell hook (override)

Exported helpers: getWeekStart, toDayString, fromDayString.