component

Input Datetime

A segmented date / time input. Each unit — year, month, day, hour, minute, AM/PM — is its own focusable segment; separators are static, and the container shows a single focus ring via :focus-within. Type digits to fill a segment (it auto-advances when no further digit fits), use ↑/↓ to step a value (stepping an empty segment starts from the current date/time), and

←/→ to move between segments. Date segment order and separators follow the locale.

One component covers three shapes via granularity: "date" (default), "time", and "datetime". Set range for a start/end pair. The time portion follows the locale’s 12/24-hour cycle unless you force it with is24Hour.

The value is a Date (or a { start, end } DateRange when range is set), exposed as a bindable value + onValueChange with defaultValue for uncontrolled use, and serialized into hidden input(s) for native form submission — YYYY-MM-DD for date, HH:MM for time, YYYY-MM-DDTHH:MM for datetime.

Opt into a calendar picker with calendar: it adds a trailing calendar-icon button (joined to the field with a controls Group) that opens a date-picker popover, so the same field supports both typing and point-and-click selection.

Date

MMDDYYYY

value: (null)

---
// GENERATED from packages/demos/input-datetime/default.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const readoutClass = css({
  fontFamily: "monospace",
  fontSize: "0.75rem",
  opacity: 0.7,
});

const value = null as Date | null;
---

<div class={stack({ gap: "md" })} data-ir-root="input-datetime/default">
  <InputDatetime value={null as Date | null} data-ir="1" />
  <p class={readoutClass}>
    value: <!--ir:2-->
    {value?.toISOString().slice(0, 10) ?? "(null)"}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/default.ts";

  document
    .querySelectorAll('[data-ir-root="input-datetime/default"]')
    .forEach((root) => hydrate([root]));
</script>

Time

granularity="time" renders hour + minute, plus an AM/PM segment in 12-hour mode. is24Hour forces the 24-hour clock.

value: (null)

---
// GENERATED from packages/demos/input-datetime/time.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const pad2 = (n: number) => (n < 10 ? `0${n}` : `${n}`);
const hhmm = (d: Date | null) =>
  d ? `${pad2(d.getHours())}:${pad2(d.getMinutes())}` : "(null)";
const captionClass = css({ fontSize: "0.75rem", opacity: 0.7 });
const readoutClass = css({
  fontFamily: "monospace",
  fontSize: "0.75rem",
  opacity: 0.7,
});

const value = null as Date | null;
---

<div class={stack({ gap: "md" })} data-ir-root="input-datetime/time">
  <label class={stack({ gap: "2xs" })}>
    <span class={captionClass}>12-hour (AM/PM)</span>
    <InputDatetime
      granularity="time"
      fullWidth={false}
      value={null as Date | null}
      data-ir="1"
    />
  </label>
  <label class={stack({ gap: "2xs" })}>
    <span class={captionClass}>24-hour</span>
    <InputDatetime granularity="time" is24Hour fullWidth={false} />
  </label>
  <p class={readoutClass}>
    value: <!--ir:2-->
    {hhmm(value)}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/time.ts";

  document
    .querySelectorAll('[data-ir-root="input-datetime/time"]')
    .forEach((root) => hydrate([root]));
</script>

Datetime

granularity="datetime" composes the date and time segments into one input.

MMDDYYYYhhmm--

value: (null)

---
// GENERATED from packages/demos/input-datetime/datetime.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const readoutClass = css({
  fontFamily: "monospace",
  fontSize: "0.75rem",
  opacity: 0.7,
});

const value = null as Date | null;
---

<div class={stack({ gap: "md" })} data-ir-root="input-datetime/datetime">
  <InputDatetime
    granularity="datetime"
    value={null as Date | null}
    data-ir="1"
  />
  <p class={readoutClass}>
    value: <!--ir:2-->
    {value?.toISOString().slice(0, 16) ?? "(null)"}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/datetime.ts";

  document
    .querySelectorAll('[data-ir-root="input-datetime/datetime"]')
    .forEach((root) => hydrate([root]));
</script>

Calendar

calendar adds a trailing calendar-icon trigger that opens a date-picker popover. The calendar selects the date part; for granularity="datetime" the time stays typed in the segments (a missing time defaults to 00:00 on the first pick). It is shown for granularity "date" and "datetime", and for ranges — but never for "time" (a clock has no calendar). Picking a day fills the field and closes the popover; typing keeps the open calendar in sync, navigating to and highlighting the typed date.

