One date, typed or chosen on a calendar, in and out as an ISO date.

Basic

A field for one day. Type the date into its segments, in the locale's order, or choose it on the calendar. The button at the field's end, or Alt+Arrow Down, opens the calendar on the value's month, or on today's month when there is no value.

  • Value: an ISO date, '2026-10-14', in and out. Days only, never times.
  • onChange: receives the ISO date once every segment is typed and the year has its four digits, and null once every segment is cleared. A year being typed or erased is held back: 2, 20 and 202 are never sent. Erasing the year empties the field and sends null, and leaving the field with a year cut short does too, so a required field then shows itself invalid. The hidden input and the face agree with what you hold. Days before the year 1000 cannot be entered.
  • Forms: name submits the ISO date.

Example

DateChanged
14 Oct 2026
The day on the receipt.
Follows the page theme and corners
import { DatePicker } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function DatePickerBasic(): ReactElement {
  return (
    <div className="stack" style={{ width: '100%', maxWidth: '20rem' }}>
      <DatePicker
        label="Date"
        description="The day on the receipt."
        defaultValue="2026-10-14"
        // The app's own words for the day, shown before the page hydrates.
        valueLabel="14 Oct 2026"
        today="2026-10-16"
        name="date"
      />
    </div>
  );
}

Presets

presets adds a row of days you name above the calendar: "Today", "Yesterday". The library computes no dates: compute each from your own today, ideally on the server. A press sets the day, closes the calendar and returns focus to the button. A preset is pressed while its day is chosen, and disabled when its day falls outside minValue or maxValue or isDateUnavailable rejects it.

Example

Paid onChanged
16 Oct 2026
Follows the page theme and corners
import { DatePicker, type DatePreset } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

/** The app's days, computed on the server from its own today, 2026-10-16, with its own words. */
const PRESETS: DatePreset[] = [
  { id: 'today', label: 'Today', date: '2026-10-16' },
  { id: 'yesterday', label: 'Yesterday', date: '2026-10-15' },
];

export default function DatePickerPresets(): ReactElement {
  return (
    <div className="stack" style={{ width: '100%', maxWidth: '20rem' }}>
      <DatePicker
        label="Paid on"
        defaultValue="2026-10-16"
        valueLabel="16 Oct 2026"
        today="2026-10-16"
        presets={PRESETS}
      />
    </div>
  );
}

In a review grid

A grid of fields with no label rows: each field is named by aria-labelledby, pointing at its column's header and its row's name, so a screen reader hears "Date Corner Mart". A changed field with no label has no chip to show, so it draws the changed dot instead: a clay dot in its frame, before its button or at its text's end, and a hidden "Changed" read after its name. An invalid field shows its error edge and line, and no dot. Compact density draws the 36px field.

Example

MerchantDateAmountMemoCategory
Corner Mart
15 Oct 2026Changed
$
Changed
City Transit
13 Oct 2026
$Changed
Changed
Green Grocer
12 Oct 2026
$
Enter the amount paid.
Unsaved changes5 changes to 3 receipts
Follows the page theme and corners
'use client';

import { DatePicker, NumberField, PickerOption, SaveBar, Select, TextField } from '@snapshot-ui/react';
import { useState, type CSSProperties, type ReactElement } from 'react';

interface Receipt {
  id: string;
  merchant: string;
  /** An ISO date, or null once the reader has erased it. */
  date: string | null;
  /** In cents. */
  amount: number;
  memo: string;
  category: string;
}

/** What the server holds: the reader edits a copy, and each field compares its value with this one. */
const [CORNER_MART, CITY_TRANSIT, GREEN_GROCER]: readonly [Receipt, Receipt, Receipt] = [
  {
    id: 'r1',
    merchant: 'Corner Mart',
    date: '2026-10-14',
    amount: 4218,
    memo: 'Milk and bread',
    category: 'food',
  },
  {
    id: 'r2',
    merchant: 'City Transit',
    date: '2026-10-13',
    amount: 275,
    memo: 'Bus fare',
    category: 'transport',
  },
  { id: 'r3', merchant: 'Green Grocer', date: '2026-10-12', amount: 1890, memo: 'Apples', category: 'food' },
];

/** Each saved receipt beside the reader's edits so far: a date and a memo, an amount and a category, and one
    amount cleared. */
const REVIEW: readonly { saved: Receipt; edited: Receipt }[] = [
  { saved: CORNER_MART, edited: { ...CORNER_MART, date: '2026-10-15', memo: 'Milk, bread and eggs' } },
  { saved: CITY_TRANSIT, edited: { ...CITY_TRANSIT, amount: 550, category: 'home' } },
  { saved: GREEN_GROCER, edited: { ...GREEN_GROCER, amount: 0 } },
];

const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

/** The app's own words for a day, "15 Oct 2026", for the server's HTML: twelve month names, no Intl. */
const dayLabel = (iso: string): string => {
  const [year, month, day] = iso.split('-').map(Number);
  return `${String(day)} ${MONTHS[(month ?? 1) - 1] ?? ''} ${String(year)}`;
};

