Date picker
A date field that opens a Calendar in a Popover and closes on the pick.
Date picker is a date field: a button that shows the chosen date and opens a Calendar in a Popover to change it. Picking a day sets the value and closes the popover, and focus goes back to the button. It is built from Popover, Calendar, and Button, and it picks one day.
Usage
'use client';
import * as React from 'react';
import { DatePicker } from '@/components/ui/date-picker';
import { Field, FieldLabel } from '@/components/ui/field';
export function DueDateField() {
const [date, setDate] = React.useState<Date | undefined>();
return (
<Field>
<FieldLabel htmlFor="due">Due date</FieldLabel>
<DatePicker id="due" value={date} onChange={setDate} />
</Field>
);
}The value and the button
value is a Date or undefined, and onChange receives the new one. Date picker keeps no
value of its own, so set value from onChange, or the button keeps showing the old date.
Picking the chosen day again clears the value, and onChange receives undefined.
The button shows the date in date-fns's long format, like "October 1st, 2026", in English
whatever the page's language, and placeholder in muted text while there is no value. It is
an outline Button at sm, 36px tall and full width, with a calendar icon before the date,
and on a touch screen it grows to at least 44px. variant="ghost" drops the border and keeps
the hover wash, for a date that sits among other quiet property fields. id, disabled, and
className go on the button. The popover opens 4px under it, aligned to its start edge, and
holds the calendar with no padding.
Your own trigger
renderTrigger replaces the button with an element of your own and keeps the popover and the
calendar. It receives label, which is the formatted date or the placeholder, along with
value and disabled, and returns one element. That element becomes the popover's trigger
through asChild, so it has to forward its ref and pass its props to the DOM, as a kit Button
or Chip trigger does. id, variant, and className
only reach the built-in button, so set what you need on your element.
import { CalendarBlank } from '@phosphor-icons/react';
import { ChipTrigger } from '@/components/ui/chip-trigger';
import { DatePicker } from '@/components/ui/date-picker';
<DatePicker
value={due}
onChange={setDue}
placeholder="No due date"
renderTrigger={({ label, disabled }) => (
<ChipTrigger
size="sm"
caret="hover"
iconBefore={CalendarBlank}
disabled={disabled}
aria-label={`Due date: ${label}`}
>
{label}
</ChipTrigger>
)}
/>The trigger is yours, so aria-labelledby, aria-describedby, and an invalid style can go on
it.
Composing your own
Date picker takes its eight props and nothing else. It cannot disable days, bound the months, pick a range, set a locale, or submit with a form. For any of those, put Popover, Calendar, and Button together yourself, which is about as much code as Date picker is. This one offers only weekdays from today on.
'use client';
import { CalendarBlank } from '@phosphor-icons/react';
import { format } from 'date-fns';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Field, FieldLabel } from '@/components/ui/field';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export function ShipDateField() {
const [date, setDate] = React.useState<Date>();
const [open, setOpen] = React.useState(false);
const today = new Date();
return (
<Field>
<FieldLabel htmlFor="ship-date">Ship date</FieldLabel>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id="ship-date"
variant="outline"
size="sm"
iconBefore={CalendarBlank}
className={cn('w-full justify-start font-normal', !date && 'text-fg-muted')}
>
{date ? format(date, 'PPP') : 'Pick a weekday'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
required
selected={date}
startMonth={today}
disabled={[{ before: today }, { dayOfWeek: [0, 6] }]}
onSelect={(next) => {
setDate(next);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
</Field>
);
}required keeps a second click on the chosen day from clearing it. The button is yours, so
aria-describedby and any other attribute reach it, though an outline Button has no invalid
style of its own.
Props
Date picker takes these eight props and nothing else.
| Prop | Type | Default | What it does |
|---|---|---|---|
value | Date | The chosen date. Leave it undefined for none. | |
onChange | (date?: Date) => void | Called with the picked day, or with undefined when the chosen day is picked again. | |
placeholder | string | "Pick a date" | Shown in muted text while there is no value. |
id | string | Goes on the button, for a FieldLabel's htmlFor. | |
disabled | boolean | Disables the button, so the calendar cannot open. | |
className | string | Classes for the button. | |
variant | "outline" | "ghost" | "outline" | The button's variant. ghost has no border. |
renderTrigger | ({ label, value, disabled }) => ReactElement | Replaces the button. The element must forward its ref. id, variant, and className no longer apply. |
Accessibility
- The button has
aria-haspopup="dialog"andaria-expanded. Point a FieldLabel'shtmlForat itsid, and the label names it. - A label replaces the button's text as its name, so with one, a screen reader hears "Due
date" and not the chosen date, and without one it hears the date or the placeholder and not
what the field is for. The built-in button cannot take
aria-labelledbyoraria-describedbyto join the two, so when the date has to be read with its label, render your own trigger withrenderTriggerand name it there, as the chip above does. - A trigger from
renderTriggergetsaria-haspopup,aria-expanded, and the popover's open and close from the popover itself. Its size, focus ring, and name are its own. - Opening moves focus to the previous-month button. Tab moves on to the next-month button and then to the day in the grid, where the keys work as on Calendar. Tab does not leave the open popover.
- Picking a day or pressing Escape closes the popover and returns focus to the button. A click outside closes it and leaves focus where you clicked.
- The popover is a
dialogwith no name, and it is not modal, so the page behind stays available to assistive tech. - There is no field to type a date into, so the calendar is the only way to enter one.
- The button is 36px tall and at least 44px on a touch screen, and the days in the calendar grow to 44px there too.
- Under reduced motion the popover opens and closes without its fade.
When to use something else
- Calendar for a calendar that stays on the page, or to pick a range or several days. Date picker picks one day from a button.
Install
shadcn add @gesso/date-picker