Skip to main content
astrocraft-ui/ components · 101

Time 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/time-field

Plain-CSS theme — no build step

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

Tailwind theme — needs Tailwind v4

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

Plain-CSS theme, re-pointed onto Lumos tokens

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

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/time-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
`TimeField.astro``time-field` `time-field-literal` `time-field-value``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
——
`TimePicker.astro``time-picker` `time-picker-input` `time-picker-list` `time-picker-option``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
`data-side`: `bottom` · `top` · `left` · `right`
`data-align`: `start` · `center` · `end`
`data-selected``[aria-selected]`

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/time-field/TimeField.astro
---
// src/components/ui/forms/time-field/TimeField.astro — headless primitive (see ../../README.md).
// The same segmented field as DateField, for a time: hour / minute (/ second), plus an AM-PM segment
// where the locale uses one. Digits type with auto-advance, ↑/↓ step and wrap, ←/→ walk the
// segments, and the AM/PM segment also answers to "a" and "p" — read off the localised names, so it
// still works where those are not the words.
//
// The hidden input always submits 24-hour `HH:MM`, whatever the field displays. That is the format
// `<input type="time">` submits and the one every server-side date library parses without being
// told; a field that submitted "3:04 PM" would push the locale problem onto the backend.
//
// Behavior and the typing rule live in `_segments.ts`, shared with DateField and checked by
// `_segments.test.ts`. The clock itself is the locale's unless you override `hour12` — asked of
// `Intl`, not assumed from the language.
import type { HTMLAttributes } from "astro/types";

import {
  dayPeriodNames,
  fieldParts,
  padSegment,
  SEGMENT_LABELS,
  SEGMENT_PLACEHOLDERS,
  segmentRange,
  type SegmentType,
  usesHour12,
} from "../../_segments";
import DateSegment from "../../forms/date-field/DateSegment.astro";

type Props = HTMLAttributes<"div"> & {
  /** 24-hour `HH:MM` (or `HH:MM:SS`), the same format the hidden input submits. */
  value?: string;
  name?: string;
  label?: string;
  locale?: string;
  /** Force a 12- or 24-hour display. Defaults to what the locale does. */
  hour12?: boolean;
  seconds?: boolean;
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
  labels?: Partial<Record<SegmentType, string>>;
  placeholders?: Partial<Record<SegmentType, string>>;
};

const {
  value = "",
  name,
  label = "Time",
  locale,
  hour12 = usesHour12(locale),
  seconds = false,
  size = "md",
  state = "default",
  labels,
  placeholders,
  class: className,
  ...rest
} = Astro.props;

const parsed = /^\d{2}:\d{2}(:\d{2})?$/.test(value) ? value.split(":").map(Number) : [];
const [hour24, minute, second] = parsed;
const period = dayPeriodNames(locale);

// The displayed hour is not the submitted one on a 12-hour clock: midnight is 12 AM, not 0 AM.
const numbers: Partial<Record<SegmentType, number>> = {
  hour: hour24 === undefined ? undefined : hour12 ? hour24 % 12 || 12 : hour24,
  minute,
  second,
  dayPeriod: hour24 === undefined ? undefined : hour24 >= 12 ? 1 : 0,
};

const textFor = (type: SegmentType, n: number) =>
  type === "dayPeriod" ? (n ? period.pm : period.am) : padSegment(type, n, hour12);
---

<div
  role="group"
  aria-label={label}
  data-slot="time-field"
  data-value={value}
  data-hour12={hour12 ? "true" : undefined}
  data-size={size}
  data-state={state}
  class={className}
  {...rest}
>
  {
    fieldParts(locale, "time", { hour12, seconds }).map((part) =>
      part.type === "literal" ? (
        <span data-slot="time-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, { hour12 }).min}
          max={segmentRange(part.type, { hour12 }).max}
          value={numbers[part.type]}
          text={
            numbers[part.type] === undefined ? undefined : textFor(part.type, numbers[part.type]!)
          }
          am={part.type === "dayPeriod" ? period.am : undefined}
          pm={part.type === "dayPeriod" ? period.pm : undefined}
        />
      ),
    )
  }
  {name && <input type="hidden" name={name} value={value} data-slot="time-field-value" />}
</div>

<script>
  import { onReadyOnce } from "../../_once";
  import { type SegmentValues, wireSegmentField } from "../../_segments";

  function wire(root: HTMLElement) {
    const hour12 = root.dataset.hour12 === "true";
    const pad = (n: number) => String(n).padStart(2, "0");

    wireSegmentField(root, {
      hour12,
      compose: ({ hour, minute, second, dayPeriod }: SegmentValues) => {
        // On a 12-hour clock the time is not complete until the day period is set — 3:04 with no
        // AM/PM is two different times, and guessing one of them is how appointments get missed.
        if (hour === undefined || minute === undefined) return "";
        if (hour12 && dayPeriod === undefined) return "";
        const h = hour12 ? (hour % 12) + (dayPeriod ? 12 : 0) : hour;
        return `${pad(h)}:${pad(minute)}${second === undefined ? "" : `:${pad(second)}`}`;
      },
    });
  }

  onReadyOnce('[data-slot="time-field"]', wire);
