Date Picker

Free · MIT

Date picker and calendar with no date library: single, range (two months side by side) and multiple modes, presets, time inputs, min/max, disabled weekdays, booked days, ISO week numbers, month/year quick switch, typed dates parsed in the locale's order via Intl with the locale's first weekday, popover or inline, six field variants, tones, solid/soft/ring selection, day shapes, sizes and slide, fade or scale month animations with APG grid keyboard.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/date-picker.json

The file lands in components/goodui/date-picker.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/date-picker works too.

Usage

import * as React from "react"
import { DatePicker, type DateRange } from "@/components/goodui/date-picker"

export function BookingFields({ bookedDays }: { bookedDays: Date[] }) {
  const [when, setWhen] = React.useState<Date | null>(null)
  const [period, setPeriod] = React.useState<DateRange | null>(null)
  const [today, setToday] = React.useState<Date>()
  React.useEffect(() => setToday(new Date()), [])

  return (
    <form className="grid gap-4">
      <label htmlFor="when" className="text-sm font-medium">Appointment</label>
      <DatePicker
        id="when"
        className="w-full"
        time
        minuteStep={30}
        min={today}
        disabledWeekdays={[0]}
        booked={bookedDays}
        presets
        value={when}
        onValueChange={setWhen}
        name="startsAt"
      />
      <DatePicker mode="range" presets max={today} value={period} onValueChange={setPeriod} aria-label="Report period" />
    </form>
  )
}

When to use

Use it for
  • Booking forms: pick a day and a time, with past days, closed weekdays and fully booked days blocked.
  • Report filters: a date range with presets (Last 7 days, This month…) and two months side by side.
  • Picking several separate days (staff days off, class dates) with mode="multiple".
  • Any app that needs locale-correct names, digits, typed formats and first day of the week without shipping date-fns or dayjs.
Reach for something else
  • Birth dates and other far-away dates people know by heart: three selects or a plain typed field are faster than paging through years.
  • A week or month calendar of events with drag and drop: use the scheduler.
  • Only a time of day: use a time input.
If you needUse
Formsvariant="outline" (default) with a <label htmlFor> pointing at id
Appointmentstime, min, disabledWeekdays and booked; minuteStep for the slot length
Reports and analyticsmode="range" with presets; max={today} to block the future
Always-visible calendar in a page or sidebarinline
Several separate daysmode="multiple" (popover stays open, Done closes)
Calm UIselection="soft" or "ring", animation="fade"
Playful or retro brandsvariant="neo", shape="square"

Examples

Inline range for a stay, with week numbers

<DatePicker inline mode="range" months={2} weekNumbers booked={booked} value={stay} onValueChange={setStay} aria-label="Stay dates" />

Custom presets

<DatePicker
  mode="range"
  aria-label="Billing period"
  presets={[
    { label: "This quarter", value: (t) => ({ from: new Date(t.getFullYear(), Math.floor(t.getMonth() / 3) * 3, 1), to: t }) },
    { label: "Year to date", value: (t) => ({ from: new Date(t.getFullYear(), 0, 1), to: t }) },
  ]}
/>

German locale (Monday first, 24.12.2026)

<DatePicker
  locale="de-DE"
  aria-label="Rechnungsdatum"
  labels={{ openCalendar: "Kalender öffnen", dialog: "Datum wählen", prevMonth: "Vorheriger Monat", nextMonth: "Nächster Monat", today: "Heute", formatDay: "TT", formatMonth: "MM", formatYear: "JJJJ" }}
/>

API reference

PropTypeDefault
id
Goes on the text input, so <label htmlFor> works.
string—
aria-label
Defines a string value that labels the current element. Names the field or the inline calendar.
string—
locale
BCP 47 locale for names, digits, the typed format and the first day of the week. Defaults to the GooduiLocaleProvider language, else en-US.
string—
weekStartsOn
0 = Sunday … 6 = Saturday. Defaults to the locale's first day (Intl.Locale week info), else Monday (Sunday for en-US).
number—
min
Earliest selectable day.
Date—
max
Latest selectable day.
Date—
disabledDays
Days that cannot be picked: a list of dates or a test function.
Date[] | ((date: Date) => boolean)—
disabledWeekdays
Weekdays that cannot be picked (0 = Sunday), e.g. [0, 6] for weekends.
number[]—
booked
Days shown as booked (struck through) and not pickable. A range cannot span a booked or disabled day.
Date[] | ((date: Date) => boolean)—
weekNumbers
ISO week numbers in a first column.
boolean—
months
Months shown side by side. Default: 2 for range on wide screens, else 1.
1 | 2—
presets
true for the built-in presets of the mode, or your own list.
boolean | DatePickerPreset[]—
time
Adds time inputs (single: one, range: start and end).
boolean—
minuteStep
Minute step of the time inputs.
number—
inline
Render the calendar on the page instead of a field with a popover.
boolean—
placeholder
Field placeholder. Defaults to the locale's format, e.g. MM/DD/YYYY.
string—
variant
Field style and surface: outline (default), filled, ghost, underline, pill or neo.
"outline" | "filled" | "ghost" | "underline" | "pill" | "neo"—
tone
Accent for picked days, range band, today dot and focus.
"primary" | "neutral" | "success" | "warning" | "danger" | "info"—
selection
How picked days are painted: solid fill, soft tint or ring.
"solid" | "soft" | "ring"—
shape
Day cell shape.
"circle" | "rounded" | "square"—
size"sm" | "md" | "lg"—
shadow
Surface shadow (popover and inline calendar).
"none" | "sm" | "md" | "lg"—
animation
Popover open and month change: slide (default), fade, scale or none. Reduced motion: fade only.
"none" | "fade" | "slide" | "scale"—
clearable
× button in the field.
boolean—
closeOnSelect
Close the popover after a full pick (single day, or range end) when there is no time input.
boolean—
disabledboolean—
invalidboolean—
month
Displayed month (controlled), any day in it.
Date—
defaultMonthDate—
onMonthChange(month: Date) => void—
openboolean—
defaultOpenboolean—
onOpenChange(open: boolean) => void—
name
Hidden input(s) with ISO dates (yyyy-mm-dd, or with time yyyy-mm-ddThh:mm) for form posts.
string—
labels
Text for built-in controls and screen readers; pass translations here.
Partial<DatePickerLabels>—
mode"single"—
value
to is null while the end is being picked.
Date | null—
defaultValueDate | null—
onValueChange(value: Date | null) => void—