MMDDYYYY

value: (null)

---
// GENERATED from packages/demos/input-datetime/calendar.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const readoutClass = css({
  fontFamily: "monospace",
  fontSize: "0.75rem",
  opacity: 0.7,
});

const value = null as Date | null;
---

<div class={stack({ gap: "md" })} data-ir-root="input-datetime/calendar">
  <InputDatetime calendar value={null as Date | null} data-ir="1" />
  <p class={readoutClass}>
    value: <!--ir:2-->
    {value?.toISOString().slice(0, 10) ?? "(null)"}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/calendar.ts";

  document
    .querySelectorAll('[data-ir-root="input-datetime/calendar"]')
    .forEach((root) => hydrate([root]));
</script>

Calendar range

Combine calendar with range for a two-click range picker. The first click sets the start; the second commits { start, end } and closes the popover.

MMDDYYYYMMDDYYYY

(null) → (null)

---
// GENERATED from packages/demos/input-datetime/calendar-range.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
import type { DateRange } from "@pindoba/core-input-datetime";

const iso = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : "(null)");
const readoutClass = css({
  fontFamily: "monospace",
  fontSize: "0.75rem",
  opacity: 0.7,
});

const value = { start: null, end: null } as DateRange;
---

<div class={stack({ gap: "md" })} data-ir-root="input-datetime/calendar-range">
  <InputDatetime
    range
    calendar
    value={{ start: null, end: null } as DateRange}
    data-ir="1"
  />
  <p class={readoutClass}>
    <!--ir:2-->
    {iso(value.start)}
    <!--/ir--> → <!--ir:3-->
    {iso(value.end)}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/calendar-range.ts";

  document
    .querySelectorAll('[data-ir-root="input-datetime/calendar-range"]')
    .forEach((root) => hydrate([root]));
</script>

Calendar months

calendarMonths shows more than one month in the popover — handy for ranges that cross a month boundary. It maps to the Calendar’s numberOfMonths (clamped to 1–3) and only applies with calendar.

MMDDYYYYMMDDYYYY
---
// GENERATED from packages/demos/input-datetime/calendar-months.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
---

<InputDatetime range calendar calendarMonths={2} />

Calendar with time

With granularity="datetime", the calendar only sets the date — the typed time of day is preserved (or defaults to 00:00 on the first pick). Type a time, pick a day from the calendar, and the time stays put.

MMDDYYYYhhmm--

value: (null)

---
// GENERATED from packages/demos/input-datetime/calendar-datetime.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const readoutClass = css({
  fontFamily: "monospace",
  fontSize: "0.75rem",
  opacity: 0.7,
});

const value = null as Date | null;
---

<div
  class={stack({ gap: "md" })}
  data-ir-root="input-datetime/calendar-datetime"
>
  <InputDatetime
    calendar
    granularity="datetime"
    value={null as Date | null}
    data-ir="1"
  />
  <p class={readoutClass}>
    value: <!--ir:2-->
    {value?.toISOString().slice(0, 16) ?? "(null)"}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/calendar-datetime.ts";

  document
    .querySelectorAll('[data-ir-root="input-datetime/calendar-datetime"]')
    .forEach((root) => hydrate([root]));
</script>

Range

range renders two groups joined by a separator (configurable via rangeSeparator). The value becomes { start, end }, and each side’s day clamps to its own month/year.

MMDDYYYYMMDDYYYY

(null) → (null)

---
// GENERATED from packages/demos/input-datetime/range.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
import type { DateRange } from "@pindoba/core-input-datetime";

const iso = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : "(null)");
const readoutClass = css({
  fontFamily: "monospace",
  fontSize: "0.75rem",
  opacity: 0.7,
});

const value = { start: null, end: null } as DateRange;
---

<div class={stack({ gap: "md" })} data-ir-root="input-datetime/range">
  <InputDatetime
    range
    value={{ start: null, end: null } as DateRange}
    data-ir="1"
  />
  <p class={readoutClass}>
    <!--ir:2-->
    {iso(value.start)}
    <!--/ir--> → <!--ir:3-->
    {iso(value.end)}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/range.ts";

  document
    .querySelectorAll('[data-ir-root="input-datetime/range"]')
    .forEach((root) => hydrate([root]));
</script>

Formats

Date segment order and separators are derived from the locale via the platform’s Intl short-date pattern. Use separator to override the date-part separator.

