Skip to main content
astrocraft-ui/ components · 101

Date Field

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add forms/date-field

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/date-field --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/date-field --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/date-field --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/date-field --theme tailwind --bridge lumos

Live 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.

11092026
09112026
0304PM
1504

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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
---
// 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
>
src/components/ui/forms/date-field/index.ts
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.