Datepicker
Try in playground →opens this recipe’s markup and SCSS, live-compiled
Live: click the field to open the calendar, navigate months, pick a date. Click outside or press Escape to close. No date library — native Date and Intl.DateTimeFormat only.
Use this when
You need a date field with a visual calendar, not just typed digits —
booking a date, filtering a report by range, scheduling. Start with native
<input type="date"> when the browser's own picker UI is good enough (it's
zero-JS, zero-recipe, keyboard- and screen-reader-accessible out of the
box, and its formatting follows the user's OS locale automatically). Reach
for this recipe when you need a styled calendar that matches your
theme, or a UI the native picker doesn't offer (e.g. a visible month grid
inline rather than a browser-chrome popup).
Structure (raw HTML)
<div class="my-datepicker">
<label for="my-date-trigger" data-slot="label">Appointment date</label>
<button
id="my-date-trigger"
type="button"
data-slot="trigger"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="my-date-grid"
>
Select a date
</button>
<div id="my-date-panel" data-slot="panel" hidden>
<div data-slot="header">
<button type="button" data-slot="prev" aria-label="Previous month">‹</button>
<span data-slot="month-label" aria-live="polite">September 2026</span>
<button type="button" data-slot="next" aria-label="Next month">›</button>
</div>
<div id="my-date-grid" data-slot="grid" role="grid" aria-labelledby="my-date-trigger">
<div role="row" data-slot="weekdays">
<span role="columnheader" aria-label="Sunday">S</span>
<span role="columnheader" aria-label="Monday">M</span>
<!-- …Tue–Sat… -->
</div>
<div role="row">
<button type="button" role="gridcell" tabindex="-1" data-outside-month>31</button>
<button type="button" role="gridcell" tabindex="0" aria-selected="false">1</button>
<!-- …one button per day… -->
</div>
</div>
</div>
</div>
Notes on the markup:
- The trigger is a
<button>, not a text input — it displays the formatted selection but the user never types a date string by hand (avoids the whole "what format did they mean" parsing problem). If you need a typeable field too, pair this recipe with native<input type="date">as a fallback path rather than parsing free text. aria-haspopup="dialog"+aria-expandedon the trigger,role="grid"on the day grid — this is the WAI-ARIA Date Picker Dialog pattern.data-outside-monthmarks a leading/trailing day from the adjacent month (filling the grid to full weeks) — usually dimmed, still clickable.- Only the currently-focusable day gets
tabindex="0"; every other day istabindex="-1"— the grid uses roving tabindex, not one tab-stop per cell.
Styling (cia mixins)
@use 'css-is-awesome/api' as cia;
.my-datepicker {
position: relative;
display: inline-block;
[data-slot="label"] {
display: block;
margin-block-end: cia.space(1);
}
[data-slot="trigger"] {
@include cia.input-base;
@include cia.button-reset;
text-align: start;
cursor: pointer;
}
[data-slot="panel"] {
@include cia.popover-base($p: 3, $max-width: 20rem);
position: absolute;
inset-block-start: calc(100% + #{cia.space(1)});
inset-inline-start: 0;
}
[data-slot="header"] {
display: flex;
align-items: center;
justify-content: space-between;
margin-block-end: cia.space(2);
button {
@include cia.btn(ghost);
}
}
[data-slot="grid"] {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: cia.space(1);
}
[data-slot="weekdays"] {
display: contents;
span {
text-align: center;
font-size: 0.75rem;
color: var(--text-muted);
}
}
[role="gridcell"] {
@include cia.button-reset;
aspect-ratio: 1;
border-radius: var(--radius-full);
cursor: pointer;
@include cia.transition(background-color, color);
&:hover {
background: var(--interactive-hover);
}
&[data-outside-month] {
color: var(--text-muted);
}
&[aria-selected="true"] {
background: var(--action-primary-default);
color: var(--text-inverse);
}
}
}
popover-base is the same mixin the combobox recipe
already uses — the popup positioning here is the identical position: relative wrapper + position: absolute panel pattern combobox uses for
its listbox, not a new mechanism.
Interactivity
function buildMonth(year, month) {
// Native Date only — no date library. First day of the grid is the
// Sunday on/before the 1st; last is the Saturday on/after the last day.
const first = new Date(year, month, 1);
const start = new Date(first);
start.setDate(first.getDate() - first.getDay());
const days = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
days.push(d);
}
return days; // always 6 full weeks — a stable grid size, no reflow
}
const monthLabelFmt = new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" });
const dayFmt = new Intl.DateTimeFormat(undefined, { day: "numeric" });
- Native: the trigger button, the popup's open/close (a plain
hiddentoggle is enough;[popover]works too if you want light- dismiss and top-layer stacking for free), all date math viaDate. - JS: grid generation (above), roving-tabindex arrow-key navigation within the grid (Left/Right move a day, Up/Down move a week, Home/End jump to week start/end, PageUp/PageDown change month), and closing + returning focus to the trigger on Escape or a selection.
- Locale, for free:
Intl.DateTimeFormatreads the browser's locale automatically — the month name, and which day starts the week if you extend this to respectIntl.Locale().weekInfo, need no per-locale code in this recipe. Deeper locale/formatting concerns (a specific display format, RTL calendars) belong to the i18n-recipes epic, not repeated here. - Edge cases: build the 42-cell grid deterministically (always 6 weeks) so the popup never resizes as you navigate months — a resizing popup shifts focus unpredictably for a screen-reader or keyboard user mid-navigation.
A11y checklist
- The day grid is
role="grid"withrole="row"/role="gridcell"— not a bare<div>soup — so a screen reader announces it as a navigable table structure (WAI-ARIA APG: Date Picker Dialog) - Roving
tabindex(onetabindex="0", everything else-1") — a keyboard user tabs into the grid once, then uses arrow keys, not 42 individual tab stops (WAI-ARIA APG: Grid pattern) - The month label is
aria-live="polite"so navigating months is announced without moving focus (WCAG 2.2 SC 4.1.3 Status Messages) - Closing the popup (Escape, or a date selection) returns focus to the trigger button — never leaves focus inside a now-hidden panel (WCAG 2.2 SC 2.4.3 Focus Order)
Framework examples
React
"use client";
import { useState } from "react";
const monthFmt = new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" });
function buildMonth(year: number, month: number): Date[] {
const first = new Date(year, month, 1);
const start = new Date(first);
start.setDate(first.getDate() - first.getDay());
return Array.from({ length: 42 }, (_, i) => {
const d = new Date(start);
d.setDate(start.getDate() + i);
return d;
});
}
export function DatePicker({ onSelect }: { onSelect: (d: Date) => void }) {
const [open, setOpen] = useState(false);
const [cursor, setCursor] = useState(() => new Date());
const [selected, setSelected] = useState<Date | null>(null);
const days = buildMonth(cursor.getFullYear(), cursor.getMonth());
return (
<div className="my-datepicker">
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
{selected ? selected.toLocaleDateString() : "Select a date"}
</button>
{open && (
<div role="dialog" aria-label="Choose date">
<div>
<button type="button" aria-label="Previous month" onClick={() => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1))}>‹</button>
<span aria-live="polite">{monthFmt.format(cursor)}</span>
<button type="button" aria-label="Next month" onClick={() => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1))}>›</button>
</div>
<div role="grid">
{days.map((d) => (
<button
type="button"
key={d.toISOString()}
role="gridcell"
data-outside-month={d.getMonth() !== cursor.getMonth() || undefined}
aria-selected={selected?.toDateString() === d.toDateString()}
onClick={() => {
setSelected(d);
onSelect(d);
setOpen(false);
}}
>
{d.getDate()}
</button>
))}
</div>
</div>
)}
</div>
);
}
Vue
<template>
<div class="my-datepicker">
<button type="button" :aria-expanded="open" @click="open = !open">
{{ selected ? selected.toLocaleDateString() : "Select a date" }}
</button>
<div v-if="open" role="dialog" aria-label="Choose date">
<div>
<button type="button" @click="shiftMonth(-1)">‹</button>
<span aria-live="polite">{{ monthFmt.format(cursor) }}</span>
<button type="button" @click="shiftMonth(1)">›</button>
</div>
<div role="grid">
<button
v-for="d in days"
:key="d.toISOString()"
type="button"
role="gridcell"
@click="select(d)"
>
{{ d.getDate() }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
const monthFmt = new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" });
const open = ref(false);
const cursor = ref(new Date());
const selected = ref<Date | null>(null);
const emit = defineEmits<{ select: [Date] }>();
function buildMonth(year: number, month: number) {
const first = new Date(year, month, 1);
const start = new Date(first);
start.setDate(first.getDate() - first.getDay());
return Array.from({ length: 42 }, (_, i) => {
const d = new Date(start);
d.setDate(start.getDate() + i);
return d;
});
}
const days = computed(() => buildMonth(cursor.value.getFullYear(), cursor.value.getMonth()));
function shiftMonth(delta: number) {
cursor.value = new Date(cursor.value.getFullYear(), cursor.value.getMonth() + delta, 1);
}
function select(d: Date) {
selected.value = d;
emit("select", d);
open.value = false;
}
</script>
Svelte
<script lang="ts">
const monthFmt = new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" });
let open = false;
let cursor = new Date();
let selected: Date | null = null;
export let onSelect: (d: Date) => void;
function buildMonth(year: number, month: number) {
const first = new Date(year, month, 1);
const start = new Date(first);
start.setDate(first.getDate() - first.getDay());
return Array.from({ length: 42 }, (_, i) => {
const d = new Date(start);
d.setDate(start.getDate() + i);
return d;
});
}
$: days = buildMonth(cursor.getFullYear(), cursor.getMonth());
function select(d: Date) {
selected = d;
onSelect(d);
open = false;
}
</script>
<div class="my-datepicker">
<button type="button" aria-expanded={open} on:click={() => (open = !open)}>
{selected ? selected.toLocaleDateString() : "Select a date"}
</button>
{#if open}
<div role="dialog" aria-label="Choose date">
<span aria-live="polite">{monthFmt.format(cursor)}</span>
<div role="grid">
{#each days as d (d.toISOString())}
<button type="button" role="gridcell" on:click={() => select(d)}>{d.getDate()}</button>
{/each}
</div>
</div>
{/if}
</div>
Vanilla
<script type="module">
const trigger = document.querySelector('[data-slot="trigger"]');
const panel = document.querySelector('[data-slot="panel"]');
trigger.addEventListener("click", () => {
const willOpen = panel.hidden;
panel.hidden = !willOpen;
trigger.setAttribute("aria-expanded", String(willOpen));
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !panel.hidden) {
panel.hidden = true;
trigger.setAttribute("aria-expanded", "false");
trigger.focus();
}
});
// See "Interactivity" above for buildMonth() + the grid-render/arrow-key
// navigation that fills [data-slot="grid"] — omitted here for length.
</script>
Pitfalls
- Don't parse a typed date string. "03/04/2026" is ambiguous (US vs. rest-of-world day/month order) — that ambiguity is exactly why this recipe's trigger is a button with a calendar, not a free-text field.
- A variable-height popup shifts layout as you navigate months (some months render 5 weeks, some 6). Always build the deterministic 42-cell grid above, even for months that only need 5 weeks — the extra leading/trailing days keep the panel's height constant.
- Timezones.
new Date(year, month, day)constructs in the local timezone — safe for a UI calendar. If the selected date needs to travel to a server, serialize it explicitly (e.g.toISOString().slice(0, 10)) rather than passing theDateobject across a JSON boundary, where it silently becomes a timezone-shifted string.