Skip to content

Date picker

Picks a day, typed in the field or chosen from a calendar under it. Holds a day with no time, like a Date field.

Installation

pnpm dlx shadcn@latest add @wisty/date-picker

The CLI installs the Radix or Base UI file to match your project, with what it builds on: button, calendar, input-group.

If your project doesn't have Wisty UI's theme yet, add it once with npx shadcn@latest add @wisty/cosmos. Until then, components keep shadcn's default look. Installation has the details.

When to use

Use it

  • People enter one day in a form, like a close date, a due date or a birthday.
  • People type the date or pick it from a calendar under the field. The field shows it in the locale's style, "Jun 18, 2026" by default, and dateStyle changes it.
  • The value is a day with no time and no time zone, like a Salesforce Date field. The date picker holds it as "YYYY-MM-DD".
  • Some days can't be picked, like dates before today. Pass min and max.
  • It replaces lightning-input with type="date".

Don't use it

  • The value is a date and a time, like a Salesforce Date/Time field. The date picker holds a day only.
  • People pick a range, like a reporting period. Use two date pickers, each bounding the other with min and max, as the reporting period example does.
  • The calendar is always on the page, not in a field. Use Calendar.
  • A button opens the calendar, as in Salesforce's React template. Compose Calendar in a Popover. People type in the date picker's field.
  • The date is only read. Show it as text.

Usage

import {
  DatePicker,
  DatePickerCalendar,
  DatePickerContent,
  DatePickerControl,
  DatePickerInput,
  DatePickerToday,
  DatePickerTrigger,
} from "@/components/wisty/date-picker"
DatePicker
├── DatePickerControl
│   ├── DatePickerInput
│   └── DatePickerTrigger
└── DatePickerContent
    ├── DatePickerCalendar
    └── DatePickerToday

DatePicker holds the value, the typed text and the calendar's state; the other parts only draw. onValueChange gets the day, or null with the reason when the text isn't a date the field accepts. Keep the reason to show a message:

"use client"

import { useState } from "react"

import { type DatePickerError } from "@/components/wisty/date-picker"
export function CloseDate() {
  const [closeDate, setCloseDate] = useState<string | null>(null)
  const [error, setError] = useState<DatePickerError>()

  return (
    <DatePicker
      name="CloseDate"
      value={closeDate}
      onValueChange={(value, details) => {
        setCloseDate(value)
        setError(details.error)
      }}
    >
      <DatePickerControl>
        <DatePickerInput aria-label="Close date" />
        <DatePickerTrigger />
      </DatePickerControl>
      <DatePickerContent>
        <DatePickerCalendar />
        <DatePickerToday />
      </DatePickerContent>
    </DatePicker>
  )
}

The field reads "YYYY-MM-DD", the locale's numeric order (10/12/2026 in en-US) and a month name with a day and a year (Oct 12, 2026; Sept works too), in the locale's own digits; a two-digit year is in the 2000s. error is "format" for text it can't read, and "min" or "max" for a day outside the bounds. In a form, name submits the day as "YYYY-MM-DD". To accept other text, like "next Friday", pass your own parse.

In Radix projects, the calendar's popover is still built on @base-ui/react, so installing Date picker adds that package beside radix-ui. The two work side by side.

Examples

Min and max

Days outside `min` and `max` are disabled, the year picklist stops at their years, and a typed day outside them is an error.

From today to the end of the quarter.

States

A required field showing the error `onValueChange` reports (empty or bad text), and a `disabled` one holding a date.

Reporting period

Two date pickers for a period, each bounding the other with `min` and `max`: there's no range picker.

Reporting period

In a dialog

A due date in a modal dialog: the calendar opens over it, and Escape closes the calendar first, then the dialog.

API reference

PartBuilt on
DatePicker
DatePickerControlInputGroup
DatePickerInput<input> (opens in a new tab)
DatePickerTriggerInputGroupButton
DatePickerContentBase UI Popover.Popup (opens in a new tab)
DatePickerCalendarCalendar
DatePickerTodayButton

DatePicker

PropTypeDefaultDescription
valuestring | nullThe day, as "YYYY-MM-DD", or null when the field is empty or its text isn't accepted.
defaultValuestring | nullnull
onValueChange( value: string | null, details: DatePickerChangeDetails ) => void
minstringThe earliest day people can pick or type, as "YYYY-MM-DD".
maxstringThe latest day people can pick or type, as "YYYY-MM-DD".
todaystringThe day the calendar marks as today, as "YYYY-MM-DD". Default the browser's date.
localestring"en-US"Formats and reads dates.
dateStyle"short" | "medium" | "long""medium"How the field shows the date.
parseDatePickerParseparseDateReads typed text into "YYYY-MM-DD", or null when it isn't a date.
namestringSubmits the value as "YYYY-MM-DD" with a form, from a hidden input.
requiredbooleanfalse
disabledbooleanfalse
readOnlybooleanfalse

DatePickerCalendar

PropTypeDefaultDescription
captionLayout"label" | "dropdown" | "dropdown-months" | "dropdown-years""dropdown-years"Show dropdowns to navigate between months or years.

DatePickerToday

PropTypeDefault
childrenReactNode"Today"

