MenuChevron Down
DatePicker - Docs - Artefact

DatePicker

Forms
Smart auto-detect

Introduction

A date picker that combines a text input with a popup calendar. It supports single, multiple, and range selection, month/year panel views, quick-select presets, week numbers, and manual date entry — rendered server-side with HonoX and hydrated as an island only when interactivity is needed.

The component is headless by design: app/components/ui/date-picker-primitive.tsx produces semantic, accessible markup and state, while app/islands/date-picker.tsx adds the client-side behaviour (keyboard navigation, hover preview, outside-click, typing). No new runtime dependencies are introduced — it builds on Hono JSX and Panda CSS, the same stack as the rest of the design system.

What's new in this revision

  • Selected values now survive static rendering. The input previously rendered its value through defaultValue, which hono/jsx serialises as a dead defaultValue="…" attribute — a statically rendered picker with a value/defaultValue showed an empty input. It now renders a real value attribute; typing stays uncontrolled because the DOM runtime only assigns the value property when the prop actually changes.
  • Month/year dropdowns preselect correctly in SSR. MonthSelect/YearSelect used value on <select>, which is not an HTML attribute, so the focused month/year was never preselected in server output. The matching <option> now carries selected.
  • Localised calendar text. Weekday headers, month grids, and the month dropdown are generated from Intl.DateTimeFormat for the configured locale (cached per locale, with an English fallback when the locale is unknown). Previously only the heading respected locale.
  • Range endpoints connect to the band. The start/end days of a range now carry data-range-start / data-range-end, and the recipe squares their inner corners so the endpoints join the in-range highlight seamlessly.
  • Keyboard focus survives view switches. Zooming between day/month/year views (via the heading or by drilling into a month/year) used to drop DOM focus onto <body> because the previously focused control gets hidden. Focus now moves onto the active cell of the new view.
  • Grid semantics tightened. The stray role="row" on <thead> (which overrode its implicit rowgroup role) is gone, and the grid announces aria-multiselectable in multiple/range modes.
  • Popup stays on-screen. The calendar popup caps its width to the viewport on narrow screens instead of overflowing horizontally.

Carried over from the previous revision: keyboard-first grid navigation clamped to [min, max], bounded month/year views, range hover preview, free-text entry with commit-on-Enter/blur, native form submission via hidden inputs, ISO week numbers, conditional clear trigger, ARIA grid semantics with a roving tab stop, and open/close animations keyed to data-state.

Functionality

  • Popup calendar — opens from the calendar trigger or by focusing/clicking the input; closes on outside click, Escape (focus returns to the trigger), or after a completed selection when closeOnSelect is set.
  • Panel views — click the month/year heading to zoom out from days → months → years; selecting a year or month drills back down. The prev/next arrows page by month, year, or decade depending on the view.
  • Bounded selectionmin/max are enforced in every view: out-of-range days are disabled, months and years that fall entirely outside the range are disabled too, and keyboard focus is clamped so it can never rest on a disabled cell.
  • Manual entry — type a date in the input and press Enter (or blur). Valid YYYY-MM-DD values are committed and normalised; invalid or out-of-range values revert to the last committed value. Clearing the input clears the selection.
  • Range selection — the first click sets the start, the second sets the end (automatically ordered); the days in between are highlighted, and hovering previews the span before you commit.
  • Multiple selection — clicking toggles individual days on and off.
  • Week numbers — with showWeekNumbers, an ISO week-number column is shown alongside the day grid.
  • Form submission — pass a name prop and a hidden input is rendered for each selected date, so the picker works inside a plain <form> with no extra glue code.
  • PresetsDatePicker.PresetTrigger supports the values today, last3Days, last7Days, last14Days, last30Days, and last90Days. In range mode a preset selects the whole span; otherwise it selects the single anchor date.

Keyboard support

When the popup is open and focus is inside the calendar, the following keys are active:

KeyDay viewMonth viewYear view
/ Previous / next dayPrevious / next monthPrevious / next year
/ ±1 week±3 months±3 years
Home / EndStart / end of week
PageUp / PageDownPrevious / next monthPrevious / next yearPrevious / next decade
Shift+PageUp / Shift+PageDownPrevious / next year
Enter / SpaceSelect focused dayDrill into monthDrill into year
EscClose popup, return focus to trigger

