Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
SelectionUpdatedFreeWCAG 2.2 AA

Date Picker

Calendar-based date selection.

Preview

Live preview
@oshon-ai/components
Empty (placeholder visible)
Pre-filled — click to open the panel
Five-size axis
size=xs
size=s
size=m
size=l
size=mobile
States — disabled · error · required + tooltip

Pick a date in the future.

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add datepicker

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { DatePicker } from '@oshon-ai/components';

export default function Example() {
  return <DatePicker />;
}

Date — single day

Single-day picker. Click the trigger to open the 288 px panel, pick a day to commit. Month + year are native <select>s — tab into them to get the OS listbox.
value = 2026-02-06
tsx
{
    const [value, setValue] = useState<Date | null>(ANCHOR);
    return (
      <div style={col}>
        <div style={caption}>
          Single-day picker. Click the trigger to open the 288 px panel,
          pick a day to commit. Month + year are native <code>&lt;select&gt;</code>s —
          tab into them to get the OS listbox.
        </div>
        <DatePicker
          label="Date"
          required
          tooltip
          placeholder="Pick a date"
          value={value}
          onChange={setValue}
        />
        <div style={valueRow}>
          value = {value ? value.toISOString().slice(0, 10) : 'null'}
        </div>
      </div>
    );
  }

Time — hour + minute + meridiem

Time-only picker. Numeric inputs for HH + MM, then an AM/PM toggle pair bound to a role="radiogroup". Use when the date is already fixed by context (meeting rooms, daily standups, appointment slots).
value = 02:30 PM
tsx
{
    const [value, setValue] = useState<TimeValue | null>({
      hour: 2,
      minute: 30,
      meridiem: 'pm',
    });
    return (
      <div style={col}>
        <div style={caption}>
          Time-only picker. Numeric inputs for HH + MM, then an AM/PM
          toggle pair bound to a <code>role="radiogroup"</code>. Use when
          the date is already fixed by context (meeting rooms, daily
          standups, appointment slots).
        </div>
        <DatePicker
          type="time"
          label="Time"
          required
          tooltip
          placeholder="Pick a time"
          value={value}
          onChange={setValue}
        />
        <div style={valueRow}>
          value ={' '}
          {value
            ? `${String(value.hour).padStart(2, '0')}:${String(value.minute).padStart(2, '0')} ${value.meridiem.toUpperCase()}`
            : 'null'}
        </div>
      </div>
    );
  }

Date & Time — bundle

Calendar + time footer in one popover. Prefer this over two side-by-side pickers — the Tab journey stays linear (label → trigger → day grid → time inputs → meridiem), and consumers manage a single value atom instead of two.
value = 2026-02-06 @ 02:30 PM
tsx
{
    const [value, setValue] = useState<{
      date: Date | null;
      time: TimeValue;
    } | null>({
      date: ANCHOR,
      time: { hour: 2, minute: 30, meridiem: 'pm' },
    });
    return (
      <div style={col}>
        <div style={caption}>
          Calendar + time footer in one popover. Prefer this over two
          side-by-side pickers — the Tab journey stays linear (label →
          trigger → day grid → time inputs → meridiem), and consumers
          manage a single <code>value</code> atom instead of two.
        </div>
        <DatePicker
          type="datetime"
          label="Date and Time"
          required
          tooltip
          placeholder="Pick date + time"
          value={value}
          onChange={setValue}
        />
        <div style={valueRow}>
          value ={' '}
          {value?.date
            ? `${value.date.toISOString().slice(0, 10)} @ ${String(value.time.hour).padStart(2, '0')}:${String(value.time.minute).padStart(2, '0')} ${value.time.meridiem.toUpperCase()}`
            : 'null'}
        </div>
      </div>
    );
  }

Range — dual calendar

Dual-calendar inclusive range. Click once to anchor the start, hover to preview the fill, click again to commit the end. Teal- 700 on endpoints, teal-200 mid-range, live hover preview before the second click lands. Panel is 576 px wide (two months side by side).
start = 2026-02-06 · end = 2026-02-13
tsx
{
    const [value, setValue] = useState<DateRangeValue>({
      start: new Date(2026, 1, 6),
      end: new Date(2026, 1, 13),
    });
    return (
      <div style={col}>
        <div style={caption}>
          Dual-calendar inclusive range. Click once to anchor the start,
          hover to preview the fill, click again to commit the end. Teal-
          700 on endpoints, teal-200 mid-range, live hover preview before
          the second click lands. Panel is 576 px wide (two months side
          by side).
        </div>
        <DatePicker
          type="range"
          label="Travel window"
          required
          tooltip
          placeholder="Pick a range"
          value={value}
          onChange={setValue}
        />
        <div style={valueRow}>
          start = {value.start ? value.start.toISOString().slice(0, 10) : 'null'}
          {' · '}
          end = {value.end ? value.end.toISOString().slice(0, 10) : 'null'}
        </div>
      </div>
    );
  }