---
// GENERATED from packages/demos/input-datetime/formats.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const seed = new Date(2026, 4, 14);
const captionClass = css({ fontSize: "0.75rem", opacity: 0.7 });
---

<div class={stack({ gap: "md" })}>
  <label class={stack({ gap: "2xs" })}>
    <span class={captionClass}>en-US — MM/DD/YYYY</span>
    <InputDatetime locale="en-US" defaultValue={seed} fullWidth={false} />
  </label>
  <label class={stack({ gap: "2xs" })}>
    <span class={captionClass}>en-GB — DD/MM/YYYY</span>
    <InputDatetime locale="en-GB" defaultValue={seed} fullWidth={false} />
  </label>
  <label class={stack({ gap: "2xs" })}>
    <span class={captionClass}>pt-BR — DD/MM/YYYY</span>
    <InputDatetime locale="pt-BR" defaultValue={seed} fullWidth={false} />
  </label>
</div>

Labels & localization

Every accessible name the field renders comes from labels. With a non-English locale, the segment names are derived from Intl.DisplayNames (pt-BR → Ano / Mês / Dia); everything else keeps its English default until you pass it. The popover dialog is named labels.calendarDialog (default "Choose date", or labels.calendarDialogRange → "Choose date range" with range), and the trigger button labels.calendarTrigger.

The popover calendar receives the field’s locale, so its weekdays, month names, view tabs and Today button follow it automatically. Its remaining strings are forwarded through labels.calendar, which takes the same keys as the Calendar’s own labels prop:

const labels = {
  calendarTrigger: "Escolher data",
  calendarDialog: "Escolher data",
  calendar: {
    previous: "Anterior",
    next: "Próximo",
    reset: "Limpar",
    dayToday: "{date}, hoje",
  },
};

On Astro, use {placeholder} template strings (not functions) inside labels.calendar — see the Calendar’s Localization & labels section.

Required

required surfaces an error when the field is left empty on blur — data-error flips the input to the danger palette and emits onErrorChange (Svelte) / pindoba:invalid (Astro).

MMDDYYYY

error: (none)

---
// GENERATED from packages/demos/input-datetime/required.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
import type { InputDatetimeError } from "@pindoba/core-input-datetime";

const readoutClass = css({
  fontFamily: "monospace",
  fontSize: "0.75rem",
  opacity: 0.7,
});

const error = null as InputDatetimeError | null;
---

<div class={stack({ gap: "md" })} data-ir-root="input-datetime/required">
  <InputDatetime required value={null as Date | null} data-ir="1" />
  <p class={readoutClass}>
    error: <!--ir:2-->
    {error ?? "(none)"}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/required.ts";

  document
    .querySelectorAll('[data-ir-root="input-datetime/required"]')
    .forEach((root) => hydrate([root]));
</script>

Disabled

disabled greys the field, drops the segments out of the tab order, and ignores all editing — in both the Svelte and Astro (DOM-attach) paths.

05142026
---
// GENERATED from packages/demos/input-datetime/disabled.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md" })}>
  <InputDatetime disabled defaultValue={new Date(2026, 4, 14)} />
</div>

Form submission

The component renders hidden <input>(s), so it submits inside a native <form> like any other control — name is the field key (range adds ${name}-end).

05142026

submitted: (nothing yet)

---
// GENERATED from packages/demos/input-datetime/form.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const submitClass = css({
  alignSelf: "flex-start",
  py: "0.25rem",
  px: "0.75rem",
  fontSize: "0.875rem",
});
const resultClass = css({
  fontFamily: "monospace",
  fontSize: "0.75rem",
  opacity: 0.7,
});

const submitted = "";
---

<form
  class={stack({ gap: "md" })}
  data-ir-root="input-datetime/form"
  data-ir="1"
>
  <InputDatetime name="appointment" defaultValue={new Date(2026, 4, 14)} />
  <button type="submit" class={submitClass}>
    Submit
  </button>
  <p data-submitted class={resultClass}>
    submitted: <!--ir:2-->
    {submitted || "(nothing yet)"}
    <!--/ir-->
  </p>
</form>

<script>
  import { hydrate } from "./_client/form.ts";

  document
    .querySelectorAll('[data-ir-root="input-datetime/form"]')
    .forEach((root) => hydrate([root]));
</script>
  • Components
  • Blocks