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, andnullonce 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 sendsnull, 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:
namesubmits the ISO date.
Example
Example
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
Example
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
Example
| Merchant | Date | Amount | Memo | Category |
|---|---|---|---|---|
| Corner Mart | 15 Oct 2026Changed | $ | Changed | |
| City Transit | 13 Oct 2026 | $Changed | Changed Changed | |
| Green Grocer | 12 Oct 2026 | $ Enter the amount paid. |
'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
todayfrom 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
valueLabelwherever 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 fromIntl, 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 yourvalueLabelin place of the segments; the segments replace it without moving anything. - In a grid, name each field with
aria-labeloraria-labelledby, and passisChangedwhen 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 |
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 |
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 |
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.
| Tab | Into the segments, then the button. |
|---|---|
| LeftRight | Move between segments, in the drawn direction. |
| UpDown | Change the focused segment. |
| 0-9Backspace | Type into or clear the focused segment. Focus moves on when it is full. |
| Alt+Down | Opens the calendar. |
| Escape | Closes 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.