Skip to content

Proposal: DatePicker / DateField (and future DateRangePicker)

Status: Decided — v1 builds on the §9 lighter foundation (@internationalized/date + Base UI), approved 2026-06-26 (superseding the original react-aria-components decision). See the Revision note. Scope: v1 ships DateField, DatePicker, and a standalone Calendar. DateRangePicker / RangeCalendar are designed-for but deferred.

Revision — 2026-06-26 (foundation swap approved)

The original v1 foundation decision (react-aria-components, 2026-06-17, §1 below) has been superseded: v1 now builds on the §9 lighter foundation@internationalized/date + Base UI — measured at ~11 KB gz vs ~74 KB for react-aria, with the same public API and value layer (see date-picker-impl-comparison.md). The swap was approved on 2026-06-26.

  • Decision (v1): ship the @internationalized/date + Base UI variant. Public API and value layer are unchanged, so this is invisible to consumers.
  • Trade-off accepted: we own ~670 LOC of APG behaviour instead of Adobe-audited primitives. Known gaps tracked for follow-up: mobile/touch typing (segments are role="spinbutton" with no hidden numeric <input>, so on-screen-keyboard entry is limited — the calendar is the touch path), RTL arrow-key flipping, and the pattern is unit-tested but not yet screen-reader-audited.

Post-v1 fast-follows (Base UI variant)

Owning the segmented input + grid means a handful of behaviours react-aria gave us for free are now our own follow-up work. The full inventory is in date-picker-impl-comparison.md ("Known gaps vs react-aria"); the highest-value item is written up here so the approach isn't lost.

Mobile / touch text entry (biggest gap)

Problem. Our segments are role="spinbutton" <div>s with contentEditable={false}, and input is read only from keydown. On-screen keyboards don't emit usable keydown, and with no editable element focused the keyboard doesn't surface at all — so on touch devices you can't type a date (the calendar popover is the only touch path). Desktop keyboard + calendar work fully.

Approach — mirror react-aria. react-aria makes each segment a contentEditable span with inputmode="numeric"; that's exactly what surfaces the numeric soft keyboard and lets it type into the element (verified: react-aria's month segment is <span role="spinbutton" contenteditable="true" inputmode="numeric" enterkeyhint="next" autocorrect="off" style="caret-color: transparent">). To match it in DateInputGroup:

  1. Render each editable segment with contentEditable (when not disabled/readonly), inputMode="numeric" (omit for the AM/PM segment), plus enterKeyHint="next", autoCorrect="off", autoCapitalize="off", spellCheck={false}. Keep the transparent caret so the field still renders our locale-formatted text, not an editing caret.
  2. Add a native beforeinput listener that always preventDefaults — the segment text stays React-controlled, so the browser must never mutate the contentEditable DOM — then translates the event into the existing engine:
    • insertText → per character: digit → setDigit(...) (same typed-count / auto-advance path as keydown); a/p on the day-period segment → setDayPeriod.
    • deleteContentBackward / deleteContentForwardclearSegment.
    • anything else → ignored (already prevented), which also swallows stray characters so the segment can't accumulate junk.
  3. Keep the current keydown path for physical keyboards. The two are mutually exclusive: a handled keydown calls preventDefault, which suppresses the follow-on beforeinput; soft keyboards emit no actionable keydown, so beforeinput is the only path that fires there.

Why native beforeinput, not React's onBeforeInput: React's synthetic onBeforeInput is unreliable for cancelling the native default on contentEditable; a native listener (attached via ref/effect) makes preventDefault reliably stop DOM mutation across browsers.

Scope: touches only DateInputGroup (segment attributes + the input handler). No public-API or value-layer change. Segment DOM snapshots update (the new contentEditable/inputmode attributes).