State matrix — hover / error / disabled

Pixel-port of the state rows from Figma frame 6548:3304. Hover the first field to see the border fade from primary-700 to primary-500. The error row renders the description line in error-600 and flips aria-invalid. The disabled row drops its border and fills with neutral-100; clicking it does nothing.

Error description

Error description

tsx
<div style={col}>
      <div style={caption}>
        Pixel-port of the state rows from Figma frame <code>6548:3304</code>.
        Hover the first field to see the border fade from primary-700 to
        primary-500. The error row renders the description line in error-600
        and flips <code>aria-invalid</code>. The disabled row drops its
        border and fills with neutral-100; clicking it does nothing.
      </div>
      <div style={row}>
        <DatePicker
          label="Enabled (hover me)"
          placeholder="MM/DD/YYYY"
          defaultValue={ANCHOR}
        />
        <DatePicker
          label="Error — empty"
          required
          error
          errorMessage="Error description"
        />
        <DatePicker
          label="Error — filled"
          required
          errorMessage="Error description"
          defaultValue={ANCHOR}
        />
        <DatePicker
          label="Disabled — empty"
          required
          disabled
          placeholder="MM/DD/YYYY"
        />
        <DatePicker
          label="Disabled — filled"
          required
          disabled
          defaultValue={ANCHOR}
        />
      </div>
    </div>

Size matrix — QA grid

size=xs
size=s
size=m
size=l
size=mobile
tsx
<div style={col}>
      {SIZES.map((size) => (
        <div
          key={size}
          style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
        >
          <div style={caption}>
            <strong style={{ color: 'var(--oshon-color-neutral-900)' }}>
              size={size}
            </strong>
          </div>
          <div style={row}>
            <DatePicker
              size={size}
              label="Date"
              placeholder="Pick a date"
              defaultValue={ANCHOR}
            />
            <DatePicker
              size={size}
              type="time"
              label="Time"
              placeholder="Pick a time"
              defaultValue={{ hour: 2, minute: 30, meridiem: 'pm' }}
            />
            <DatePicker
              size={size}
              type="datetime"
              label="Both"
              placeholder="Pick both"
              defaultValue={{
                date: ANCHOR,
                time: { hour: 2, minute: 30, meridiem: 'pm' },
              }}
            />
            <DatePicker
              size={size}
              type="range"
              label="Range"
              placeholder="Pick a range"
              defaultValue={{ start: null, end: null }}
            />
          </div>
        </div>
      ))}
    </div>

Booking — working demo

Working demo: a booking form that wires a range picker + time picker in sequence. The "Book" button stays disabled until both fields are set.
stay = · arrival =
tsx
{
    const [window, setWindow] = useState<DateRangeValue>({
      start: null,
      end: null,
    });
    const [arrival, setArrival] = useState<TimeValue | null>(null);

    const disabled = !window.start || !window.end || !arrival;

    return (
      <div style={col}>
        <div style={caption}>
          Working demo: a booking form that wires a range picker +
          time picker in sequence. The "Book" button stays disabled
          until both fields are set.
        </div>
        <div style={row}>
          <DatePicker
            type="range"
            label="Stay"
            required
            placeholder="Select check-in / out"
            value={window}
            onChange={setWindow}
          />
          <DatePicker
            type="time"
            label="Arrival time"
            required
            placeholder="Estimated arrival"
            value={arrival}
            onChange={setArrival}
          />
        </div>
        <div style={valueRow}>
          stay ={' '}
          {window.start && window.end
            ? `${window.start.toISOString().slice(0, 10)} → ${window.end.toISOString().slice(0, 10)}`
            : '—'}
          {' · '}
          arrival ={' '}
          {arrival
            ? `${String(arrival.hour).padStart(2, '0')}:${String(arrival.minute).padStart(2, '0')} ${arrival.meridiem.toUpperCase()}`
            : '—'}
        </div>
        <button
          type="button"
          disabled={disabled}
          style={{
            alignSelf: 'flex-start',
            padding: '0.5rem 1rem',
            borderRadius: '8px',
            border: '1px solid var(--oshon-color-primary-700, #038487)',
            background: disabled
              ? 'var(--oshon-color-neutral-200, #ececec)'
              : 'var(--oshon-color-primary-700, #038487)',
            color: disabled
              ? 'var(--oshon-color-neutral-600, #687576)'
              : 'white',
            cursor: disabled ? 'not-allowed' : 'pointer',
            font: 'inherit',
            fontWeight: 600,
          }}
        >
          Book
        </button>
      </div>
    );
  }

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<DatePicker
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