Also accepts every <div> attribute.

Accessibility

Arrow Down (before typing) / Alt + Arrow DownIn the text field: opens the calendar with the selected day (or today) focused.
Enter (in the field)Reads the typed date in the locale's order (also ISO 2026-11-24, month names, and an optional time like 2:30 pm). Invalid or blocked dates mark the field invalid and keep the text.
Escape (in the field)Throws away typed text and shows the current value again.
Arrow Left / Arrow RightPrevious / next day (mirrored in RTL). Crossing the edge turns the month.
Arrow Up / Arrow DownSame weekday in the previous / next week.
Home / EndFirst / last day of the week (follows weekStartsOn).
Page Up / Page DownSame day in the previous / next month; with Shift, previous / next year.
Enter / SpacePicks the focused day (single closes the popover unless time is on; range picks start then end; multiple toggles).
EscapeIn the month or year grid: back to days. Otherwise closes the popover and returns focus to what opened it.
Tab / Shift + TabMoves through presets, navigation, the day grid (one tab stop), time and footer, and stays inside the popover dialog.
Arrows / Home / End / Enter (month and year grids)Move by one (left/right) or a row of three (up/down), jump to the ends, pick.
  • The popover is role="dialog" with aria-modal and keeps focus inside (WAI-ARIA date picker dialog). The calendar button has aria-haspopup="dialog", aria-expanded and aria-controls; focus returns to it (or the field) on close.
  • Each month is a role="grid" table labelled by its caption, with weekday column headers (abbr holds the full weekday name). Days are buttons with a full date label such as “Tuesday, November 24, 2026”, plus “Today”, “Booked” or “Unavailable” when they apply; the cell carries aria-selected.
  • Roving tabindex: the grid is a single tab stop and arrow keys move a real focus. Disabled and booked days stay focusable (so keyboard users can pass over them) but have aria-disabled and cannot be picked.
  • The visible month is announced in a polite live region when it changes. Week numbers are row headers named “Week 47”.
  • The text field has no visible label of its own: use aria-label or id with <label htmlFor>. Invalid typed input sets aria-invalid and an alert with labels.invalid.
  • Names, digits, weekday headers and the typed format come from Intl for the locale prop; every built-in string is in labels.

Motion

  • slide (default): the popover moves 8px in; changing the month slides the grid 36px toward the direction of travel (mirrored in RTL) with a fade, on a spring (stiffness 420, damping 36, mass 0.7). fade: the popover fades and months crossfade. scale: the popover grows from 0.95 and the month/year grids from 0.94, months slide as in slide.
  • The popover closes with a 120 ms fade. The range band follows the pointer (and keyboard focus) as a lighter preview before the end is picked.
  • Reduced motion: popover, months and grids only fade (120 ms), and CSS transitions on the field and days are off.
  • No dependencies: date math uses the Date constructor in local time, and all Intl formatters are created once per locale.
  • Grids always have six rows, so switching months never changes the height. The popover mounts only while open.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • mode decides the value shape: single → Date | null, range → { from: Date; to: Date | null } | null (to is null while the end is being picked), multiple → Date[].
  • Before hydration there is no today: pass min/max/booked that depend on today from state set in useEffect, never from new Date() during render.
  • Without time, values are local midnight. With time, the time inputs set hours and minutes on the picked days.
  • A range cannot cross a booked or disabled day; clicking past one starts a new range there.
  • locale defaults to en-US (not the browser's language) so server and client render the same text; pass the user's locale explicitly.
  • name renders hidden ISO inputs: name for single, nameFrom and nameTo for range, one per day for multiple.
  • Do not add a date library for formatting; use the locale prop and labels.

FAQ

How does typed parsing work?

The field reads digits in the order the locale writes dates (MM/DD/YYYY for en-US, DD.MM.YYYY for de-DE, YYYY/MM/DD for ja-JP), ISO dates, month names in the locale, two-digit years and an optional trailing time. A missing year uses the current one.

How is the first day of the week chosen?

weekStartsOn wins. Otherwise the browser's Intl.Locale week info for the locale is used, with a fallback of Sunday for en-US and similar regions and Monday elsewhere.

Can it show two months on phones?

Range mode shows two months from 640px up and one below; set months to force either.

Related