Date Picker
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/date-pickerPlain-CSS theme — no build step
npx astrocraft-ui add forms/date-picker --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/date-picker --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/date-picker --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/date-picker --theme tailwind --bridge lumosLive demo
DatePicker & DateRangePicker
A Calendar in a Popover over a real <input type="date">. Type into the field and open the panel — it is already on that month. Pick a day and the field fills, the panel closes and focus returns to the field. In the range picker, picking a day before the start becomes the new start rather than a backwards range, and the days between are markeddata-in-range.
| Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday |
|---|---|---|---|---|---|---|
| Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday |
|---|---|---|---|---|---|---|
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `DatePicker.astro` | `date-picker` `date-picker-input` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` `data-week-start`: `0` · `1` · `2` · `3` · `4` · `5` · `6` `data-side`: `bottom` · `top` · `left` · `right` `data-align`: `start` · `center` · `end` | `data-value` `data-month` | — |
| `DateRangePicker.astro` | `date-range-picker` `date-range-picker-end` `date-range-picker-separator` `date-range-picker-start` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` `data-week-start`: `0` · `1` · `2` · `3` · `4` · `5` · `6` `data-side`: `bottom` · `top` · `left` · `right` `data-align`: `start` · `center` · `end` | `data-value` `data-month` | — |
Source
What the command copies — 3 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/date-picker/DatePicker.astro — headless primitive (see ../../README.md).
// A real <input type="date"> with a Calendar in a Popover beside it. The INPUT is the value: it is
// what carries `name`, what the form submits, what validates, and what a mobile browser opens its
// own native picker for. The calendar is an enhancement over it, not a replacement for it — delete
// the JavaScript and this is still a working date field, which is the whole reason it is built this
// way round rather than as a text box plus a hidden input.
//
// The two are kept in step through the DOM and nothing else: the calendar is driven by writing
// `data-value` on it (see Calendar.astro — it observes that attribute), and it reports back with a
// plain bubbling `change`. No runtime API, no imported controller, no shared state object.
//
// The popover is PopoverTrigger + PopoverContent rather than a private panel of its own, so every
// theme rule already written for Popover applies here — placement, flipping, light dismiss and
// Escape all come from `_anchor` and the platform. The script below imports `_anchor` directly
// (a side-effect ES singleton) because the `<Popover>` wrapper element is what usually pulls it in,
// and this primitive has its own root.
import type { HTMLAttributes } from "astro/types";
import Calendar from "../../forms/calendar";
import { PopoverContent, PopoverTrigger } from "../../overlays/popover";
type Props = HTMLAttributes<"input"> & {
/** The selected date, `YYYY-MM-DD` — the same format `<input type="date">` speaks. */
value?: string;
min?: string;
max?: string;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
locale?: string;
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
side?: "bottom" | "top" | "left" | "right";
align?: "start" | "center" | "end";
/** Accessible name for the button that opens the calendar. */
triggerLabel?: string;
};
const {
value,
min,
max,
size = "md",
state = "default",
locale,
weekStart = 1,
side = "bottom",
align = "start",
triggerLabel = "Choose date",
class: className,
...rest
} = Astro.props;
const panelId = `date-picker-${crypto.randomUUID().slice(0, 8)}`;
---
<div data-slot="date-picker">
<input
type="date"
value={value}
min={min}
max={max}
data-slot="date-picker-input"
data-size={size}
data-state={state}
class={className}
{...rest}
/>
<PopoverTrigger for={panelId} aria-label={triggerLabel} size={size}>
<slot name="icon"
><svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M8 2v4M16 2v4M3 10h18"></path>
<rect x="3" y="4" width="18" height="18" rx="2"></rect>
</svg></slot
>
</PopoverTrigger>
<PopoverContent id={panelId} side={side} align={align} aria-label={triggerLabel}>
<Calendar value={value} min={min} max={max} locale={locale} weekStart={weekStart} />
</PopoverContent>
</div>
<script>
import "../../_anchor";
import { onReadyOnce } from "../../_once";
function wire(root: HTMLElement) {
const input = root.querySelector<HTMLInputElement>('[data-slot="date-picker-input"]');
const calendar = root.querySelector<HTMLElement>('[data-slot="calendar"]');
const panel = root.querySelector<HTMLElement>('[data-slot="popover-content"]');
if (!input || !calendar) return;
// Field → calendar. Typing a date (or the OS picker) moves the grid to that month, so opening
// the popover never shows a month unrelated to what the field says.
const toCalendar = () => {
calendar.dataset.value = input.value;
if (input.value) calendar.dataset.month = input.value;
};
input.addEventListener("change", toCalendar);
// Calendar → field. `change` on the input is what form libraries listen for, and dispatching it
// ourselves is the price of writing `.value` from script — the platform only fires it for a user.
calendar.addEventListener("change", () => {
const value = calendar.dataset.value ?? "";
if (value === input.value) return;
input.value = value;
input.dispatchEvent(new Event("change", { bubbles: true }));
// Close on choose: a date picker whose panel stays open after a pick hides the field it just
// filled. Focus goes back to the field rather than the trigger, because that is where the
// value now is and where a keyboard user expects to continue.
panel?.hidePopover();
input.focus();
});
toCalendar();
}
onReadyOnce('[data-slot="date-picker"]', wire);
</script>
---
// src/components/ui/forms/date-picker/DateRangePicker.astro — DatePicker compound part (see ../../README.md).
// Two real <input type="date">s — a start and an end — sharing one Calendar. Same bargain as
// DatePicker: the inputs are the value, they submit and validate on their own, and the calendar is
// an enhancement over them.
//
// WHY THE RANGE LOGIC IS HERE AND NOT IN CALENDAR. Calendar holds one date, deliberately. Teaching
// it about two would put a second, optional mode in the hardest keyboard code in the library, for a
// feature only this file needs. Instead this script DECORATES the cells Calendar renders —
// `data-in-range` on everything between the ends, `data-range-start` / `data-range-end` on them —
// which is the same "state is a data attribute" contract everything else here follows, and leaves a
// theme one obvious hook. Paging the calendar re-fills those cells, so a MutationObserver re-runs
// the decoration after each render.
//
// Picking alternates: the first click sets the start, the next the end, and a pick before the start
// becomes the new start rather than an invalid backwards range. Focusing either field aims the next
// pick at that field, so a wrong end can be corrected without starting over.
import type { HTMLAttributes } from "astro/types";
import Calendar from "../../forms/calendar";
import { PopoverContent, PopoverTrigger } from "../../overlays/popover";
type Props = HTMLAttributes<"div"> & {
start?: string;
end?: string;
min?: string;
max?: string;
/** `name` for the two fields: `${name}-start` and `${name}-end`. */
name?: string;
startLabel?: string;
endLabel?: string;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
locale?: string;
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
side?: "bottom" | "top" | "left" | "right";
align?: "start" | "center" | "end";
triggerLabel?: string;
};
const {
start,
end,
min,
max,
name,
startLabel = "Start date",
endLabel = "End date",
size = "md",
state = "default",
locale,
weekStart = 1,
side = "bottom",
align = "start",
triggerLabel = "Choose dates",
class: className,
...rest
} = Astro.props;
const panelId = `date-range-${crypto.randomUUID().slice(0, 8)}`;
---
<div class={className} data-slot="date-range-picker" {...rest}>
<input
type="date"
value={start}
min={min}
max={max}
name={name && `${name}-start`}
aria-label={startLabel}
data-slot="date-range-picker-start"
data-size={size}
data-state={state}
/>
<span data-slot="date-range-picker-separator" aria-hidden="true">–</span>
<input
type="date"
value={end}
min={start ?? min}
max={max}
name={name && `${name}-end`}
aria-label={endLabel}
data-slot="date-range-picker-end"
data-size={size}
data-state={state}
/>
<PopoverTrigger for={panelId} aria-label={triggerLabel} size={size}>
<slot name="icon"
><svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M8 2v4M16 2v4M3 10h18"></path>
<rect x="3" y="4" width="18" height="18" rx="2"></rect>
</svg></slot
>
</PopoverTrigger>
<PopoverContent id={panelId} side={side} align={align} aria-label={triggerLabel}>
<Calendar value={start} min={min} max={max} locale={locale} weekStart={weekStart} />
</PopoverContent>
</div>
<script>
import "../../_anchor";
import { onReadyOnce } from "../../_once";
import { parseISO } from "../calendar/calendar-grid";
function wire(root: HTMLElement) {
const startField = root.querySelector<HTMLInputElement>(
'[data-slot="date-range-picker-start"]',
);
const endField = root.querySelector<HTMLInputElement>('[data-slot="date-range-picker-end"]');
const calendar = root.querySelector<HTMLElement>('[data-slot="calendar"]');
const grid = root.querySelector<HTMLElement>('[data-slot="calendar-grid"]');
if (!startField || !endField || !calendar || !grid) return;
const cells = [...grid.querySelectorAll<HTMLElement>('[data-slot="calendar-day"]')];
/** Which field the next pick fills. Focusing a field aims at it; picking moves it along. */
let filling: "start" | "end" = startField.value && !endField.value ? "end" : "start";
startField.addEventListener("focus", () => (filling = "start"));
endField.addEventListener("focus", () => (filling = "end"));
const flag = (el: HTMLElement, key: "inRange" | "rangeStart" | "rangeEnd", on: boolean) => {
if (on) el.dataset[key] = "true";
else delete el.dataset[key];
};
/** Mark the cells Calendar just rendered. Cheap, and the only way a range can be seen at all. */
const decorate = () => {
const from = parseISO(startField.value);
const to = parseISO(endField.value);
for (const cell of cells) {
const day = parseISO(cell.dataset.date ?? "");
flag(cell, "rangeStart", day === from);
flag(cell, "rangeEnd", day === to);
flag(cell, "inRange", !Number.isNaN(from) && !Number.isNaN(to) && day > from && day < to);
}
};
const commit = (field: HTMLInputElement, value: string) => {
field.value = value;
field.dispatchEvent(new Event("change", { bubbles: true }));
};
calendar.addEventListener("change", () => {
const picked = calendar.dataset.value ?? "";
if (!picked) return;
const from = parseISO(startField.value);
// A pick before the start is a new start, not a backwards range — the only reading that does
// not silently produce an end the form would reject.
if (filling === "start" || Number.isNaN(from) || parseISO(picked) < from) {
commit(startField, picked);
if (parseISO(endField.value) < parseISO(picked)) commit(endField, "");
endField.min = picked; // the field itself now refuses an end before the start
filling = "end";
} else {
commit(endField, picked);
filling = "start";
}
decorate();
});
for (const field of [startField, endField]) {
field.addEventListener("change", () => {
if (field === startField) {
endField.min = field.value || "";
calendar.dataset.value = field.value;
if (field.value) calendar.dataset.month = field.value;
}
decorate();
});
}
// Calendar re-fills its 42 cells when you page, wiping the decoration with it. Watching the
// cells' own `data-date` is the narrowest signal that a render happened; the microtask guard
// collapses all 42 attribute changes of one render into a single pass.
let queued = false;
new MutationObserver(() => {
if (queued) return;
queued = true;
queueMicrotask(() => {
queued = false;
decorate();
});
}).observe(grid, { subtree: true, attributeFilter: ["data-date"] });
decorate();
}
onReadyOnce('[data-slot="date-range-picker"]', wire);
</script>
import DatePicker from "./DatePicker.astro";
import DateRangePicker from "./DateRangePicker.astro";
export { DatePicker, DateRangePicker };
export default DatePicker;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/date-picker — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.