The trigger and input are reachable by Tab; opening from the keyboard moves focus straight into the grid so the calendar is operable without a mouse.

Accessibility

  • The grid uses ARIA grid semantics (role="grid", row, gridcell, columnheader, rowheader) with aria-selected, aria-current="date" on the today cell, and aria-multiselectable in multiple/range modes.
  • A roving tabindex keeps a single tab stop on the focused date; arrow keys move focus without tabbing through every cell.
  • Focus is managed: opening from the keyboard focuses the active day; closing returns focus to the trigger; switching between day/month/year views moves focus onto the active cell of the new view instead of dropping it.
  • All interactive controls carry aria-labels (Open date picker, Clear selected dates, Previous, Next, Switch calendar view, Select month, Select year).
  • Colour is never the only signal — selected, today, in-range, and disabled states combine fill, weight, and (for today) a dot marker.
  • Respects disabled / readOnly / invalid via aria-disabled, readonly, and aria-invalid.

Styling slots

The Panda CSS slot recipe (app/theme/recipes/date-picker.ts) exposes the following data-part slots for theming. Override via the class/className props or a semantic classNames map:

root, label, control, input, trigger, clearTrigger, positioner, content, view, viewControl, prevTrigger, nextTrigger, viewTrigger, rangeText, table, tableHead, tableHeader, tableRow, tableBody, tableCell, tableCellTrigger, weekNumber, monthSelect, yearSelect, presetTrigger, valueText. The hidden-input part is rendered only when name is set and carries no visible styling.

The selected / today / in-range / range-preview / disabled states are driven by data-selected, data-today, data-in-range, data-outside-range, data-range-preview, and data-disabled attributes (applied in all three panel views), so they can be re-skinned independently of the recipe. Range endpoints additionally carry data-range-start / data-range-end (only when the range spans more than one day), which the recipe uses to square their inner corners against the in-range band. The open/close animation keys off data-state="open" / data-state="closed".

Hydration

Tier 1 — interactive by default. A DatePicker hydrates as an island unless explicitly opted out with interactive={false}, in which case it renders as static HTML with no client JS.

interactive propResult
omittedHydrates as an island
trueHydrates as an island
falseStatic — no client JS

All interactivity decisions in the library route through the shared shouldHydrate() helper in app/components/ui/island-utils.ts.

Usage

import { DatePicker } from "../components/ui";

export default function MyPage() {
  return (
    <>
      {/* Single date */}
      <DatePicker label="Choose Date" selectionMode="single" />

      {/* Date range with bounds */}
      <DatePicker
        label="Travel Dates"
        selectionMode="range"
        min="2026-01-01"
        max="2026-12-31"
      />

      {/* Week numbers + accent colour */}
      <DatePicker
        label="Pick a day"
        selectionMode="single"
        showWeekNumbers
        colorPalette="purple"
      />

      {/* Preselected value */}
      <DatePicker label="Due Date" defaultValue="2026-07-15" />

      {/* Inside a native form — the selected date submits as `due` */}
      <form method="post" action="/submit">
        <DatePicker label="Due Date" name="due" selectionMode="single" />
        <button type="submit">Save</button>
      </form>

      {/* Range submission — start/end submit as two `range` entries */}
      <form method="post" action="/report">
        <DatePicker
          label="Reporting Window"
          name="range"
          selectionMode="range"
        />
        <button type="submit">Run</button>
      </form>
    </>
  );
}

CMS Page Builder