Verification: the desktop keydown path stays covered by the existing unit tests; the beforeinput path can be exercised by dispatching a synthetic InputEvent("beforeinput", { inputType, data }). The soft keyboard appearing on focus is platform behaviour driven purely by the contentEditable + inputmode attributes, so it can't be unit-tested — it needs real-device QA (physical phone or BrowserStack) before sign-off.

Other gaps

RTL arrow-key flipping, non-Gregorian calendar systems, localized placeholder/nav-button strings, a dedicated navigation live-region, and a full screen-reader audit — all inventoried in the comparison doc's "Known gaps vs react-aria" section.


Everything below is the original 2026-06-17 decision and analysis, kept verbatim for the record (now superseded by the notes above).

1. Summary & decision

Decision (v1): build on react-aria-components (Adobe) for the behaviour/a11y layer, with @internationalized/date as the value layer, wrapped in a thin app-shell presentation layer that matches every other component in the library. We accept the ~75 KB cost (§7) in exchange for audited APG accessibility at the lowest build risk. A lighter foundation is kept as a deliberate future possibility (§9).

This choice is low-regret: the public API (§3) and the value layer are foundation-agnostic, so a later swap to a lighter foundation touches only the internal wrapper — not consumers, not stored values. The alternatives measured before settling here are kept in §1.1 for the record.

