Time 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/time-fieldPlain-CSS theme — no build step
npx astrocraft-ui add forms/time-field --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/time-field --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/time-field --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/time-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 |
|---|---|---|---|---|
| `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 — 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 — 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>
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.