This component is available as a datePicker block in the Page Builder (content/pages/*.json):

{
  "type": "datePicker",
  "label": "Check-in Date",
  "selectionMode": "single",
  "placeholder": "YYYY-MM-DD",
  "colorPalette": "blue"
}

Props

PropTypeDescription
labelstringRenders a label associated with the first input (default structure only).
placeholderstringPlaceholder for the input. Defaults to YYYY-MM-DD.

| selectionMode | `"single" \ | "multiple" \ | "range"` | How dates are selected. Defaults to "single". Range mode renders start and end inputs. |

| value | `CalendarDate[] \ | string[] \ | string \ | Date[]` | Selected date(s) (controlled). Strings use the YYYY-MM-DD format. | | defaultValue | `CalendarDate[] \ | string[] \ | string \ | Date[]` | Initial selected date(s) (uncontrolled). |

| focusedValue | `CalendarDate \ | string \ | Date` | The date the calendar panel is focused on (controlled). | | defaultFocusedValue | `CalendarDate \ | string \ | Date` | Initial panel date (uncontrolled). | | min | `CalendarDate \ | string \ | Date` | Earliest selectable date. Earlier days, months, and years are disabled; typed dates outside the range are rejected; keyboard focus is clamped to the range. | | max | `CalendarDate \ | string \ | Date` | Latest selectable date. |

| isDateUnavailable | (date, locale) => boolean | Marks individual dates as unselectable (static/composed usage). |

| view | `"day" \ | "month" \ | "year"` | The active panel view (controlled). |

| open | boolean | Whether the popup calendar is open (controlled). | | closeOnSelect | boolean | Close the popup after a completed selection. Defaults to true. | | showWeekNumbers | boolean | Renders an ISO-8601 week-number column. Defaults to false. | | numOfMonths | number | Reserved for multi-month rendering. Currently a single month panel is always shown; values greater than 1 are accepted but not yet rendered. | | name | string | When set, renders a hidden input per selected date under this name, enabling native <form> submission. In range/multiple mode each selected date becomes a separate entry (ordered). | | locale | string | BCP 47 locale used for the heading, weekday headers, month names, and cell labels (via Intl.DateTimeFormat, with an English fallback). Defaults to en-US. | | disabled | boolean | Disables the whole picker. | | readOnly | boolean | Makes the input read-only. | | invalid | boolean | Marks the input invalid (aria-invalid + error styling). |

| colorPalette | `"blue" \ | "green" \ | "red" \ | "orange" \ | "gray" \ | "cyan" \ | "amber" \ | "purple"` | Accent color for the selected date, today indicator, and range highlight. Defaults to "blue". |

| interactive | boolean | Forces (or suppresses) hydration as an island. | | onValueChange | (details: { value: CalendarDate[] }) => void | Called when the selection changes. | | onOpenChange | (details: { open: boolean }) => void | Called when the popup opens or closes. |

CalendarDate

Dates are represented by the CalendarDate class ({ year, month, day }, month is 1-based) to avoid timezone drift. Helpers are exported alongside the component:

HelperDescription
parseDate(str)Parses a YYYY-MM-DD string, clamping out-of-range parts.
isValidDateString(str)Strictly validates a YYYY-MM-DD string (including month lengths and leap years).
daysInMonth(year, month)Number of days in the given month.
fromJSDate(date)Converts a JavaScript Date to a CalendarDate.
getWeekNumber(date)ISO-8601 week number for a CalendarDate (used by showWeekNumbers).
getWeekDays(locale)Localised weekday names ({ short, narrow, long }, Sunday-first), cached per locale.
getMonthNames(locale, format?)Localised month names ("short" or "long"), January-first, cached per locale.

Production notes

  • No new dependencies. Built entirely on Hono JSX + Panda CSS, consistent with the rest of the design system.
  • SSR-safe. Markup is rendered on the server; only the island branch pulls in client behaviour, and only when signalled.
  • Token-driven. Colours, spacing, radii, and shadows come from the shared theme tokens, so the picker inherits dark mode and the configured colorPalette automatically.
  • Type-safe values. CalendarDate avoids the timezone pitfalls of raw Date/string handling.
  • Bounded by design. min/max are enforced consistently across day, month, and year views — both for mouse selection and keyboard focus — so an out-of-range value can never be committed or focused.
  • Form-ready. The optional name prop renders hidden inputs, so the picker drops into native forms without custom submit handlers.
  • Single-month panel. numOfMonths is accepted for forward compatibility but currently renders a single month. Multi-month rendering is on the roadmap.