Date Field
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-fieldPlain-CSS theme — no build step
npx astrocraft-ui add forms/date-field --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/date-field --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/date-field --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/date-field --theme tailwind --bridge lumosLive demo
DateField, TimeField & TimePicker
Segmented fields: each part is a spinbutton with its own label. Type 4 in the day and it commits and moves on; type 1 and it waits, because the 11th–19th are still reachable. ↑/↓ step and wrap, ←/→ walk,Backspace clears. The two date fields differ only in locale — and therefore in the ORDER of their segments, which comes from Intl, not from us.
- 8:00 AM
- 8:30 AM
- 9:00 AM
- 9:30 AM
- 10:00 AM
- 10:30 AM
- 11:00 AM
- 11:30 AM
- 12:00 PM
- 12:30 PM
- 1:00 PM
- 1:30 PM
- 2:00 PM
- 2:30 PM
- 3:00 PM
- 3:30 PM
- 4:00 PM
- 4:30 PM
- 5:00 PM
- 5:30 PM
- 6:00 PM
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 |
|---|---|---|---|---|
| `DateField.astro` | `date-field` `date-field-literal` `date-field-value` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | — | — |
| `DateSegment.astro` | `date-segment` | — | — | — |
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-field/DateField.astro — headless primitive (see ../../README.md).
// A typeable date: separate day / month / year spinbuttons in the order the LOCALE writes them, with
// the locale's own separators between. Type digits and it advances by itself, ↑/↓ step and wrap,
// ←/→ walk the segments, Backspace clears one. The composed ISO date lands in a hidden input, so the
// field submits like any other.
//
// WHY IT EXISTS, given `<input type="date">` is right there: that control is excellent on desktop
// Chrome and Safari, and on other platforms it is a text box, a differently-ordered text box, or a
// control with no keyboard entry at all. DatePicker uses the native input on purpose and this does
// not — reach for this when you need the same typing behavior everywhere, and for DatePicker when
// you want the platform's.
//
// The order and the separators come from `Intl.DateTimeFormat.formatToParts`, never a pattern string
// of ours: en-GB writes 11/09/2026 and en-US writes 09/11/2026, and guessing is wrong in most of the
// world. The behavior is in `_segments.ts`, shared with TimeField and checked by `_segments.test.ts`.
//
// ponytail: Gregorian calendar only, and no era segment — `formatToParts` would report one for a
// Japanese or Buddhist calendar and this drops it. Use `<input type="date">` there.
import type { HTMLAttributes } from "astro/types";
import {
fieldParts,
padSegment,
SEGMENT_LABELS,
SEGMENT_PLACEHOLDERS,
segmentRange,
type SegmentType,
} from "../../_segments";
import DateSegment from "./DateSegment.astro";
type Props = HTMLAttributes<"div"> & {
/** `YYYY-MM-DD`, the same format the hidden input submits. */
value?: string;
name?: string;
/** Names the group, so the three spinbuttons are announced as one field. */
label?: string;
locale?: string;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
/** Accessible names for the segments, for a field in another language. */
labels?: Partial<Record<SegmentType, string>>;
placeholders?: Partial<Record<SegmentType, string>>;
};
const {
value = "",
name,
label = "Date",
locale,
size = "md",
state = "default",
labels,
placeholders,
class: className,
...rest
} = Astro.props;
// A malformed value renders as an empty field rather than throwing: `value` often arrives from a
// database or a query string, and an unparseable one is missing data, not a crash.
const [year, month, day] = /^\d{4}-\d{2}-\d{2}$/.test(value)
? value.split("-").map(Number)
: [undefined, undefined, undefined];
const numbers: Partial<Record<SegmentType, number>> = { year, month, day };
---
<div
role="group"
aria-label={label}
data-slot="date-field"
data-value={value}
data-size={size}
data-state={state}
class={className}
{...rest}
>
{
fieldParts(locale, "date").map((part) =>
part.type === "literal" ? (
<span data-slot="date-field-literal" aria-hidden="true">
{part.value}
</span>
) : (
<DateSegment
segment={part.type}
label={labels?.[part.type] ?? SEGMENT_LABELS[part.type]}
placeholder={placeholders?.[part.type] ?? SEGMENT_PLACEHOLDERS[part.type]}
min={segmentRange(part.type).min}
max={segmentRange(part.type, { year, month }).max}
value={numbers[part.type]}
text={
numbers[part.type] === undefined
? undefined
: padSegment(part.type, numbers[part.type]!)
}
/>
),
)
}
{name && <input type="hidden" name={name} value={value} data-slot="date-field-value" />}
</div>
<script>
import { onReadyOnce } from "../../_once";
import { type SegmentValues, wireSegmentField } from "../../_segments";
function wire(root: HTMLElement) {
wireSegmentField(root, {
// An incomplete date submits nothing at all. A partial ISO string would be worse than empty:
// it looks like a value to every validator it passes through.
compose: ({ year, month, day }: SegmentValues) =>
year && month && day
? `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`
: "",
});
}
onReadyOnce('[data-slot="date-field"]', wire);
</script>
---
// src/components/ui/forms/date-field/DateSegment.astro — DateField compound part (see ../../README.md).
// One segment of a segmented field — a day, a month, an hour, an AM/PM — as a `role="spinbutton"`.
// TimeField renders these too; the slot name stays `date-segment` because it is one control type,
// and a theme should not have to style the same thing twice.
//
// `role="spinbutton"` is the whole reason this is a <span> and not an <input>. It announces as
// "Day, 11, spin button", tells assistive tech the range it accepts, and reports each change through
// `aria-valuenow` / `aria-valuetext` — while four separate number inputs would announce as four
// unrelated fields and let a browser's autofill treat a year as a quantity.
//
// `aria-valuetext` carries the PADDED text ("09"), because `aria-valuenow` is a number and a screen
// reader saying "nine" for a month is a worse reading than "oh nine" in a date. An empty segment has
// neither attribute — that is what "no value yet" looks like in ARIA — and shows its placeholder.
import type { HTMLAttributes } from "astro/types";
import type { SegmentType } from "../../_segments";
type Props = HTMLAttributes<"span"> & {
segment: SegmentType;
/** Accessible name — "Day", "Month". Pass a translated one for a non-English field. */
label: string;
/** Shown while the segment is empty, e.g. `dd`. */
placeholder: string;
min: number;
max: number;
/** The number the segment holds; omit for an empty segment. */
value?: number;
/** The text to show and announce for `value` — padded, or the AM/PM name. */
text?: string;
/** For a day-period segment: the localised names its initials answer to. */
am?: string;
pm?: string;
};
const {
segment,
label,
placeholder,
min,
max,
value,
text,
am,
pm,
class: className,
...rest
} = Astro.props;
---
<span
role="spinbutton"
tabindex="0"
inputmode="numeric"
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={value === undefined ? undefined : text}
data-slot="date-segment"
data-segment={segment}
data-placeholder={placeholder}
data-am={am}
data-pm={pm}
class={className}
{...rest}>{value === undefined ? placeholder : text}</span
>
import DateField from "./DateField.astro";
import DateSegment from "./DateSegment.astro";
export { DateField, DateSegment };
export default DateField;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/date-field — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.