Preview
Pick a date in the future.
Installation
Install the runtime packages:
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add datepicker
Wire the tokens into your Tailwind v4 stylesheet:
/* 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.
'use client';
import { DatePicker } from '@oshon-ai/components';
export default function Example() {
return <DatePicker />;
}Date — single day
<select>s — tab into them to get the OS listbox.{
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><select></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
role="radiogroup". Use when the date is already fixed by context (meeting rooms, daily standups, appointment slots).{
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
value atom instead of two.{
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
{
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
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
<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
<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
{
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.
<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.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* 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 thedisabledprop
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.
labelVisible field label rendered above the trigger. Accepts a `required` + `tooltip` flag for the Figma red asterisk and info glyph.
triggerClickable field that opens the popover. Shows the formatted value or the placeholder. Leading glyph is automatic (calendar / clock based on `type`).
panelFloating 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-footerHour / 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
<DatePicker
label="Date"
required
placeholder="Pick a date"
defaultValue={new Date()}
onChange={(d) => setDate(d)}
/><DatePicker
type="datetime"
label="Date and Time"
defaultValue={{ date: new Date(), time: { hour: 2, minute: 30, meridiem: "pm" } }}
onChange={(bundle) => setMeetingStart(bundle)}
/><DatePicker
type="range"
label="Shipping window"
defaultValue={{ start: new Date(), end: null }}
onChange={(range) => setShipWindow(range)}
/><DatePicker
type="time"
label="Pickup time"
defaultValue={{ hour: 9, minute: 30, meridiem: "am" }}
onChange={(t) => setSlot(t)}
/>✗ Don't
<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.
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.
<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.