const COLUMNS = ['Merchant', 'Date', 'Amount', 'Memo', 'Category'];
const head: CSSProperties = { textAlign: 'start', fontWeight: 500, paddingBlockEnd: 4 };

export default function DatePickerReviewGrid(): ReactElement {
  const [rows, setRows] = useState(REVIEW);
  const change = (id: string, patch: Partial<Receipt>): void => {
    setRows((current) =>
      current.map((each) =>
        each.edited.id === id ? { ...each, edited: { ...each.edited, ...patch } } : each,
      ),
    );
  };
  // How many fields differ from the server's, and how many invalid: the save bar's sentence and its status.
  const fields = ['date', 'amount', 'memo', 'category'] as const;
  const changes = rows.reduce(
    (sum, { saved, edited }) => sum + fields.filter((key) => edited[key] !== saved[key]).length,
    0,
  );
  const invalid = rows.filter(({ edited }) => edited.amount <= 0).length;
  const [left, setLeft] = useState(2);
  const [problem, setProblem] = useState('');
  return (
    // The holder of the grid and its save bar, whose last child the bar is; never wider than the stage, so the
    // grid scrolls inside it.
    <div style={{ width: '100%', minWidth: 0 }}>
      {/* The grid scrolls sideways inside its own box on a narrow screen; the page never does. */}
      <div style={{ width: '100%', overflowX: 'auto' }}>
        <table style={{ borderCollapse: 'separate', borderSpacing: '8px 12px', marginInline: -8 }}>
          <thead>
            <tr>
              {COLUMNS.map((name) => (
                <th key={name} id={`receipt-${name.toLowerCase()}`} scope="col" style={head}>
                  {name}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {rows.map(({ saved, edited: row }) => {
              // Each field is named by its column's header and its row's merchant: "Date Corner Mart".
              const named = (column: string): string => `receipt-${column} ${row.id}`;
              return (
                <tr key={row.id}>
                  <th id={row.id} scope="row" style={{ ...head, fontWeight: 450, whiteSpace: 'nowrap' }}>
                    {row.merchant}
                  </th>
                  <td style={{ minWidth: '12rem' }}>
                    <DatePicker
                      aria-labelledby={named('date')}
                      value={row.date}
                      valueLabel={row.date === null ? undefined : dayLabel(row.date)}
                      today="2026-10-16"
                      isChanged={row.date !== saved.date}
                      // A year being typed is held back, so null means the reader erased the day.
                      isInvalid={row.date === null}
                      errorMessage="Enter the date on the receipt."
                      onChange={(date) => {
                        change(row.id, { date });
                      }}
                    />
                  </td>
                  <td style={{ minWidth: '9rem' }}>
                    <NumberField
                      aria-labelledby={named('amount')}
                      money={{ symbol: '$' }}
                      value={row.amount}
                      isChanged={row.amount !== saved.amount}
                      isInvalid={row.amount <= 0}
                      errorMessage="Enter the amount paid."
                      onChange={(amount) => {
                        change(row.id, { amount: amount ?? 0 });
                      }}
                    />
                  </td>
                  <td style={{ minWidth: '12rem' }}>
                    <TextField
                      aria-labelledby={named('memo')}
                      value={row.memo}
                      isChanged={row.memo !== saved.memo}
                      onChange={(memo) => {
                        change(row.id, { memo });
                      }}
                    />
                  </td>
                  <td style={{ minWidth: '10rem' }}>
                    <Select
                      aria-labelledby={named('category')}
                      value={row.category}
                      isChanged={row.category !== saved.category}
                      onChange={(category) => {
                        if (typeof category === 'string') change(row.id, { category });
                      }}
                    >
                      <PickerOption id="food">Food</PickerOption>
                      <PickerOption id="transport">Transport</PickerOption>
                      <PickerOption id="home">Home</PickerOption>
                    </Select>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      <SaveBar
        message={`${String(changes)} ${changes === 1 ? 'change' : 'changes'} to 3 receipts`}
        isDirty={changes > 0}
        actionLabel={`Confirm and review next (${String(left)} left)`}
        // Never disabled: a press with an invalid field is answered in the status.
        onAction={() => {
          if (invalid > 0) {
            setProblem(`${String(invalid)} ${invalid === 1 ? 'field needs' : 'fields need'} attention.`);
            return;
          }
          setProblem('');
          setRows((current) => current.map(({ edited }) => ({ saved: edited, edited })));
          setLeft((count) => Math.max(0, count - 1));
        }}
        onCancel={() => {
          setProblem('');
          setRows((current) => current.map(({ saved }) => ({ saved, edited: saved })));
        }}
        status={problem}
        statusTone="error"
      />
    </div>
  );
}

Usage

  • ISO in and out, Gregorian whatever the calendar shown: a Persian page shows Mehr 1405 and still sends '2026-10-14'.
  • Pass today from your user's time zone, as an ISO date. It marks today and opens the calendar on its month when there is no value.
  • Pass valueLabel wherever the server's HTML should show the day, written like any other string in the app: "14 Oct 2026". The segments' text, order and names come from Intl, which the server must not write. Until hydration the field draws the label, the frame at its final size, the button, the hidden input and your valueLabel in place of the segments; the segments replace it without moving anything.
  • In a grid, name each field with aria-label or aria-labelledby, and pass isChanged when its value differs from the saved one.
  • A value that is not an ISO date reads as none and warns in the console. A hand-edited URL never throws.
  • Inside a Data table cell, or a list item at a narrow width, the segments keep their keys while they have focus: the arrows change and move between segments, digits type, and J types nothing. To leave the field, press Escape, which returns focus to its row, where the arrows move between rows and cells again; with the calendar open, the first Escape closes it and the second leaves the field. Tab moves on to the button and then to the next field in the table, field to field like a form, and out of the table after the last.

Props

Every date is an ISO string. name, isRequired, isDisabled, isReadOnly, isInvalid, firstDayOfWeek, isOpen, onOpenChange, shouldCloseOnSelect and autoFocus are React Aria's. validate receives the ISO date as onChange does.

aria-label
string

Defines a string value that labels the current element.

aria-labelledby
string

Identifies the element (or elements) that labels the current element.

changedLabel
string

The words of the Changed chip, or of the changed dot, which holds them unseen.

Default: 'Changed'

className
string | ((values: DatePickerRenderProps & { defaultClassName: string | undefined; }) => string)

A class name, or a function of the field's state, merged after the library's own classes.

defaultValue
string | null

The day the field starts with when value is not given.

description
ReactNode

A hint under the field. It becomes the group's accessible description.

errorMessage
((validation: ValidationResult) => ReactNode) | ReactNode

The error line, shown only while the field is invalid. A function receives the validation result. Without it the field shows its own validation messages.

isChanged
boolean

Whether the value differs from the saved one: the light signal orange edge, with the clay Changed chip beside a label, or the clay changed dot in the frame without one. A screen reader reads "Changed" with the group either way.

isDateUnavailable
((date: string) => boolean)

Days that cannot be chosen, such as a closed day. Receives an ISO date.

isDisabled
boolean

Whether the input is disabled.

isInvalid
boolean

Whether the input value is invalid.

isReadOnly
boolean

Whether the input can be selected but not changed by the user.

isRequired
boolean

Whether user input is required on the input before form submission.

label
ReactNode

The field's name, shown above it. Required unless the field has an aria-label or an aria-labelledby.

labels
Partial<DatePickerLabels>

The names the field writes, in English unless replaced.

maxValue
string

The latest day that can be chosen, as an ISO date.

minValue
string

The earliest day that can be chosen, as an ISO date.

name
string

The name of the input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname).

onChange
((value: string | null) => void)

Called with the Gregorian ISO date once complete and valid, its year all four digits, and with null when cleared, when the year is erased, or when the field is left with its year cut short. A year being typed or erased is held back: 2, 20 and 202 are never sent.

placement
Placement

Where the calendar opens.

Default: 'bottom start'

popoverClassName
string

Goes on the popover around the calendar.

presets
readonly DatePreset[]

Days the app names, in a row above the calendar; a press sets the day and closes the calendar.

today
string

Today in the app's time zone, as an ISO date. Marks today and sets the opening month when there is no value. Defaults to the device's today.

validate
((value: string | null) => ReturnType<NonNullable<RACDatePickerProps<CalendarDate>['validate']>>)

Checks the day as onChange sends it, alongside React Aria's own checks, so use isRequired for an empty field. Returns an error message, or true or null when valid.

value
string | null

The day as an ISO date, 'YYYY-MM-DD', or null for none. Gregorian whatever the calendar shown.

valueLabel
string

The day as the app writes it, shown on the server and until hydration: "14 Oct 2026".

date
string

Its day as an ISO date, computed by the app from its own today.

id
Key

label
string

The preset's words, the app's: "Yesterday".

next
string

Default: 'Next month'

open
string

The calendar button's name; the field's label follows it.

Default: 'Choose date'

presets
string

The name of the row of presets.

Default: 'Presets'

previous
string

Default: 'Previous month'

Accessibility

The field is a group named by its label, its aria-label or its aria-labelledby. Each segment is a spinbutton named by its type and the field's name: "month, Date". On iOS, each segment is a text box. The button, named "Choose date" and the field's name, opens a dialog with the calendar, and the presets are a group named "Presets".

"Choose date", "Previous month", "Next month" and "Presets" come from labels. Segment type names come from the browser. React Aria's announcements, such as the chosen date, have no Persian.

A changed field with no label is described by a hidden "Changed", from changedLabel, which leaves while the field is invalid.

TabInto the segments, then the button.
LeftRightMove between segments, in the drawn direction.
UpDownChange the focused segment.
0-9BackspaceType into or clear the focused segment. Focus moves on when it is full.
Alt+DownOpens the calendar.
EscapeCloses the calendar and returns focus to what opened it. In a data table cell, with the calendar closed, returns focus to the row.

Design rules

What keeps date fields in the system.

Pass valueLabel where the server HTML should show the day.

Compute presets from the today of the app, on the server.

Name each field of a grid by its column and its row.

Never put times in the field.

Never mark a change with color alone: pass isChanged.

Never show a date string the app did not write.

esc

↑ ↓ to move↵ to open