Date Picker
pnpm dlx shadcn@latest add …/r/date-picker.jsonAn accessible React date picker for Next.js and Tailwind CSS: a calendar in a popover for a date or a date range, with presets and Intl formatting.
- Date Picker
- Date Range Picker
"use client"
import { useState } from "react"
import { DatePicker } from "@/components/ui/date-picker"
export function DueDate() {
const [date, setDate] = useState<Date | undefined>()
return (
<div className="grid gap-2">
<label htmlFor="due" className="text-sm font-medium">
Due date
</label>
<DatePicker id="due" value={date} onChange={setDate} />
</div>
)
}"use client";
import { DatePicker, DateRangePicker } from "@/components/ui/date-picker";
// Fixed days, made in the browser's time zone (a client module), so the server and the browser agree
const due = new Date(2026, 9, 20);
const stay = { from: new Date(2026, 9, 12), to: new Date(2026, 9, 18) };
/*
* A date and a date range side by side, named underneath. Picking a day closes the first once the circle
* lands; the second stays open while the band grows between the two days.
*/
export default function DatePickers() {
return (
<ul className="flex flex-wrap items-start justify-center gap-x-10 gap-y-6">
<li className="flex flex-col items-center gap-2">
<DatePicker aria-label="Due date" defaultValue={due} className="w-56" />
<span className="text-xs text-muted-foreground">Date Picker</span>
</li>
<li className="flex flex-col items-center gap-2">
<DateRangePicker aria-label="Stay" defaultValue={stay} className="w-64" />
<span className="text-xs text-muted-foreground">Date Range Picker</span>
</li>
</ul>
);
}Installation
Install the dependencies.
$ pnpm add lucide-react motionAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { Calendar, type CalendarLabels, type DateRange, type Matcher, type Weekday, } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { duration, ease, spring, swap } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { CalendarIcon } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { type ComponentProps, type ReactNode, useEffect, useId, useRef, useState } from "react"; /* * Date Picker and Date Range Picker (roadmap, 2026-10-16: "A calendar in a popover, for dates and ranges"). * - Our Calendar in our Popover, opened from a trigger drawn like Select's: a calendar icon, then the date or * a placeholder. The trigger is a combobox that pops up a dialog (the WAI-ARIA date picker pattern), so a * <label htmlFor> names it and screen readers read the date as its value. * - Opening moves the focus to the picked day (or today), ready for the arrow keys. Escape, a click outside * or the trigger closes it, and the focus goes back to the trigger. * - Motion (.agents/rules/motion.md): the popover opens from the trigger through the blur (Popover), and the * trigger's text swaps through `swap` when the date changes. DatePicker closes a beat after a pick * (duration.base), once the circle has landed on the new day. DateRangePicker stays open, so the band can * be seen growing and the range picked again. * - Presets ("Today", "Next 7 days") are a small list beside the calendar, with one highlight gliding between * them (Select's pill). Their values can be functions, worked out when picked, so nothing reads the clock * while rendering (no hydration mismatch). * - Dates are formatted by Intl: dateStyle "medium" by default, and formatRange for ranges ("Oct 12 – 18, 2026"). */ export type DatePreset<T> = { label: string; /** The date (or range) it picks, or a function that works it out when it's picked */ value: T | (() => T); }; export type DatePickerLabels = CalendarLabels & { /** Names the popup, a dialog */ dialog: string; /** Names the list of presets */ presets: string; }; const triggerSizes = { sm: "h-(--size-control-sm) rounded-md px-2.5", md: "h-(--size-control-md) px-3", } as const; type PickerProps = Omit< ComponentProps<"button">, "value" | "defaultValue" | "onChange" | "children" | "name" > & { /** Shown in the trigger while nothing is picked */ placeholder?: string; size?: keyof typeof triggerSizes; /** Whether it's open, when you control it */ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** A BCP 47 language tag for the trigger's date and the calendar */ locale?: string; /** How the trigger writes the date, as Intl.DateTimeFormat options */ formatOptions?: Intl.DateTimeFormatOptions; /** The earliest day that can be picked */ min?: Date; /** The latest day that can be picked */ max?: Date; /** Days that can't be picked: Calendar's `disabled` */ disabledDates?: Matcher | Matcher[]; weekStartsOn?: Weekday; numberOfMonths?: number; /** The day marked as today. Defaults to the browser's today. */ today?: Date; /** How the popover lines up with the trigger */ align?: "start" | "center" | "end"; labels?: Partial<DatePickerLabels>; }; export type DatePickerProps = PickerProps & { /** The picked day, when you control it */ value?: Date | undefined; defaultValue?: Date; onChange?: (date: Date | undefined) => void; presets?: DatePreset<Date>[]; /** Submits the day with a form, as YYYY-MM-DD */ name?: string; }; export type DateRangePickerProps = PickerProps & { /** The picked range, when you control it */ value?: DateRange | undefined; defaultValue?: DateRange; onChange?: (range: DateRange | undefined) => void; presets?: DatePreset<DateRange>[]; }; const monthOf = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); const monthIndex = (date: Date) => date.getFullYear() * 12 + date.getMonth(); const pad = (value: number, length = 2) => String(value).padStart(length, "0"); const keyOf = (date: Date) => `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; const resolve = <T,>(value: T | (() => T)) => (typeof value === "function" ? (value as () => T)() : value); /** * What both pickers share: whether they're open, the month the calendar shows, and closing a beat after a * pick. Each opening starts on the picked month, or on today's (which Calendar knows). */ function usePicker( openProp: boolean | undefined, defaultOpen: boolean | undefined, onOpenChange: ((open: boolean) => void) | undefined, anchor: Date | undefined, count: number, today: Date | undefined, ) { const reduce = useReducedMotion() ?? false; const [ownOpen, setOwnOpen] = useState(defaultOpen ?? false); const open = openProp ?? ownOpen; const [month, setMonth] = useState<Date | undefined>(() => (anchor ? monthOf(anchor) : undefined)); const closing = useRef<ReturnType<typeof setTimeout>>(undefined); useEffect(() => () => clearTimeout(closing.current), []); const setOpen = (next: boolean) => { clearTimeout(closing.current); if (next) setMonth(anchor ? monthOf(anchor) : undefined); if (openProp === undefined) setOwnOpen(next); onOpenChange?.(next); }; return { open, setOpen, month, setMonth, /** Closes once the circle has landed on the picked day */ closeSoon: () => { clearTimeout(closing.current); closing.current = setTimeout(() => setOpen(false), reduce ? 0 : duration.base * 1000); }, /** Pages the calendar to a picked day it isn't showing (a preset's) */ reveal: (date: Date) => { // With no month set, the calendar is on today's (yours, or the clock's: this runs on a click, not a render) const shown = month ?? monthOf(today ?? new Date()); const offset = monthIndex(date) - monthIndex(shown); if (offset < 0 || offset >= count) setMonth(monthOf(date)); }, }; } /** The list of presets, with one highlight gliding to the row under the mouse or with the focus */ function PresetList<T>({ presets, label, onPick, }: { presets: DatePreset<T>[]; label: string; onPick: (value: T) => void; }) { const groupId = useId(); const reduce = useReducedMotion() ?? false; const [hovered, setHovered] = useState<string | null>(null); const [focused, setFocused] = useState<string | null>(null); const active = hovered ?? focused; return ( <ul data-slot="date-picker-presets" aria-label={label} className="relative isolate flex shrink-0 flex-col gap-0.5 py-3 ps-3" onPointerLeave={() => setHovered(null)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(null); }} > <LayoutGroup id={groupId}> {presets.map((preset) => ( <li key={preset.label}> <button type="button" data-slot="date-picker-preset" onPointerEnter={(event) => { // Hover highlights follow the mouse only if (event.pointerType === "mouse") setHovered(preset.label); }} onFocus={() => setFocused(preset.label)} onClick={() => onPick(resolve(preset.value))} className={cn( "relative flex w-full cursor-pointer items-center rounded-md px-2 py-1.5 text-start text-sm whitespace-nowrap", "transition-colors duration-(--duration-fast) ease-out", "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", active === preset.label && "text-accent-foreground", )} > {/* The rows aren't stacking contexts: the pill joins the list's layer (it's `isolate`), behind every label */} {active === preset.label ? ( <motion.span layoutId="date-picker-preset-highlight" aria-hidden className="pointer-events-none absolute inset-0 -z-10 rounded-md bg-accent" transition={reduce ? { duration: 0 } : spring.snappy} /> ) : null} {preset.label} </button> </li> ))} </LayoutGroup> </ul> ); } /** The trigger, drawn like Select's, and the popover round whatever the picker puts in it */ function PickerFrame({ open, onOpenChange, text, empty, size = "md", align, dialogLabel, className, children, ...props }: Omit<ComponentProps<"button">, "children"> & { open: boolean; onOpenChange: (open: boolean) => void; text: string; empty: boolean; size?: keyof typeof triggerSizes; align: "start" | "center" | "end"; dialogLabel: string; children: ReactNode; }) { const reduce = useReducedMotion() ?? false; const content = useRef<HTMLDivElement>(null); return ( <Popover open={open} onOpenChange={onOpenChange}> <PopoverTrigger asChild> <button type="button" // A combobox that pops up a dialog (Radix adds aria-haspopup="dialog" and aria-controls) role="combobox" aria-expanded={open} data-slot="date-picker-trigger" data-size={size} data-placeholder={empty ? "" : undefined} className={cn( "group flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg border border-input bg-transparent text-start text-sm whitespace-nowrap shadow-xs", "transition duration-(--duration-fast) ease-out", "outline-ring focus-visible:border-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", "aria-invalid:border-destructive aria-invalid:outline-destructive", "disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)", triggerSizes[size], className, )} {...props} > <CalendarIcon aria-hidden className="size-(--size-icon-md) shrink-0 text-muted-foreground" /> {/* Screen readers read this as the combobox's value; what's drawn swaps through the blur */} <span data-slot="date-picker-value" className="sr-only"> {text} </span> <span aria-hidden className="grid min-w-0 flex-1"> <AnimatePresence initial={false}> <motion.span key={text} {...(reduce ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } : swap)} transition={ reduce ? { duration: duration.fast } : { duration: duration.base, ease: ease.out, y: spring.snappy } } // Each text keeps its own colour as it leaves, so the placeholder goes out muted className={cn("col-start-1 row-start-1 truncate", empty && "text-muted-foreground")} > {text} </motion.span> </AnimatePresence> </span> </button> </PopoverTrigger> <PopoverContent ref={content} align={align} aria-label={dialogLabel} // Focus goes to the picked day (or today), not to the first button, as in the WAI-ARIA date picker onOpenAutoFocus={(event) => { event.preventDefault(); content.current?.querySelector<HTMLElement>("[data-slot=calendar-day][tabindex='0']")?.focus(); }} // Where two months or the presets don't fit, the calendar wraps under them className="flex w-auto max-w-(--radix-popover-content-available-width) flex-wrap p-0" > {children} </PopoverContent> </Popover> ); } /** * A date field: a trigger showing the picked day (or a placeholder) that opens a Calendar in a Popover. * Picking a day closes it. Give it a visible label (`<label htmlFor>`) or an aria-label. */ function DatePicker(props: DatePickerProps) { const { value: valueProp, defaultValue, onChange, presets, name, placeholder = "Pick a date", locale = "en-US", formatOptions = { dateStyle: "medium" }, min, max, disabledDates, weekStartsOn, numberOfMonths = 1, today, labels, open: openProp, defaultOpen, onOpenChange, align = "start", ...trigger } = props; const controlled = "value" in props; const [own, setOwn] = useState(defaultValue); const value = controlled ? valueProp : own; const picker = usePicker(openProp, defaultOpen, onOpenChange, value, numberOfMonths, today); const commit = (next: Date | undefined) => { if (!controlled) setOwn(next); onChange?.(next); }; return ( <> <PickerFrame open={picker.open} onOpenChange={picker.setOpen} text={value ? new Intl.DateTimeFormat(locale, formatOptions).format(value) : placeholder} empty={!value} align={align} dialogLabel={labels?.dialog ?? "Choose date"} {...trigger} > {presets?.length ? ( <PresetList presets={presets} label={labels?.presets ?? "Presets"} onPick={(date) => { commit(date); picker.reveal(date); picker.closeSoon(); }} /> ) : null} <Calendar mode="single" // Picking the same day again keeps it and closes, rather than clearing the field required selected={value} onSelect={(date) => { commit(date); picker.closeSoon(); }} month={picker.month} onMonthChange={picker.setMonth} numberOfMonths={numberOfMonths} min={min} max={max} disabled={disabledDates} weekStartsOn={weekStartsOn} locale={locale} today={today} labels={labels} /> </PickerFrame> {name ? <input type="hidden" name={name} value={value ? keyOf(value) : ""} /> : null} </> ); } /** * A date range field: a trigger showing the picked range (or a placeholder) that opens a Calendar in range * mode, two months side by side by default. It stays open while you pick. Give it a visible label or an * aria-label. */ function DateRangePicker(props: DateRangePickerProps) { const { value: valueProp, defaultValue, onChange, presets, placeholder = "Pick a date range", locale = "en-US", formatOptions = { dateStyle: "medium" }, min, max, disabledDates, weekStartsOn, numberOfMonths = 2, today, labels, open: openProp, defaultOpen, onOpenChange, align = "start", ...trigger } = props; const controlled = "value" in props; const [own, setOwn] = useState(defaultValue); const value = controlled ? valueProp : own; const picker = usePicker(openProp, defaultOpen, onOpenChange, value?.from, numberOfMonths, today); const from = value?.from; const to = value?.to; const format = new Intl.DateTimeFormat(locale, formatOptions); const commit = (next: DateRange | undefined) => { if (!controlled) setOwn(next); onChange?.(next); }; return ( <PickerFrame open={picker.open} onOpenChange={picker.setOpen} // Intl writes a range the short way: "Oct 12 – 18, 2026" text={from && to ? format.formatRange(from, to) : from ? `${format.format(from)} – …` : placeholder} empty={!from} align={align} dialogLabel={labels?.dialog ?? "Choose dates"} {...trigger} > {presets?.length ? ( <PresetList presets={presets} label={labels?.presets ?? "Presets"} onPick={(range) => { commit(range); if (range.from) picker.reveal(range.from); }} /> ) : null} <Calendar mode="range" selected={value} onSelect={commit} month={picker.month} onMonthChange={picker.setMonth} numberOfMonths={numberOfMonths} min={min} max={max} disabled={disabledDates} weekStartsOn={weekStartsOn} locale={locale} today={today} labels={labels} /> </PickerFrame> ); } export { DatePicker, DateRangePicker };Update the import paths to match your project.
Examples
Presets
A small list beside the calendar for the dates people pick most. Each works its date out from today when it's picked, and the calendar turns to it.
"use client";
import type { DateRange } from "@/components/ui/calendar";
import { DatePicker, type DatePreset, DateRangePicker } from "@/components/ui/date-picker";
import { useId } from "react";
/** A day counted from today. Presets call it when they're picked, so nothing reads the clock while rendering. */
const fromToday = (days: number) => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + days);
};
const days: DatePreset<Date>[] = [
{ label: "Today", value: () => fromToday(0) },
{ label: "Tomorrow", value: () => fromToday(1) },
{ label: "In a week", value: () => fromToday(7) },
{ label: "In a month", value: () => fromToday(30) },
];
const ranges: DatePreset<DateRange>[] = [
{ label: "Next 7 days", value: () => ({ from: fromToday(0), to: fromToday(6) }) },
{ label: "Next 14 days", value: () => ({ from: fromToday(0), to: fromToday(13) }) },
{ label: "Next 30 days", value: () => ({ from: fromToday(0), to: fromToday(29) }) },
];
/*
* Presets beside the calendar, for the dates people pick most. Each works its date out from today when it's
* picked; the calendar turns to it, the circle travels or the band grows, and the first picker closes.
*/
export default function DatePickerPresets() {
const id = useId();
return (
<div className="grid w-full max-w-xs gap-4">
<div className="grid gap-2">
<label htmlFor={`${id}-follow-up`} className="text-sm font-medium">
Follow up
</label>
<DatePicker id={`${id}-follow-up`} presets={days} />
</div>
<div className="grid gap-2">
<label htmlFor={`${id}-campaign`} className="text-sm font-medium">
Campaign
</label>
<DateRangePicker id={`${id}-campaign`} presets={ranges} />
</div>
</div>
);
}API reference
DatePicker
A field for one day: a trigger drawn like Select's that opens a Calendar in a Popover, and closes once a day is picked. The trigger is a combobox with a dialog, so give it a visible label (<label htmlFor>) or an aria-label. Also accepts every button prop, for the trigger.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | Date | – | The picked day, when you control it. |
defaultValue? | Date | – | The day picked at first. |
onChange? | (date: Date | undefined) => void | – | Called with the new day. |
name? | string | – | Submits the day with a form, as YYYY-MM-DD. |
numberOfMonths? | number | 1 | Months shown side by side. |
placeholder? | string | "Pick a date" | Shown in the trigger while nothing is picked. |
presets? | { label: string; value: T | (() => T) }[] | – | A short list beside the calendar. A function value is worked out when it's picked, so presets like Today never read the clock while rendering. |
locale? | string | "en-US" | A language tag for the trigger's date and the calendar's names. |
formatOptions? | Intl.DateTimeFormatOptions | { dateStyle: "medium" } | How the trigger writes the date, as Intl.DateTimeFormat options. |
min? | Date | – | The earliest day that can be picked. |
max? | Date | – | The latest day that can be picked. |
disabledDates? | Matcher | Matcher[] | – | Days that can't be picked: Calendar's disabled prop. |
weekStartsOn? | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 0 | 0 is Sunday. |
open? | boolean | – | Whether the calendar is open, when you control it. |
onOpenChange? | (open: boolean) => void | – | Called when it opens or closes. |
align? | "start" | "center" | "end" | "start" | How the popover lines up with the trigger. |
size? | "sm" | "md" | "md" | The trigger's height, as Select's. |
today? | Date | – | The day marked as today. Defaults to the browser's today. |
labels? | { dialog?, presets?, previousMonth?, nextMonth?, selected? } | – | The words it says, for other languages: the popup's name, the presets' name and Calendar's labels. |
disabled? | boolean | false | Prevents opening. |
DateRangePicker
A field for a range: the same trigger, opening a Calendar in range mode that stays open while you pick. The trigger writes the range the short way (Oct 12 – 18, 2026). Also accepts every button prop, for the trigger.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | DateRange | – | The picked range ({ from, to }), when you control it. |
defaultValue? | DateRange | – | The range picked at first. |
onChange? | (range: DateRange | undefined) => void | – | Called with the new range: first with only its start, then with both ends. |
numberOfMonths? | number | 2 | Months shown side by side. |
placeholder? | string | "Pick a date range" | Shown in the trigger while nothing is picked. |
presets? | { label: string; value: T | (() => T) }[] | – | A short list beside the calendar. A function value is worked out when it's picked, so presets like Today never read the clock while rendering. |
locale? | string | "en-US" | A language tag for the trigger's date and the calendar's names. |
formatOptions? | Intl.DateTimeFormatOptions | { dateStyle: "medium" } | How the trigger writes the date, as Intl.DateTimeFormat options. |
min? | Date | – | The earliest day that can be picked. |
max? | Date | – | The latest day that can be picked. |
disabledDates? | Matcher | Matcher[] | – | Days that can't be picked: Calendar's disabled prop. |
weekStartsOn? | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 0 | 0 is Sunday. |
open? | boolean | – | Whether the calendar is open, when you control it. |
onOpenChange? | (open: boolean) => void | – | Called when it opens or closes. |
align? | "start" | "center" | "end" | "start" | How the popover lines up with the trigger. |
size? | "sm" | "md" | "md" | The trigger's height, as Select's. |
today? | Date | – | The day marked as today. Defaults to the browser's today. |
labels? | { dialog?, presets?, previousMonth?, nextMonth?, selected? } | – | The words it says, for other languages: the popup's name, the presets' name and Calendar's labels. |
disabled? | boolean | false | Prevents opening. |
Related components
- October 2026
An accessible React calendar for Next.js and Tailwind CSS: pick a day or a range, with full keyboard support, disabled days and Intl names. No date library.
An accessible React popover for Next.js and Tailwind CSS: forms, settings and other rich content anchored to a button, built on Radix Popover.
An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.