label

Visible field label rendered above the trigger. Accepts a `required` + `tooltip` flag for the Figma red asterisk and info glyph.

trigger

Clickable field that opens the popover. Shows the formatted value or the placeholder. Leading glyph is automatic (calendar / clock based on `type`).

panel

Floating popover panel — 288 px (single) or 576 px (range). Hosts the calendar grid + optional time footer. Carries the Figma drop shadow + inset highlight + backdrop-blur.

time-footer

Hour / minute numeric inputs, colon, divider, AM/PM radio pair. Reused between the `time` variant (standalone) and `datetime` variant (stacked under the calendar).

Keyboard

Trigger is a <button> with aria-haspopup="dialog" and aria-expanded reflecting open state. Panel opens on click or Enter/Space; closes on Escape, outside pointer-down, or after the terminal selection lands. Day cells are role="gridcell" inside role="row"/role="grid" — arrow keys + Home/End/PgUp/PgDn navigation is delegated to native button focus for now (Phase 4i). Month + Year selectors are Oshon <Menu> dropdowns opened via <Popover> — triggers carry aria-haspopup="menu" + aria-expanded; the menu itself ships arrow-key roving tabindex + typeahead from the Menu primitive. Time inputs accept numeric input; AM/PM is a role="radiogroup" pair. aria-current="date" marks today; aria-selected marks the active day.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-22

Do / Don't

✓ Do

Single date with a label
<DatePicker
  label="Date"
  required
  placeholder="Pick a date"
  defaultValue={new Date()}
  onChange={(d) => setDate(d)}
/>
Date + time bundle
<DatePicker
  type="datetime"
  label="Date and Time"
  defaultValue={{ date: new Date(), time: { hour: 2, minute: 30, meridiem: "pm" } }}
  onChange={(bundle) => setMeetingStart(bundle)}
/>
Inclusive date range
<DatePicker
  type="range"
  label="Shipping window"
  defaultValue={{ start: new Date(), end: null }}
  onChange={(range) => setShipWindow(range)}
/>
Time-only slot picker
<DatePicker
  type="time"
  label="Pickup time"
  defaultValue={{ hour: 9, minute: 30, meridiem: "am" }}
  onChange={(t) => setSlot(t)}
/>

✗ Don't

Bypassing the `type` axis by shipping two DatePickers side-by-side
<div><DatePicker label="Date" /><DatePicker type="time" label="Time" /></div>

The `datetime` variant wraps both inside a single popover — matches the Figma `Date & Time Picker` composition, keeps the keyboard journey linear (Tab once, not twice), and shares the `value` bundle so the consumer does not juggle two state atoms. Reach for two pickers only when the date and time live on unrelated form concerns.

Hardcoded teal for the selected day
className="bg-teal-700 text-white"

Breaks white-labeling (principle #6). Every color flows through `@oshon-ai/tokens` so `applyTheme({ primarySeed })` retints every picker at once. The selected day uses `--oshon-color-primary-700`; extend the tokens if you need a different primary tint.

Using DatePicker as a non-modal calendar inside a form row
<DatePicker />

DatePicker always floats its calendar inside a popover panel — the trigger is a form field, not the calendar itself. Consumers that want an inline, always-open calendar (booking widgets, availability pickers) should wait for the forthcoming `<Calendar>` primitive, which exposes the same grid without the popover shell.

Design rationale

One component with a four-value `type` axis rather than four siblings (DatePicker / TimePicker / DateTimePicker / DateRangePicker) because the four Figma compositions share ~85% of the same chrome — field, label row, popover panel, time footer, month/year selectors. Four separate components would triple the API surface (four manifests, four barrels, four size budgets) without adding capability. The `type="datetime"` variant intentionally re-uses the `<TimeFooter>` sub-component from `type="time"` so a consumer who switches between them sees an identical time-entry surface. Range mode falls back to a 576 px dual-calendar panel only when wide — a future mobile variant will stack vertically. Day cells are plain `<button>`s (not a single role="grid" with synthetic focus) because the grid dimensions are well within plain-DOM focus cost and the simpler markup keeps the test surface tight.