# Date Picker

> A date field with a calendar popover on Base UI and calendar-core. Typed dates parse in the field's locale, months slide the way you page, the selection glides between days, and ranges preview as you hover.

- Tier: Free (MIT)
- Interaction: Select
- Page: https://eased.dev/components/date-picker
- Installs: @base-ui/react, motion
- Registry: https://eased.dev/r/date-picker.json

## Install

```bash
npx shadcn add https://eased.dev/r/date-picker.json
```

A date field with a calendar in a popover, on Base UI's Popover and Input, with the month grid, range selection and keyboard movement from calendar-core. `Calendar` is the same grid on its own, for an inline picker.

## When to use

- Picking one day for a form: a due date, a start date, a review. The field also takes typed dates, so keyboard users never need the grid.
- Picking a span: time off, a stay, a reporting period. Range mode previews the span as you hover and never spans a disabled day.
- An always-visible calendar on a page or in a panel: use `Calendar` directly.

## When not to use

- Dates far in the past, such as a birthday: paging month by month is slow; use three Selects or a plain Input.
- A date and a time together: pair this with a time Select, or build a scheduler on calendar-core's `timeGrid`.
- Relative choices ("in 3 days", "next Monday"): a Select or Segmented control is faster.

## Usage

```tsx example.tsx
import { useState } from "react";
import { DatePicker } from "@/components/eased/date-picker/date-picker";
import { Field } from "@/components/eased/field/field";

export function Example() {
  const [due, setDue] = useState<Date | null>(null);
  return (
    <Field label="Due date" description="Type a date like “Oct 24” or pick one.">
      <DatePicker value={due} onValueChange={setDue} min={new Date()} />
    </Field>
  );
}
```

## Examples

### A range

The trigger shows the span ("Oct 26 – 30, 2026"); click a start and an end, or drag across days. Label it with `aria-labelledby`, since a range has no text input for a Field label to point at.

```tsx
<span id="away-label">Away</span>
<DatePicker mode="range" aria-labelledby="away-label" value={away} onValueChange={setAway} maxLength={21} />
```

### Weekdays only

`isDateDisabled` blocks days in the grid and rejects them when typed.

```tsx
<DatePicker value={day} onValueChange={setDay} isDateDisabled={(d) => d.getDay() === 0 || d.getDay() === 6} />
```

### An inline calendar

```tsx
import { Calendar } from "@/components/eased/date-picker/date-picker";

<Calendar mode="range" value={stay} onValueChange={setStay} isDateDisabled={(d) => booked.has(toDayKey(d))} />
```

## API reference

### DatePicker

| Prop | Type | Default | Description |
|---|---|---|---|
| `mode` | `"single" \| "range"` | `"single"` | One day in a text field, or a span on a button. |
| `value` | `Date \| null` or `DateRange \| null` | | Controlled value. A `DateRange` is `{ start, end }`, both local midnight, inclusive. |
| `defaultValue` | same as `value` | `null` | Uncontrolled starting value. |
| `onValueChange` | `(value) => void` | | Fires when a day is picked or a typed date is committed (on blur or Enter). |
| `onInvalidInput` | `(text: string) => void` | | Called with the text when a typed date can't be read or isn't allowed; the field shows the last value again. |
| `min` / `max` | `Date` | | First and last selectable day. |
| `isDateDisabled` | `(date: Date) => boolean` | | Days that can't be picked. A range never spans one. |
| `minLength` / `maxLength` | `number` | | Range mode: fewest and most days. |
| `locale` | `string` | runtime | Parsing, formatting, month and weekday names. |
| `formatOptions` | `Intl.DateTimeFormatOptions` | `{ day, month: "short", year }` | How the value is written in the field. |
| `weekStartsOn` | `0`–`6` | the locale's | First column of the grid. |
| `today` | `Date` | now | The today mark, and the base for "tomorrow". Pass a fixed day for stable demos. |
| `closeOnSelect` | `boolean` | `true` | Close after a day, or a range's second end, is picked. |
| `open` / `defaultOpen` / `onOpenChange` | | | Control the popover. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | 32, 36 or 40px. |
| `align` | `"start" \| "center" \| "end"` | `"start"` | Popover alignment against the field. |
| `zIndex` | `number` | `50` | Raise it inside a dialog. |
| `className` | `string` | `""` | Classes for the bordered box. |

Single mode passes any other Base UI Input prop (`name`, `id`, `required`, `aria-*`) to the text input and takes `calendarLabel` (default "Choose date") for the calendar button. Range mode takes `id`, `name` (submits `YYYY-MM-DD/YYYY-MM-DD`), `aria-label` and `aria-labelledby`.

### Calendar

The same props as the picker's selection (`mode`, `value`, `defaultValue`, `onValueChange`, `min`, `max`, `isDateDisabled`, `minLength`, `maxLength`, `locale`, `weekStartsOn`, `today`), plus `defaultMonth`, `onMonthChange`, `autoFocus` and `onSelectComplete`.

### parseDate

`parseDate(text, { locale, today })` returns the local Date the field would read from `text`, or null. It accepts ISO dates, numeric dates in the locale's order ("24/10" in en-GB, "10/24" in en-US), month names in the locale or English ("24 Oct", "October 24 2026") and today, tomorrow or yesterday.

