Calendar
hc-calendar is a styled, inline month grid for choosing a date — the
shadcn Calendar equivalent. installCalendar renders the grid into a
.hc-calendar container and wires the WAI-ARIA date-picker keyboard
model. You author only the container with data-* config; the behavior
renders the header, the localized weekday row, and a six-week grid of
<td role="gridcell"> day cells managed by a roving tabindex.
Basic usage
Section titled “Basic usage”<div class="hc-calendar" data-value="2026-05-15" aria-label="Pick a date"></div>import { installCalendar } from '@hypermedia-components/core';installCalendar(); // idempotent; returns an uninstallerThe zero-config @hypermedia-components/core/behaviors entry installs
it automatically.
Configuration
Section titled “Configuration”All configuration is via data-* attributes on the container:
| Attribute | Purpose |
|---|---|
data-value | Selected date, ISO YYYY-MM-DD. Also sets the displayed month. |
data-min / data-max | Selectable range (ISO). Days outside are aria-disabled. |
data-first-day | First day of the week: 0 = Sunday (default) … 6 = Saturday. |
data-locale | BCP-47 locale for month / weekday names. Falls back to <html lang>, then the browser default. |
data-name | When set, the behavior maintains a hidden <input name="…"> with the selected ISO value, so the calendar serialises in a form. |
data-target | A CSS selector for an external field. On each pick the calendar writes the value into it (firing input / change) and closes the enclosing popover — a custom date field with no per-field JavaScript. It also seeds the calendar’s initial selection from that field’s value, so you set the date once (on the input) and omit data-value. |
data-nav | "select" swaps the month/year title for dropdown pickers (see Month / year navigation). |
Month and weekday names come from Intl.DateTimeFormat. The first day
of the week is taken from data-first-day (not Intl.Locale’s
getWeekInfo(), which is not yet Baseline).
Month / year navigation
Section titled “Month / year navigation”The header shows previous/next-month arrows by default. Add data-nav="select"
to swap the title for month and year dropdowns, so users can jump to a far
month/year in one step:
<div class="hc-calendar" data-value="2026-05-15" data-nav="select" aria-label="Pick a date"></div>The year range spans data-min…data-max when set, otherwise the focused
year ±10. The dropdown labels are translatable (calendar.month /
calendar.year), and the arrows still work alongside them.
Keyboard
Section titled “Keyboard”Focus a day cell, then:
| Key | Action |
|---|---|
← / → | Previous / next day |
↑ / ↓ | Previous / next week |
Home / End | First / last day of the week |
PageUp / PageDown | Previous / next month |
Shift+PageUp / Shift+PageDown | Previous / next year |
Enter / Space | Select the focused day |
Moving past the edge of the month re-renders the adjacent month with the
target day focused. The grid is a single Tab stop (roving tabindex);
days outside data-min / data-max stay focusable but are not
selectable.
The change event
Section titled “The change event”Selecting a date dispatches a bubbling hc:calendarchange on the
container and updates data-value:
calendar.addEventListener('hc:calendarchange', (e) => { const { value, date } = e.detail; // value: 'YYYY-MM-DD', date: Date});Range selection
Section titled “Range selection”Add data-mode="range" to pick a start and end date. The first click (or
Enter) sets the start, the next sets the end (auto-swapped so
start ≤ end), and a third starts a fresh range. While the second end is
being chosen, the tentative band previews under the pointer or the
keyboard focus.
<div class="hc-calendar" data-mode="range" data-value="2026-05-10/2026-05-14" data-name="stay" aria-label="Pick a date range"></div>The days carry data-in-range between the ends (with data-range-start /
data-range-end markers, and data-range-preview* during selection) so you
can theme the band. Each change dispatches hc:calendarrangechange:
calendar.addEventListener('hc:calendarrangechange', (e) => { const { start, end, startDate, endDate } = e.detail; // start / end: 'YYYY-MM-DD' (end is null until the second pick)});data-value becomes "START/END", and with data-name="stay" the calendar
writes two hidden inputs — stay-start and stay-end — so the range
serialises for htmx without any client-side state:
<!-- submits stay-start=2026-05-10 & stay-end=2026-05-14 -->Single-date mode stays the default. (Multiple months side by side and week numbers remain out of scope.)
Form integration
Section titled “Form integration”Set data-name and the calendar serialises like a native control via a
hidden input — it submits with the surrounding <form> exactly like
<input name>. Pick a date below and watch the submitted value update:
<form> <div class="hc-calendar" data-name="due" data-value="2026-05-15" aria-label="Due date"></div> <!-- submits due=2026-05-15 — the hidden input updates as the user picks --></form>As a custom date field
Section titled “As a custom date field”Don’t like the native date control? This is the blessed date-field
pattern: an hc-field stanza
whose hc-input is the visible
field, a trailing button that opens a calendar in a
popover, and picking a day
that fills the input and closes the popover. It is plain markup — code
generators can emit it verbatim for date columns, and it is stable under
the markup versioning policy.
Point the calendar at the field with data-target — the behavior
writes the value and closes the popover for you. No per-field
JavaScript, so a page with many date fields stays pure markup. Set the
initial date once on the input (value="…"); the calendar inherits it,
so there is no data-value to keep in sync.
<div class="hc-field"> <label class="hc-field__label" for="due">Due date</label>
<div class="hc-input-group"> <!-- The value lives here, once — and this input carries the form name. --> <input class="hc-input" id="due" name="due" type="text" readonly value="2026-05-15" placeholder="Pick a date"> <button class="hc-button" type="button" popovertarget="due-cal" aria-label="Choose date">📅</button> </div>
<div id="due-cal" class="hc-popover" popover data-side="bottom" data-align="end"> <!-- data-target seeds from #due, fills it on pick, and closes #due-cal. No JS. --> <div class="hc-calendar" data-target="#due" aria-label="Choose date"></div> </div>
<p class="hc-field__message">Format: YYYY-MM-DD</p></div>The popover anchors below the trigger via installPopover
(data-side / data-align). Add as many such fields as you like — each
is just markup with its own data-target.
Who carries the form name
Section titled “Who carries the form name”The visible input carries name and submits the value (due=…) —
the calendar drives it through data-target, firing input / change
so validation and htmx triggers see every pick. Do not also set
data-name on the calendar here: that renders a second, hidden control
with the same value and the field would submit twice. data-name is for
a standalone calendar sitting directly in a form (see
Form integration above).
Because the name lives on a real input inside an hc-field, the
field-errors recipe works
unchanged: a server item with data-field="due" finds the input, writes
its message into this field’s error slot, and marks the input
aria-invalid.
Read-only or free-typed?
Section titled “Read-only or free-typed?”The blessed form is readonly: the calendar is the only editor, so
input and grid can never disagree, and there is no client-side parsing of
hand-typed dates (readonly inputs still submit; they only block
typing). If you drop readonly to allow typing, know that the calendar
reads the field once (at install) — it does not re-seed from text
typed afterwards — so validate typed values server-side, e.g. with the
field-errors recipe.
Keyboard and focus
Section titled “Keyboard and focus”- The trigger is a regular
popovertargetbutton: Enter / Space opens the popover. Tab then walks the calendar header (previous / next month) and lands in the grid — a single tab stop whose roving tabindex sits on the selected day. - Inside the grid the full calendar keyboard model applies; Enter / Space picks the focused day, which fills the input and closes the popover. Focus returns to the trigger button (native popover focus restoration).
- Esc and light dismiss close the popover without changing the field — both native popover behaviors.
These claims are pinned by a browser test against this exact markup
(test-browser/datefield.spec.mjs), so the pattern stays stable for
generated code.
Locale
Section titled “Locale”data-locale falls back to <html lang>, and that fallback is the
intended path for server-localized pages: render lang once and every
date field follows, no per-field attribute. The labels the behavior
injects (previous / next month, the grid’s fallback name) come from the
i18n catalog; the visible
<label>, the trigger’s aria-label, and the calendar’s aria-label
are yours to localize in markup.
htmx usage
Section titled “htmx usage”Fetch off the change event — e.g. load availability for the picked day:
<div class="hc-calendar" data-name="day" data-hx-get="/availability" data-hx-trigger="hc:calendarchange" data-hx-include="this" data-hx-target="#slots" aria-label="Pick a day"></div>Hyperscript
Section titled “Hyperscript”React to the change event inline — the same event htmx can drive via
data-hx-trigger:
<div class="hc-calendar" aria-label="Pick a date" _="on hc:calendarchange put event.detail.value into #due"></div><output id="due"></output>More patterns: Hyperscript → Reacting to component events.
Accessibility
Section titled “Accessibility”- The grid follows the WAI-ARIA APG date-picker pattern: a
role="grid"table with<td role="gridcell">day cells, a roving tabindex,aria-selectedon the chosen day, andaria-disabledfor out-of-range days. Each cell’saria-labelis the full localized date. - The month title is an
aria-live="polite"region, so month changes are announced. - Always give the container an accessible name (
aria-labeloraria-labelledby).
Theming tokens
Section titled “Theming tokens”| Token path | Purpose |
|---|---|
calendar.bg / fg / border / radius / padding | Surface. |
calendar.title-* | Month title. |
calendar.nav-* | Prev / next buttons. |
calendar.weekday-* | Weekday header row. |
calendar.day-size / day-radius / day-font-size / day-fg | Day cells. |
calendar.day-hover-bg | Day hover. |
calendar.day-selected-bg / day-selected-fg | Selected day (tracks data-color). |
calendar.day-today-border | Today’s ring. |
calendar.day-outside-fg / day-disabled-fg | Adjacent-month / out-of-range days. |
CSS variables
Section titled “CSS variables”Show the generated CSS variables
--hc-calendar-bg | -fg | -border | -radius | -padding--hc-calendar-title-font-size | -title-font-weight--hc-calendar-nav-size | -nav-fg | -nav-hover-bg | -nav-radius--hc-calendar-weekday-fg | -weekday-font-size | -weekday-font-weight--hc-calendar-day-size | -day-radius | -day-font-size | -day-fg--hc-calendar-day-hover-bg | -day-selected-bg | -day-selected-fg--hc-calendar-day-today-border | -day-outside-fg | -day-disabled-fgOut of scope
Section titled “Out of scope”Multiple months side by side, week numbers, time selection, and non-Gregorian calendars are deferred. Range selection (see Range selection) and month / year dropdown navigation (see Month / year navigation) are supported. For a no-JS native field, see Datepicker.
Related
Section titled “Related”- Datepicker — native
<input type="date">skin; the no-JS baseline. - Popover — pair with a calendar to build a dropdown date field.