Verified facts behind this decision:

  • Single bundled stack. Modern react-aria-components v1 ships as 3 pre-bundled artifacts (react-aria-components, react-aria, react-stately), not the 30+ split @react-aria/* packages of the v0 era. Net-new footprint ≈ 10 packages, 3 substantive.
  • Base UI has no date primitives and none on the near roadmap, so "wait for Base UI" is not an option. Base UI remains our primitive provider for everything else; react-aria is scoped strictly to the date family.

The trade-off we accept: a second headless stack exists in the bundle, bounded to date components and invisible to consumers (they never import from react-aria-components).

1.1 Library choice — alternatives re-evaluated

The 75 KB figure prompted a re-test of the hypothesis: is there a slimmer option that doesn't add a primitive foundation competing with Base UI? The deepest form of that concern is not bundle size — it's that react-aria-components is itself a general primitive suite (Popover, Dialog, Select, Menu, ComboBox, Table…) that directly overlaps Base UI. Measured with identical methodology (esbuild, minified + gzipped, react/react-dom externalised, tree-shaken to the date slice):

OptionDate slice (gz)Value layerAdds a competing primitive foundation?A11yBuild effort
react-aria-components73.4 KB@internationalized/dateYes — full primitive suite overlaps Base UIBest-in-class, APG-audited, SR-testedLowest
Ark UI / Zag.js42.7 KB@internationalized/dateYes — Ark is also a full headless suite (lighter)Good — Zag implements APGLow
Zag machine only + Base UI39.4 KB@internationalized/datePartial — own popper/dismiss/live-region (dual positioning)GoodMedium (awkward wiring)
@internationalized/date + Base UI10.8 KBis the value layer ✅No — value/math lib onlyWe own itHighest
react-day-picker v10 + Base UI20.4 KBdate-fns, Date-basedNo — calendar widget onlyCalendar grid only; build input + dialogHigh (+ loses value correctness)

What the data changes:

  • The hypothesis is partly confirmed. Slimmer, better-aligned options exist. react-aria is the heaviest candidate and, with Ark available, no longer the obvious pick.
  • Ark UI/Zag strictly dominates react-aria if a second behaviour layer is acceptable: ~40% lighter, the same value layer (@internationalized/date → identical calendar systems, ZonedDateTime, value-type correctness), comparable APG a11y. react-aria's only remaining edges are maturity/ecosystem (Adobe, larger adoption) and an exceptionally polished segmented input.
  • @internationalized/date + Base UI is the only option that honours "no competing foundation" while keeping value correctness — ~7× slimmer than react-aria. The cost is real: we build and own the segmented input + calendar grid + APG dialog a11y (date pickers are the #1 place teams ship broken a11y). De-riskable by shipping the simpler APG text-input + calendar-dialog pattern first (Base UI Dialog already handles focus trap/return/dismiss), with the segmented input as a fast-follow.
  • Drop react-day-picker: Date-based, abandons the value-type thesis, and still calendar-only (you build the input + dialog anyway). Worst of both.
  • Drop raw Zag-machine-on-Base-UI: not meaningfully lighter than Ark (39 vs 43 KB) and forces two positioning systems.

Outcome: for v1 we chose react-aria-components (audited a11y, least build risk, largest ecosystem) and accept the bundle cost. Ark UI/Zag and the Base-UI-native build are retained as a future direction — see §9.

2. Why wrap — what the wrapper actually buys us

The interface-slimming is the least of it. Ranked by value:

  1. Styling ownership (the main reason). react-aria ships zero CSS. Without a wrapper, "use the DatePicker" means assembling ~12 headless sub-components and re-authoring all the astw: / theme-token / dark-mode / animation classes at every call site. The wrapper is where the visual identity lives — once. This is the same reason Select is wrapped today.
  2. Abstraction seam / vendor insulation (the strategic one). Wrapping makes react-aria an implementation detail behind our own stable API. If react-aria reshapes its composition, or we later move the popover to Base UI, or migrate off react-aria entirely, it's a one-package change — not a consumer-wide migration. This is precisely what makes "we took on a second stack" reversible rather than a long-term lock-in.
  3. Library consistency. Consumers get <DatePicker value onChange … />, shaped like every other app-shell component, instead of a foreign 12-part Adobe composition. The second stack never enters the consumer's mental model.
  4. Footgun masking (correctness). Bake in safe defaults: site timezone instead of getLocalTimeZone(), full BCP-47 locale, no Date round-trip, granularity → value-type discrimination. Hide the knobs that let people do the wrong thing.
  5. Interface slimming itself. ~40 props + 12 sub-components → ~12 flat props. Real, but a consequence of 1–4, not the goal.

Counter-discipline: don't re-design what's already good. Keep value / onChange / minValue / maxValue / granularity / isDateUnavailable named exactly as react-aria has them. We slim and add safety; we do not invent a new vocabulary (that would create a translation tax against upstream docs and churn).

3. Proposed public API

Three exports in v1, plus two deferred:

tsx
<DateField />     // segmented typed input only, no popover  → DateValue
<DatePicker />    // input + popover + calendar              → DateValue
<Calendar />      // standalone inline calendar (reporting filters, no popover)

// deferred, designed-for:
<DateRangePicker />  // → { start: DateValue; end: DateValue }
<RangeCalendar />

Single-date and range are separate components, not a mode prop — they have different value types (T vs { start: T; end: T }), and a mode prop forces every callsite to discriminate.

3.1 Value type is set by granularity

tsx
<DatePicker granularity="day" />     // → CalendarDate          (no time, no tz)
<DatePicker granularity="minute" />  // → CalendarDateTime       (local wall-time)
<DatePicker granularity="minute" timeZone="America/Los_Angeles" />
                                     // → ZonedDateTime          (tz-aware)

onChange returns the @internationalized/date type — never a JS Date. Returning Date discards the calendar system and corrupts timezone reasoning, which is the whole point of the stack. For callers that genuinely need a Date, the value carries .toDate(timeZone); ISO round-tripping to the backend lives in a codec layer (see §7).

3.2 Props we pass through (the 90% surface — names unchanged from react-aria)

PropTypeNotes
value / defaultValue / onChangeDateValue (typed by granularity)Controlled or uncontrolled
granularity"day" | "hour" | "minute" | "second"Default "day". Drives the value type
minValue / maxValueDateValueSame type as value
isDateUnavailable(date) => booleanSingle predicate — holidays, weekends, lead-time
isDisabled / isReadOnly / isRequiredboolean
isInvalidboolean
autoFocusboolean
hourCycle12 | 24Overrides locale default
hideTimeZonebooleanCosmetic, ZonedDateTime only
placeholderValueDateValueSeeds the segment placeholder (e.g. default year)
firstDayOfWeek"sun" | "mon" | …Forwarded to the inner Calendar; defaults from locale
namestringNative form / form-library integration

3.3 Props we add (app-shell conveniences)

PropTypeWhy
labelLocalizedStringRenders <Label>; accepts our LocalizedString (string | (locale) => string)
descriptionLocalizedStringRenders the help text slot
errorMessageLocalizedStringSimpler than react-aria's validate-returns-ValidationError; matches our Field pattern
timeZonestring (IANA)Defaults to the AppShell timeZone (§6), not getLocalTimeZone()
localestring (BCP-47)Per-field override; defaults to AppShell formatting locale (§5)
classNamestringApplied to the root group only — deep restyling is intentionally not a goal (wrapper owns visuals)
data-slot(internal)date-picker, date-segment, calendar-cell, … for testing/identification

3.4 Props we mask or omit (explicit call-outs)

react-aria prop / seamWhat we doWhy
Whole sub-component compositionDateInput render-prop, DateSegment, Group, Button, Popover, Dialog, Calendar, CalendarGrid, CalendarCell, Heading, nav buttonsInternalised. Not exposed.This is the styling/theme layer. Consumers must never assemble or re-style it — that's the wrapper's whole job (§2.1). The single biggest masking.
validate (returns ValidationError)Replace with isInvalid + errorMessageAligns with how every other app-shell field expresses errors. A power validate can be added later if the Form integration needs it.
validationBehavior ("native" | "aria")Pin "aria" default, not exposed in v1One validation mode across the app instead of per-callsite drift. Revisit when we wire native <Form> integration.
isOpen / defaultOpen / onOpenChangeMasked (uncontrolled only) in v1Externally controlling the popover is rare; YAGNI. Easy to add later without breaking the surface.
shouldForceLeadingZerosOmitCosmetic; the locale already decides.
pageBehaviorPin a sensible default, not exposedNiche calendar paging knob.
shouldCloseOnSelectDefault true, not exposedSensible default; expose only if asked.
RAC form context / slot plumbingInternalNot part of the public contract.

4. Example code

4.1 Consumer usage

tsx
// Date helpers + types are re-exported from app-shell — no extra install (see §7.2):
import {
  DatePicker,
  DateField,
  Calendar,
  today,
  getLocalTimeZone,
  type CalendarDate,
} from "@tailor-platform/app-shell";

// Basic — date only (value is CalendarDate)
function ShipDate() {
  const [value, setValue] = useState<CalendarDate | null>(null);
  return <DatePicker label="Ship date" value={value} onChange={setValue} />;
}

// Constrained — no past dates, no weekends
<DatePicker
  label="Delivery date"
  minValue={today(getLocalTimeZone())}
  isDateUnavailable={(date) => {
    const dow = date.toDate(getLocalTimeZone()).getDay();
    return dow === 0 || dow === 6; // weekends unavailable
  }}
  errorMessage="Pick a weekday on or after today"
/>

// Date + time, timezone from the AppShell site config (value is ZonedDateTime)
<DatePicker label="Cut-off" granularity="minute" />

// Input-only, no popover (value is CalendarDate)
<DateField label="Invoice date" />

// Inline calendar for a reporting filter (no popover)
<Calendar aria-label="Report range start" value={value} onChange={setValue} />

4.2 Internal wrapper sketch (illustrative — proves where styling lives)

This is the part consumers never see. It shows the masking + theme-token reuse + data-attribute styling. Note the popover reuses the exact same tokens as our Base UI popovers (bg-popover, --z-popup, the same enter/exit animation classes); only the state-attribute names differ (data-entering/data-exiting for react-aria vs data-ending-style for Base UI).

tsx
import {
  DatePicker as RACDatePicker,
  Group,
  DateInput,
  DateSegment,
  Button,
  Label,
  Text,
  FieldError,
  Popover,
  Dialog,
  Calendar as RACCalendar,
  CalendarGrid,
  CalendarCell,
  Heading,
} from "react-aria-components";
import { cva } from "class-variance-authority";
import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { cn } from "../lib/utils";
import { useTimeZone, useResolvedLocale } from "../contexts/appshell-context";

// Cell styling driven entirely by react-aria's boolean data-* attributes (Tailwind v4 arbitrary variants).
const cell = cva(
  "astw:flex astw:size-9 astw:items-center astw:justify-center astw:rounded-md astw:text-sm astw:outline-none",
  {
    variants: {
      state: {
        base: [
          "astw:data-[hovered]:bg-accent astw:data-[hovered]:text-accent-foreground",
          "astw:data-[focus-visible]:ring-ring/50 astw:data-[focus-visible]:ring-2",
          "astw:data-[selected]:bg-primary astw:data-[selected]:text-primary-foreground",
          "astw:data-[outside-month]:text-muted-foreground/50",
          "astw:data-[unavailable]:text-muted-foreground astw:data-[unavailable]:line-through",
          "astw:data-[disabled]:opacity-50 astw:data-[disabled]:pointer-events-none",
          // range states wired now so DateRangePicker is purely additive later:
          "astw:data-[selection-start]:rounded-l-md astw:data-[selection-end]:rounded-r-md",
        ],
      },
    },
    defaultVariants: { state: "base" },
  },
);

function DatePicker({ label, description, errorMessage, timeZone, locale, className, ...props }) {
  const shellTz = useTimeZone(); // §6
  const { locale: shellLocale } = useResolvedLocale(); // §5
  return (
    <RACDatePicker
      data-slot="date-picker"
      className={cn("astw:flex astw:flex-col astw:gap-1.5", className)}
      // resolution order: field prop → shell config → browser fallback handled in hook
      {...props}
    >
      {label && (
        <Label className="astw:text-foreground astw:text-sm astw:font-medium">{label}</Label>
      )}
      <Group
        className={cn(
          "astw:border-input astw:bg-background astw:flex astw:h-9 astw:w-full astw:items-center",
          "astw:rounded-md astw:border astw:px-3 astw:text-sm",
          "astw:focus-within:border-ring astw:focus-within:ring-ring/50 astw:focus-within:ring-[3px]",
          "astw:data-[invalid]:border-destructive",
        )}
      >
        <DateInput className="astw:flex astw:flex-1">
          {(segment) => (
            <DateSegment
              segment={segment}
              data-slot="date-segment"
              className={cn(
                "astw:rounded astw:px-0.5 astw:tabular-nums astw:outline-none",
                "astw:data-[focused]:bg-primary astw:data-[focused]:text-primary-foreground",
                "astw:data-[placeholder]:text-muted-foreground",
              )}
            />
          )}
        </DateInput>
        <Button className="astw:text-muted-foreground hover:astw:text-foreground astw:cursor-pointer">
          <CalendarIcon className="astw:size-4" />
        </Button>
      </Group>
      {description && (
        <Text slot="description" className="astw:text-muted-foreground astw:text-xs">
          {description}
        </Text>
      )}
      <FieldError className="astw:text-destructive astw:text-xs">{errorMessage}</FieldError>

      <Popover
        data-slot="date-popover"
        className={cn(
          // SAME tokens as our Base UI popover (see badge-list.tsx); only the
          // animation state-attribute names differ (data-entering/exiting vs data-ending-style).
          "astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup)",
          "astw:rounded-md astw:border astw:p-3 astw:shadow-md astw:origin-(--trigger-anchor-point)",
          "astw:data-[entering]:animate-in astw:data-[entering]:fade-in-0 astw:data-[entering]:zoom-in-95",
          "astw:data-[exiting]:animate-out astw:data-[exiting]:fade-out-0 astw:data-[exiting]:zoom-out-95",
        )}
      >
        <Dialog className="astw:outline-none">
          <RACCalendar>
            <header className="astw:flex astw:items-center astw:justify-between astw:pb-2">
              <Button slot="previous">
                <ChevronLeftIcon className="astw:size-4" />
              </Button>
              <Heading className="astw:text-sm astw:font-medium" />
              <Button slot="next">
                <ChevronRightIcon className="astw:size-4" />
              </Button>
            </header>
            <CalendarGrid>{(date) => <CalendarCell date={date} className={cell()} />}</CalendarGrid>
          </RACCalendar>
        </Dialog>
      </Popover>
    </RACDatePicker>
  );
}

5. Locale — tag format call-out + granular-locale direction

5.1 The call-out (a real bug if we don't fix it)

detectBrowserLocale() in lib/i18n.ts deliberately strips to the language subtag:

ts
const locale = new Intl.Locale(browserLocale);
return locale.language; // "ja-JP" → "ja"

That is correct for label resolution (we have one translation table per language), but it throws away exactly the information formatting needs. react-aria's I18nProvider / Intl need the full BCP-47 tag to drive first-day-of-week (US = Sun, GB/EU = Mon), segment order (MM/DD/YYYY vs DD/MM/YYYY), 12h vs 24h, and numbering system.

Concrete failure: a browser reporting en-GB gets stripped to en, react-aria falls back to en-US defaults, and a British user sees MM/DD/YYYY, Sunday-first — wrong.

Rule: never feed the stripped language code into react-aria. Feed the full tag.

5.2 Direction: split "language" from "formatting locale" at the AppShell

This is bigger than DatePicker and shouldn't be solved DatePicker-locally — currency, number, and relative-time formatting will all want the same thing. The shape (high-level; to be specced separately):

  • The AppShell takes one source of truth: a full BCP-47 locale (e.g. "en-GB", "ja-JP"). Default to navigator.languages[0] (the full tag we already have), not the stripped version.
  • From it, derive two consumers held in appshell-context:
    • language = new Intl.Locale(locale).language → drives the existing label/i18n system, unchanged downstream.
    • locale (full) → drives all Intl/CLDR formatting: react-aria's <I18nProvider locale={locale}> mounted once at the shell root, and future Intl.NumberFormat / currency / Intl.DateTimeFormat.
  • Expose via a hook, e.g. useResolvedLocale(): { locale: string; language: string }. DatePicker is simply the first consumer; currency/number formatters join later.
  • Backward compatible: the label path still receives a language code (derived), so existing behaviour is preserved. We just stop discarding the region for the formatting path. Passing a bare "en" still works (Intl falls back to language defaults).
  • Optional future: allow language and formatting locale to be set independently (e.g. Japanese UI text but US number format). A footnote, not v1.

6. Time zone — AppShell global config param

First-cut design, matching the "global context param" instinct:

  • Add timeZone?: string (IANA, e.g. "America/Los_Angeles") to the AppShell config, living in appshell-context alongside locale. Conceptually, locale + timeZone together form the AppShell "formatting / i18n context" — a cohesive concept, extensible to currency etc.
  • Expose useTimeZone().
  • DatePicker resolves the zone in this order: field timeZone prop → AppShell timeZonegetLocalTimeZone() (last-resort fallback).
  • Only relevant for date+time (ZonedDateTime). For granularity="day" the value is a CalendarDate with no zone, so this is a no-op — the common case is unaffected.
  • Multi-warehouse: the per-field timeZone prop is the pressure valve for "this field is pinned to the site's zone, not the user's." Where that site zone comes from in context is a product question, but the mechanism is in place.

7. Bundle / dependency impact

Dependency delta (verified)

  • ~10 net-new packages, of which 3 are substantive (react-aria-components, react-aria, react-stately); the rest are small utilities (@internationalized/*, @react-types/shared, aria-hidden, client-only, use-sync-external-store).

Measured size (esbuild, minified + gzipped, react/react-dom externalised, tree-shaken to the date slice)

Surfaceminifiedgzipped
Value layer (@internationalized/date)32.8 KB10.8 KB
RAC date components (DatePicker + DateField + Calendar + Popover + Dialog + …)241.5 KB73.4 KB
Full date surface (both)246.4 KB74.7 KB

(The value layer is largely subsumed by the components — RAC already depends on it, so the marginal cost of adding it is ~1.3 KB.)

This is notable — ~75 KB gz if loaded eagerly — and bigger than the original research's ~30 KB estimate. The drivers are overlay/positioning, focus management, collections, and bundled localized ARIA strings for ~30+ locales.

Mitigations (in priority order)

  1. Additive & opt-in. Only routes/consumers that import a date component pay; everything else tree-shakes it out. A consumer using only DateField (no calendar/popover) pays substantially less.
  2. Lazy-load the popover/calendar behind the trigger. Code-split so the field renders cheaply and the calendar chunk loads on first open. This moves the bulk off the initial bundle.
  3. Strip unused locales. react-aria ships a build plugin (@react-aria/optimize-locales-plugin, Vite/Webpack/Babel) that drops ARIA strings for locales we don't ship. If we ship only en + ja, this trims a meaningful slice of the 73 KB. (Verify exact savings in our build.)
  4. Gate merge on a real bundle report in the app's own bundler. This was an open question in the research; the measurement here confirms it's necessary, not optional.

7.1 What the bundle actually buys us

Reframed against the value layer, the premium is almost entirely pre-built accessibility, not i18n.

@internationalized/date alone (10.8 KB) already delivers the correctness dimensions — and we keep these in every option:

  • Value-type correctnessCalendarDate / CalendarDateTime / ZonedDateTime; no Date round-trip corruption.
  • 13 calendar systems (Gregorian, Buddhist, Hebrew, Indian, Islamic variants, Japanese, Persian, ROC…) selected by locale.
  • i18n formatting — locale-driven segment order, first-day-of-week, 12/24h, numbering system.
  • All date math — add/subtract, compare, parse, constrain, DST-correct zone conversions.

The +32 KB (Ark) or +62 KB (react-aria) on top buys the interaction / a11y dimensions we would otherwise hand-build and maintain:

  • WAI-ARIA APG conformance — dialog/grid roles, roving tabindex, live-region announcements, focus management/trap, scroll lock.
  • Segmented input behaviour — per-segment Up/Down, type-to-fill auto-advance, locale segment ordering, backspace, invalid-input rejection at the segment.
  • Full keyboard grid nav — arrows, Home/End, PageUp/Down, Shift+PageUp/Down (the keyboard-research "must-haves" come free).
  • Cross-browser + screen-reader testing maintained by the vendor, not us.

So the real question is not "is 75 KB worth i18n + calendars" — that's the 11 KB value layer we keep regardless. It's "is the a11y/interaction layer worth building and owning ourselves (~11 KB, highest effort), or worth renting for +32 KB (Ark) / +62 KB (react-aria)?" That is the Decision fork.

@internationalized/date vs Intl.DateTimeFormat — different layers, not alternatives

What this doc calls the "value layer" is the date representation: the immutable objects our value / onChange hold (CalendarDate / CalendarDateTime / ZonedDateTime) plus their arithmetic and parsing. Intl.DateTimeFormat is a formatter (value → localized string) — the zero-bundle native engine we use directly for display / number / currency, and which @internationalized/date itself calls under the hood. They compose; they don't compete.

"Use Intl instead of the lib" really means making JS Date the value type again — which reintroduces Date's footguns, most notably the date-only midnight-UTC shift: a plain calendar date forced through a Date becomes an instant at midnight, so any timezone conversion (on parse, display, or save) can roll it a day forward/back. CalendarDate has no time and no zone, so there is nothing to shift.

Net: the ~11 KB buys representation + parsing + timezone/calendar correctness, not formatting — formatting is free and native in every option.

7.2 Packaging / dependency surface

@internationalized/date is public API, not an internal detail — its types are the value / onChange / minValue types. Decision: ship it as a regular dependency of @tailor-platform/app-shell and re-export its common surface, so consumers import everything from one place and install nothing extra.

  • Re-exported from @tailor-platform/app-shell (the 90% surface): helpers today, now, getLocalTimeZone, parseDate, parseDateTime, parseAbsolute, parseZonedDateTime; types CalendarDate, CalendarDateTime, ZonedDateTime, Time, DateValue.
  • Long tail: a consumer needing a helper we don't re-export adds @internationalized/date to their own deps and imports it directly; it dedupes with app-shell's copy (and react-aria's internal copy) to a single instance as long as version ranges overlap — keep app-shell's range compatible with react-aria's.

Why a regular dep, not a peer: lowest friction for a multi-feature library where dates are one feature. Consumers install only @tailor-platform/app-shell; there's no phantom-dependency hazard under pnpm because app-shell owns and resolves the copy. The contract is: import date helpers/types from @tailor-platform/app-shell; only add @internationalized/date yourself for a long-tail helper.

A later move to a peer dep, or to a lighter foundation (§9), stays an internal change — the re-export surface is the consumer contract, not the underlying package.

8. Open questions (follow-ups, not blockers)

  • Form integration. CalendarDate doesn't JSON.stringify cleanly. Decide whether to coerce to ISO at the form boundary or teach the schema/validator the @internationalized/date types. Drives whether validate / validationBehavior get exposed.
  • Backend codec. A small parseAbsolute(iso, tz) → ZonedDateTime / value.toAbsoluteString() layer. Decide where it lives (resolver edge vs GraphQL client vs field).
  • Presets ({ label, value }[] rail in the popover) — defer to v1.1; validate that imperatively setting a value from a preset click doesn't break focus management. → track as a subtask on the issue.
  • Keyboard / expression layer (t, +7, next tue, etc.) — substantial enough to be its own proposal; the chosen foundation gives us the full APG grid + segment nav for free, the expression overlay is additive on top. → track as a subtask on the issue.
  • Inline Calendar a11y. The APG pattern is dialog-shaped; confirm RAC's standalone Calendar is correct for the no-popover reporting-filter case.
  • DateRangePicker. Cells already carry range-state styling (§4.2), so it should be additive — confirm when we pick it up.

9. Future plans — a lighter foundation

We chose react-aria-components for v1, but the ~75 KB cost and the overlap with Base UI make a lighter foundation worth revisiting once the component is established and we have real usage + bundle data. This is a tracked possibility, not a commitment.

Why deferring is low-regret: all candidates share the same public API (§3) and the same value layer (@internationalized/date), so a future migration touches only the internal wrapper — consumers and stored values are unaffected.

Two directions, in order of preference if/when we revisit:

  1. @internationalized/date + Base UI (~11 KB) — removes the second foundation entirely; maximal alignment with our Base UI stack. Cost: we build and own the segmented input + calendar-grid + APG dialog a11y. De-risk by starting from the simpler APG text-input + calendar-dialog pattern (Base UI Dialog already handles focus trap/dismiss), adding the segmented input afterward.
  2. Ark UI / Zag.js (~43 KB) — ~40 % lighter than react-aria with the same value layer and good APG a11y, if a lighter, date-scoped second foundation is acceptable.

Triggers to revisit: the date components show up as a material slice in a bundle report; we adopt @internationalized/date elsewhere anyway; or Base UI ships its own date primitives. Not pursued: react-day-picker (Date-based, abandons value correctness) and raw Zag-machine-on-Base-UI (no lighter than Ark, dual positioning).