## Keyboard

| Key | Action |
|---|---|
| `↓` or `Alt` `↓` in the field | Opens the calendar on the selected day, or today. |
| `Enter` in the field | Reads the typed date. |
| `Escape` in the field | Drops what was typed. |
| `←` `→` `↑` `↓` | Moves a day or a week; crossing a month edge pages the grid. |
| `Home` / `End` | First or last day of the week. |
| `PageUp` / `PageDown` | Previous or next month; with `Shift`, a year. |
| `Enter` / `Space` | Picks the focused day (range: starts, then ends the span). |
| `Escape` | Range: drops a half-picked span and keeps the calendar open. Otherwise closes and returns focus. |

## Accessibility

- The grid is `role="grid"` named by the month, with `row`, `columnheader` (full weekday names) and `gridcell` parts. Each day is named in full ("Friday, October 23, 2026"), with `aria-selected`, `aria-disabled` and `aria-current="date"` for today.
- One day is in the tab order (the selected day, else today); the popover opens with focus on it and returns focus to the trigger when it closes.
- The month title is a polite live region, so paging is announced.
- Disabled days stay focusable, so arrowing across a booked night doesn't skip silently.

## Motion

Paging slides the outgoing month 14px out and the new one 28px in from the side you moved towards, on the smooth spring with a 200ms fade in and a 150ms fade out, while the title swaps up or down with a blur; paging again mid-slide redirects. The selected day's fill is one shared-layout element that glides to the new day on the snappy spring. A range's ends pop from 0.8 scale on snappy and the band between them fills in 150ms; a hovered preview shows at lower opacity until it's committed. The popover grows from the field on Eased's floating motion. Reduced motion: no slide, glide or pop; changes fade in 100ms.

## Notes for AI

Pick DatePicker for any form field that holds a date or a span of dates; pick Calendar when the grid should always be visible. Set `min`, `max` and `isDateDisabled` rather than validating after the fact: they gate the grid and typed input alike. Pass `locale` explicitly when rendering on the server, and a fixed `today` in demos and tests.

Don't wrap it in another Popover or add your own text parsing; the single field already reads typed dates and the range button already formats the span. Values are local midnight Dates; convert to UTC yourself if your API needs it.

## Related

- [Input](/components/input): the text field the single picker is built on.
- [Select](/components/select): for short lists of dates or relative choices.
- [Popover](/components/popover): the floating layer the calendar opens in.

## Example

`components/eased/date-picker/date-picker-demo.tsx`

```tsx
"use client";

import { useId, useState } from "react";
import { addDays, isWeekend, type DateRange } from "../_primitives/calendar-core/calendar-core";
import { Field } from "../field/field";
import { DemoFrame } from "../lib/demo-frame";
import { DatePicker } from "./date-picker";

// A fixed "today", so the demo reads the same on every visit.
const TODAY = new Date(2026, 9, 18);
const LOCALE = "en-US";

const workingDays = (r: DateRange) => {
  let n = 0;
  for (let d = r.start; d <= r.end; d = addDays(d, 1)) if (!isWeekend(d)) n++;
  return n;
};

export function DatePickerDemo() {
  const awayLabel = useId();
  const [away, setAway] = useState<DateRange | null>({ start: new Date(2026, 9, 26), end: new Date(2026, 9, 30) });
  const [handover, setHandover] = useState<Date | null>(new Date(2026, 9, 23));
  const days = away ? workingDays(away) : 0;
  const lastDayBefore = away ? addDays(away.start, -1) : null;

  return (
    <DemoFrame title="Time off" meta={away ? `${days} working ${days === 1 ? "day" : "days"}` : "Not set"}>
      <div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
        <div className="flex flex-col gap-1.5">
          <span id={awayLabel} className="w-fit text-ui font-medium text-text">
            Away
          </span>
          <DatePicker mode="range" aria-labelledby={awayLabel} value={away} onValueChange={setAway} min={TODAY} maxLength={21} today={TODAY} locale={LOCALE} />
        </div>

        <Field
          label="Handover review"
          description="Type a date like “Oct 23” or “tomorrow”, or pick one. Weekdays only."
          error={handover && lastDayBefore && handover > lastDayBefore ? "Hold the review before your first day away." : undefined}
        >
          <DatePicker
            value={handover}
            onValueChange={setHandover}
            min={TODAY}
            isDateDisabled={isWeekend}
            today={TODAY}
            locale={LOCALE}
            placeholder="Pick a day"
          />
        </Field>
        <dl className="mt-auto grid grid-cols-[1fr_auto] gap-x-4 gap-y-1.5 border-t border-line-subtle pt-4 text-ui-sm">
          <dt className="text-text-secondary">Balance after</dt>
          <dd className="text-right text-text tabular-nums">{Math.max(0, 14 - days)} of 20 days</dd>
          <dt className="text-text-secondary">Approver</dt>
          <dd className="text-right text-text">Jonas Weber</dd>
        </dl>
      </div>
    </DemoFrame>
  );
}
```
