Design.dev design.dev
Forms · Component Template

Date Picker & Date Range Picker

An accessible calendar for single dates or start-to-end ranges, with min/max bounds, hover preview, and full ARIA keyboard support.

View prompt

Live demo

Vanilla JS · Zero Dependencies · ARIA Grid

Two instances, one component — switched by a single mode: 'range' option. Both share the same keyboard model and ARIA date-grid semantics.

The Prompt 945 words

Build a date picker / date range picker component using vanilla HTML, CSS, and JavaScript. No libraries, no frameworks — zero dependencies. ONE class must support BOTH a single-date mode and a start-to-end range mode.

VISUAL DESIGN
- Field button: full-width, 44px min-height, border-radius 10px, 1px border at rgba(255,255,255,0.1), background rgba(255,255,255,0.04). A calendar icon on the left, the formatted value (or placeholder) in the middle, and a small clear (×) icon on the right when a value is set
- Field states: hover brightens border and background; open state and :focus-visible add a cyan border (#00c4ff), pale cyan background tint, and a 4px cyan glow ring
- Popover: position absolute below the field, 6px gap, ~296px wide, background rgba(22,22,26,0.98) with backdrop-blur, 1px border at rgba(255,255,255,0.1), border-radius 14px, heavy box-shadow (0 20px 60px rgba(0,0,0,0.5)), 12px padding
- Header: previous-month button, the month + year title centered, next-month button. Nav buttons are 30px squares with cyan hover; they disable when the adjacent month falls entirely outside the min/max bounds
- Weekday header row: Mo Tu We Th Fr Sa Su (Monday-first), 10px uppercase at rgba(255,255,255,0.35)
- Day grid: 7-column CSS grid, square cells (aspect-ratio 1/1), 8px border-radius, 12px font
- Today's cell: bold with a small cyan dot beneath the number
- Selected day (single mode): solid cyan background (#00c4ff), dark text
- Range mode: the start and end days are solid cyan with squared-off inner corners; days between them get a translucent cyan band (rgba(0,196,255,0.14)) with no corner radius so the band reads as continuous
- Footer: a "Today" shortcut and a "Clear" button, each full-width-ish with cyan hover

ENTER ANIMATION
- Popover scales from 0.98 + translateY(-4px) to 1.0 over 160ms cubic-bezier(0.16, 1, 0.3, 1)
- prefers-reduced-motion: skip all animations and transitions

DATE MODEL (CRITICAL — avoid timezone bugs)
- Work entirely in LOCAL date integers. Build days with new Date(year, monthIndex, day); NEVER use Date.parse / new Date('2026-06-04') for calendar days, because that parses as UTC midnight and shifts the day in negative-offset timezones
- Serialise values by reading getFullYear()/getMonth()/getDate() and formatting as ISO 8601 yyyy-mm-dd strings
- Compare days with a sameDay(a, b) helper that checks year+month+date, not timestamps
- Weeks are Monday-first: compute the leading blank count as (firstOfMonth.getDay() + 6) % 7

KEYBOARD CONTRACT (WAI-ARIA date grid)
- The grid is a roving-tabindex widget: exactly one day cell has tabindex="0" (the focused day); all others have tabindex="-1"
- ArrowLeft / ArrowRight: move focus one day (crossing month boundaries flips the visible month)
- ArrowUp / ArrowDown: move focus one week back / forward
- Home / End: focus the first / last day of the current week
- PageUp / PageDown: previous / next month (preserving the focused day-of-month, clamped to the month length)
- Shift+PageUp / Shift+PageDown: previous / next year
- Enter / Space: select the focused day
- Escape: close the popover and return focus to the field, without changing the selection
- Clamp any keyboard move to the min/max bounds so focus never lands on a disabled day

RANGE SELECTION LOGIC
- First selection sets the start and keeps the popover OPEN, awaiting the end
- While awaiting the end, hovering a day previews the range (start → hovered day) live in the grid
- Second selection sets the end and closes; if the second day is BEFORE the start, swap them so start <= end
- Clicking Clear (or calling clear()) resets both endpoints

ARIA SEMANTICS
- Field button: aria-haspopup="dialog", aria-expanded reflects open state, aria-controls points to the popover id
- Popover: role="dialog", aria-modal="false", aria-label="Choose date"
- Month title: aria-live="polite" so screen readers announce month changes during navigation
- Grid: role="grid" labelled by the month title; each day is role="gridcell" as a <button> with an aria-label like "Wednesday, June 4, 2026" (append "(Today)" for today), aria-selected reflecting selection, and disabled + aria-disabled="true" for out-of-bounds days

BOUNDS / DISABLING
- Optional min and max Date options
- Out-of-range days render disabled (skipped by pointer and keyboard); prev/next nav buttons disable at the edges
- Optional disabledDates predicate (e.g. weekends, holidays) the same way

INTERACTION DETAILS
- Click outside the component root (pointerdown): close the popover, do not change selection
- Clicking the field toggles the popover; opening anchors the view month to the current selection (or today), clamped to bounds
- On open, move focus to the active day cell so keyboard users land somewhere useful

API (the class)
- new DatePicker(rootElement, {
    mode: 'single' | 'range',   // default 'single'
    min: Date, max: Date,       // optional bounds
    initial: Date,              // single mode
    initialStart: Date, initialEnd: Date, // range mode
    placeholder: 'Select a date',
    onChange: (value) => {}     // single: { date: 'yyyy-mm-dd' | null }; range: { start, end }
  })
- Public methods: getValue(), clear(), open(), close(), destroy()
- Dispatches a 'change' CustomEvent on the root element with { detail: value }
- Progressively enhance hidden input(s) holding the ISO value(s) for native form submission (one input for single, two for range)

ACCESSIBILITY
- Roving tabindex + aria-activedescendant-free focus management (real DOM focus on the active cell)
- prefers-reduced-motion: disable popover animation and all transitions
- Maintain a 4.5:1 contrast ratio for day numbers; the cyan-on-dark selected state must stay legible
- Touch targets: day cells and nav buttons are at least 34px

OUTPUT
- Single HTML file with embedded <style> and <script>
- TWO live demos in the file: one single-date picker, one date-range picker with past dates disabled (min = today)
- Both must share the same DatePicker class — only the mode (and bounds) differ
- Polished, professional aesthetic — should feel like the calendars in a premium modern booking or analytics dashboard
  1. CopyThe full prompt, one click.
  2. PasteInto Cursor, Claude Code, or any agent.
  3. CompareAgainst the live demo above.

What’s Inside

01

Trigger Field

A button with a calendar icon and the formatted value — aria-haspopup="dialog", aria-expanded, and aria-controls wired to the popover.

02

Month Header

Previous/next navigation with an aria-live month title, and edge buttons that disable when the adjacent month is out of bounds.

03

ARIA Date Grid

A role="grid" of role="gridcell" day buttons with a single roving tabindex so only the focused day sits in the tab order.

04

Range Band

Start and end days go solid cyan with squared inner corners; in-between days get a continuous translucent band.

05

Hover Preview

After the start is picked, hovering a day previews the would-be range live before the user commits to an end.

06

Today Marker

The current day renders bold with a small cyan dot, plus a one-click "Today" shortcut in the footer.

07

Min / Max Bounds

Out-of-range days carry disabled and aria-disabled so both pointer and keyboard navigation skip them entirely.

08

Local-Date Model

Days are built and serialised in local time (never UTC-parsed), so the day a user clicks is exactly the day you store.

Usage Guidelines

Use This When

  • You need a booking, scheduling, or reporting flow where users pick an exact day — or a start-to-end range like check-in/check-out or a report period
  • The native <input type="date"> is too restrictive: you want range selection, disabled dates, a custom look, or consistent rendering across browsers
  • Accessibility matters — you need full keyboard navigation and screen-reader support out of the box

Not Ideal When

  • A plain <input type="date"> covers your need — on mobile its native wheel picker is often the better experience
  • You only need a month or year (use a simpler month/year select) or a recurring schedule (use a dedicated rule builder)
  • You need full localization, alternate calendars, or time-of-day selection — extend the model first or reach for a maintained i18n library
Date Picker & Date Range Picker
Copied to clipboard