Gesso
Forms

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.

PropTypeDefaultWhat it does
valueDateThe chosen date. Leave it undefined for none.
onChange(date?: Date) => voidCalled with the picked day, or with undefined when the chosen day is picked again.
placeholderstring"Pick a date"Shown in muted text while there is no value.
idstringGoes on the button, for a FieldLabel's htmlFor.
disabledbooleanDisables the button, so the calendar cannot open.
classNamestringClasses for the button.
variant"outline" | "ghost""outline"The button's variant. ghost has no border.
renderTrigger({ label, value, disabled }) => ReactElementReplaces the button. The element must forward its ref. id, variant, and className no longer apply.

Accessibility

  • The button has aria-haspopup="dialog" and aria-expanded. Point a FieldLabel's htmlFor at its id, 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-labelledby or aria-describedby to join the two, so when the date has to be read with its label, render your own trigger with renderTrigger and name it there, as the chip above does.
  • A trigger from renderTrigger gets aria-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 dialog with 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

It brings Calendar, Popover, and Button.

On this page