parseDatefunction

Reads a typed date: "YYYY-MM-DD", the locale's numeric order, or a month name (cut short too) with a day and a year, in the locale's digits. A two-digit year is in the 2000s. Returns "YYYY-MM-DD", or null when it isn't a date.

ParameterTypeDefault
text*string
__1*{ locale: string }

Returns string | null.

DatePickerErrortype

type DatePickerError = "format" | "min" | "max"

DatePickerChangeDetailstype

MemberTypeDefaultDescription
text*stringThe field's text when the value changed.
errorDatePickerErrorWhy the text isn't a date the field accepts. Unset when it is.

DatePickerParsetype

type DatePickerParse = (
  text: string,
  options: { locale: string }
) => string | null

SLDS mapping

The date picker is SLDS's datepicker as lightning-input draws it. DatePickerControl is the form element's control with an icon on the right (slds-input-has-icon_right), DatePickerInput is slds-input, and DatePickerTrigger is the icon button inside it (slds-input__icon_right). DatePickerContent is slds-datepicker in a dropdown under the field's start edge (slds-dropdown slds-dropdown_left). DatePickerCalendar draws its header (slds-datepicker__filter: the month between the arrows, then the year picklist) and its grid (slds-datepicker__month). DatePickerToday is the Today link (slds-text-link).

Known gaps:

  • SLDS swaps in the phone's own date input on mobile. The date picker keeps its field and calendar.
  • No "date format visible" help under the field.
  • The calendar opens right under the field, over help or error text. SLDS opens it under the whole form element.
  • The year picklist's arrow is a chevron, where SLDS draws two arrows, and the calendar icon is lucide's, where SLDS draws utility:event.
  • The text the date picker adds ("Select a date", "Today", "today" and "selected" in each day's name) is English. Month and weekday names follow locale.
PropValueSLDSNotes
(no value)default (added)slds-form-element slds-dropdown-trigger, slds-input-has-icon_right, slds-input__icon_right, slds-datepicker slds-dropdown slds-dropdown_leftA typed field with the calendar button on the right, and the calendar under the field's start edge.
dateStyleshort, medium, long (added)lightning-input date-styleHow the field shows the day: 6/18/26, Jun 18, 2026 (the default) or June 18, 2026, in en-US.
locale(a locale) (added)lightning-input's user localeThe field's format and what it reads, and the calendar's names, digits and first day of the week. Defaults to en-US.
min, max(a day) (added)slds-disabled-text (days), slds-has-errorDays outside are disabled, the year picklist runs from min's year to max's, and a typed day outside is an error.
captionLayoutdropdown-years (added)slds-datepicker__filter, slds-datepicker__filter_month, slds-selectOn DatePickerCalendar, the default: the month between the arrows and the year picklist after them.
aria-invalidtrue (added)slds-has-errorSet by the date picker for text it can't read or a day outside the bounds, or by the caller.
disabledtrue (added)slds-input[disabled], slds-button_icon[disabled]

Keyboard interactions

Keys Action
Enter or Space on the calendar icon Opens the calendar with focus on the picked day, or today.
Down Arrow in the field Opens the calendar the same way.
Enter in the field Reads the typed text and shows it in the field's style, or marks it invalid. Leaving the field does the same.
Arrow keys Move a day, or a week.
Home, End Move to the start or end of the week.
Page Up, Page Down Move a month. With Shift, a year.
Enter or Space on a day Picks it, closes the calendar and returns focus to the field.
Tab, Shift+Tab Move between the arrows, the year picklist, the days and Today. Focus stays in the calendar.
Escape Closes the calendar and returns focus to the field. Inside a modal dialog, the first Escape closes only it.

A click in the field opens the calendar and keeps focus there, so people can type.

Accessibility

  • Name the field with a FieldLabel whose htmlFor is DatePickerInput's id, or pass aria-label to it.
  • For an error you decide, like an empty required field, set aria-invalid on DatePickerInput and point aria-describedby at the message. The date picker sets aria-invalid itself, and the input's validity, for text it can't read, days outside min and max, and a value that isn't a day.
  • In Lightning Blue, today's day in the calendar is 3.67:1, and in Cosmos dark the error text is about 4.3:1, both below WCAG AA's 4.5:1. SLDS's own stylesheet does the same.

Responsive behavior

  • The field fills its parent's width. Set the width on the parent or the Field.
  • The calendar is 280px wide. It opens below the field, or above when there's no room below, and never beside it.

Rules

  • Keep the value a "YYYY-MM-DD" string. A Date or a timestamp can land on the day before in another time zone.
  • Pass a value that names a real day. One that doesn't, like "2023-02-29", shows as it is with a "format" error, selects nothing in the calendar and submits nothing; it's never rolled to the next day, and onValueChange isn't called for it.
  • Changing locale or dateStyle shows the value again in the new style. Text typed and not yet committed is replaced, never read in the new locale.
  • Disabled, the date picker submits nothing with a form, as a disabled input doesn't.
  • Keep DatePickerInput and DatePickerTrigger inside DatePickerControl, and DatePickerCalendar inside DatePickerContent: the calendar opens under the control, and the root wires them together.
  • Never pass asChild or render.