</script>
src/components/ui/forms/time-field/TimePicker.astro
---
// src/components/ui/forms/time-field/TimePicker.astro — TimeField compound part (see ../../README.md).
// A real <input type="time"> with a list of whole slots beside it — the control for "pick an
// appointment", where typing 14:37 is not a thing anyone wants to do. The INPUT is the value, as in
// DatePicker: it submits, it validates, and on a phone it opens the platform's own time picker.
//
// The options are rendered at build time from `step`, so the list costs no JavaScript to produce and
// the markup is generated nowhere. Keyboard handling is the shared `createActiveDescendant` from
// `_listbox.ts` — the same roving the ComboBox trio uses — because a listbox in a popover has
// exactly the same problem: focus has to stay in one place while the active option moves. The
// committed slot is marked `data-selected`, because `aria-selected` is the rover's — see
// ComboBoxOption, which splits them the same way.
//
// ponytail: a fixed grid of slots between `min` and `max`. Irregular availability (a booking system
// where 09:30 is taken) is the consumer's job — render your own <li role="option"> children into the
// same list, or use TimeField for free entry.
import type { HTMLAttributes } from "astro/types";

import { PopoverContent, PopoverTrigger } from "../../overlays/popover";

type Props = HTMLAttributes<"input"> & {
  /** 24-hour `HH:MM`. */
  value?: string;
  min?: string;
  max?: string;
  /** Minutes between slots. */
  step?: number;
  locale?: string;
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
  side?: "bottom" | "top" | "left" | "right";
  align?: "start" | "center" | "end";
  triggerLabel?: string;
  listLabel?: string;
};

const {
  value,
  min = "00:00",
  max = "23:59",
  step = 30,
  locale,
  size = "md",
  state = "default",
  side = "bottom",
  align = "start",
  triggerLabel = "Choose time",
  listLabel = "Times",
  class: className,
  ...rest
} = Astro.props;

const listId = `time-picker-${crypto.randomUUID().slice(0, 8)}`;
const minutesOf = (time: string) => {
  const [h, m] = time.split(":").map(Number);
  return h * 60 + (m || 0);
};
const format = new Intl.DateTimeFormat(locale, {
  hour: "numeric",
  minute: "2-digit",
  timeZone: "UTC",
});

const slots = [];
for (let m = minutesOf(min); m <= minutesOf(max); m += Math.max(1, step)) {
  const time = `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
  slots.push({ time, label: format.format(Date.UTC(2026, 8, 11, Math.floor(m / 60), m % 60)) });
}
---

<div data-slot="time-picker">
  <input
    type="time"
    value={value}
    min={min}
    max={max}
    data-slot="time-picker-input"
    data-size={size}
    data-state={state}
    class={className}
    {...rest}
  />
  <PopoverTrigger for={listId} 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"
      >
        <circle cx="12" cy="12" r="9"></circle>
        <path d="M12 7v5l3 2"></path>
      </svg></slot
    >
  </PopoverTrigger>
  <PopoverContent id={listId} side={side} align={align} aria-label={triggerLabel}>
    <ul role="listbox" tabindex="0" aria-label={listLabel} data-slot="time-picker-list">
      {
        slots.map((slot, i) => (
          <li
            role="option"
            id={`${listId}-${i}`}
            aria-selected="false"
            data-slot="time-picker-option"
            data-selected={slot.time === value ? "true" : "false"}
            data-value={slot.time}
          >
            {slot.label}
          </li>
        ))
      }
    </ul>
  </PopoverContent>
</div>

<script>
  import "../../_anchor";

  import { createActiveDescendant } from "../../_listbox";
  import { onReadyOnce } from "../../_once";

  function wire(root: HTMLElement) {
    const input = root.querySelector<HTMLInputElement>('[data-slot="time-picker-input"]');
    const panel = root.querySelector<HTMLElement>('[data-slot="popover-content"]');
    const list = root.querySelector<HTMLElement>('[data-slot="time-picker-list"]');
    if (!input || !list) return;
    const options = [...list.querySelectorAll<HTMLElement>('[data-slot="time-picker-option"]')];
    const rover = createActiveDescendant(list, options);

    const commit = (option: HTMLElement) => {
      // `aria-selected` belongs to the rover (it marks the ACTIVE option, per the combobox pattern),
      // so the committed one is marked with `data-selected` — the same split ComboBoxOption uses.
      for (const o of options) o.dataset.selected = String(o === option);
      input.value = option.dataset.value ?? "";
      input.dispatchEvent(new Event("change", { bubbles: true }));
      panel?.hidePopover();
      input.focus();
    };

    // Opening moves focus into the list and lands the active option on the current value, so ↓ from
    // there means "the next slot", not "the first slot of the day".
    panel?.addEventListener("toggle", (event) => {
      if ((event as ToggleEvent).newState !== "open") return;
      rover.setActive(options.find((o) => o.dataset.value === input.value) ?? options[0] ?? null);
      list.focus();
    });

    list.addEventListener("keydown", (event) => {
      switch (event.key) {
        case "ArrowDown":
          event.preventDefault();
          rover.move(1);
          break;
        case "ArrowUp":
          event.preventDefault();
          rover.move(-1);
          break;
        case "Home":
          event.preventDefault();
          rover.setActive(options[0] ?? null);
          break;
        case "End":
          event.preventDefault();
          rover.setActive(options.at(-1) ?? null);
          break;
        case "Enter":
        case " ": {
          const active = rover.active();
          if (!active) return;
          event.preventDefault();
          commit(active);
          break;
        }
      }
    });

    for (const option of options) option.addEventListener("click", () => commit(option));
  }

  onReadyOnce('[data-slot="time-picker"]', wire);
</script>
src/components/ui/forms/time-field/index.ts
import TimeField from "./TimeField.astro";
import TimePicker from "./TimePicker.astro";

export { TimeField, TimePicker };
export default TimeField;

What you get

The component source, copied into your project by npx astrocraft-ui add forms/time-field — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.