Skip to content

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.

import { installCalendar } from '@hypermedia-components/core';
installCalendar(); // idempotent; returns an uninstaller

The zero-config @hypermedia-components/core/behaviors entry installs it automatically.

All configuration is via data-* attributes on the container:

AttributePurpose
data-valueSelected date, ISO YYYY-MM-DD. Also sets the displayed month.
data-min / data-maxSelectable range (ISO). Days outside are aria-disabled.
data-first-dayFirst day of the week: 0 = Sunday (default) … 6 = Saturday.
data-localeBCP-47 locale for month / weekday names. Falls back to <html lang>, then the browser default.
data-nameWhen set, the behavior maintains a hidden <input name="…"> with the selected ISO value, so the calendar serialises in a form.
data-targetA 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).

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:

The year range spans data-mindata-max when set, otherwise the focused year ±10. The dropdown labels are translatable (calendar.month / calendar.year), and the arrows still work alongside them.

Focus a day cell, then:

KeyAction
/ Previous / next day
/ Previous / next week
Home / EndFirst / last day of the week
PageUp / PageDownPrevious / next month
Shift+PageUp / Shift+PageDownPrevious / next year
Enter / SpaceSelect 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.

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
});

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 inputsstay-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.)

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:

Submits due=2026-05-15

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.

Format: YYYY-MM-DD

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.

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.

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.

  • The trigger is a regular popovertarget button: 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.

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.

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>

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.

  • The grid follows the WAI-ARIA APG date-picker pattern: a role="grid" table with <td role="gridcell"> day cells, a roving tabindex, aria-selected on the chosen day, and aria-disabled for out-of-range days. Each cell’s aria-label is 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-label or aria-labelledby).
Token pathPurpose
calendar.bg / fg / border / radius / paddingSurface.
calendar.title-*Month title.
calendar.nav-*Prev / next buttons.
calendar.weekday-*Weekday header row.
calendar.day-size / day-radius / day-font-size / day-fgDay cells.
calendar.day-hover-bgDay hover.
calendar.day-selected-bg / day-selected-fgSelected day (tracks data-color).
calendar.day-today-borderToday’s ring.
calendar.day-outside-fg / day-disabled-fgAdjacent-month / out-of-range days.
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-fg

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.

  • Datepicker — native <input type="date"> skin; the no-JS baseline.
  • Popover — pair with a calendar to build a dropdown date field.