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.
Copy → Paste → Ship
Live Preview
Vanilla JS · Zero Dependencies · ARIA GridTwo instances, one component — switched by a single mode: 'range' option. Both share the same keyboard model and ARIA date-grid semantics.
The Prompt
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
Anatomy
Trigger Field
A button with a calendar icon and the formatted value — aria-haspopup="dialog", aria-expanded, and aria-controls wired to the popover.
Month Header
Previous/next navigation with an aria-live month title, and edge buttons that disable when the adjacent month is out of bounds.
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.
Range Band
Start and end days go solid cyan with squared inner corners; in-between days get a continuous translucent band.
Hover Preview
After the start is picked, hovering a day previews the would-be range live before the user commits to an end.
Today Marker
The current day renders bold with a small cyan dot, plus a one-click "Today" shortcut in the footer.
Min / Max Bounds
Out-of-range days carry disabled and aria-disabled so both pointer and keyboard navigation skip them entirely.
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
Frequently Asked Questions
How do I build an accessible date picker without a library?
Render the calendar as a role="grid" of day cells, where each day is a role="gridcell" button. Track a single roving tabindex so only the focused day is in the tab order, and move focus with the arrow keys, Home/End, and PageUp/PageDown. Mirror the visible selection into aria-selected on each cell and announce the month with an aria-live region. The prompt above outputs this full WAI-ARIA date grid pattern in vanilla JavaScript.
What is the difference between a date picker and a date range picker?
A date picker selects one calendar day and closes. A date range picker captures two days — a start and an end — and visually fills the days between them. The component on this page is one class with a mode option: in range mode the first click sets the start, the second click sets the end (auto-swapping if you pick an earlier day second), and hovering before the second click previews the range.
How should the calendar handle keyboard navigation?
Arrow Left/Right move one day; Arrow Up/Down move one week; Home and End jump to the first and last day of the current week; PageUp and PageDown change the month, and Shift+PageUp/PageDown change the year. Enter or Space selects the focused day. Crossing a month boundary with the arrow keys flips the visible month automatically and keeps DOM focus on the newly focused day.
How do I avoid timezone bugs in a JavaScript date picker?
Never parse or serialise calendar days with Date.parse on an ISO string like '2026-06-04', because that is interpreted as UTC midnight and shifts a day backward in negative-offset timezones. Instead build dates with the local constructor new Date(year, monthIndex, day) and serialise by reading getFullYear, getMonth, and getDate directly. The prompt's implementation works entirely in local-date integers so the day a user clicks is the day you store.
How do I disable past dates or restrict to a min and max range?
Pass min and max Date options. Cells outside the bounds render with the disabled attribute and aria-disabled="true" so they are skipped by both pointer and keyboard interaction, and the previous/next month navigation buttons disable when the adjacent month is entirely out of range. This is how you build a check-in calendar that forbids past dates.
Can I use this date picker with React, Vue, or Svelte?
Yes. The output is framework-agnostic vanilla JavaScript exposing a DatePicker class. In React, instantiate inside useEffect with a ref; in Vue, inside onMounted; in Svelte, inside onMount. It dispatches a native change CustomEvent and exposes getValue(), clear(), open(), close(), and destroy(). Call destroy() on unmount to remove the outside-click listener and avoid memory leaks.
Does it work with native form submission?
Yes. The component progressively enhances hidden inputs (one for single date, two for range start/end) holding ISO 8601 yyyy-mm-dd values, so a standard form submit posts the selected dates without extra JavaScript and integrates cleanly with FormData and server-rendered forms.