Date picker
A field that opens our Calendar in a Popover, for single days or ranges, with a quick-pick presets column, formatted value, clear button and invalid state.
Preview
Schedule a usage report
RPT-0412Report periodPick two days or use a quick pick.
Due dateWeekdays only.
First sendPick the first send day.
Archive afterSet by your workspace policy.
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/date-picker.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| mode | "single" | "range" | "single" | One day, or a from–to range across two months. |
| value / defaultValue | Date | DateRange | — | Controlled or initial value; a DateRange in range mode. |
| onValueChange | (value) => void | — | Called with the new value, or undefined when cleared. |
| presets | { label: string; value: Date | DateRange }[] | — | Quick-pick column beside the calendar; the matching preset is marked. |
| numberOfMonths | 1 | 2 | 1 single · 2 range | Months shown in the popover. |
| placeholder | string | "Pick a date" | Muted text while empty ("Pick a date range" in range mode). |
| min / max | Date | — | Selectable bounds, passed to the calendar. |
| disabledDates | Date[] | (date: Date) => boolean | — | Blocked days in the calendar. |
| formatOptions | Intl.DateTimeFormatOptions | { month: "short", day: "numeric", year: "numeric" } | How the field text is formatted. |
| clearable | boolean | true | Show a clear button inside the field while a value is set. |
| invalid | boolean | false | Danger edge and halo; link an error with aria-describedby. |
| disabled | boolean | false | Dims the field and blocks opening. |
| today / locale / weekStartsOn | Date · string · 0–6 | — | Passed to the calendar. |
| name | string | — | Hidden yyyy-mm-dd input; range posts <name>From and <name>To. |
| open / defaultOpen / onOpenChange | boolean · (open) => void | — | Control the popover. |
Build notes
- Popover root wraps both the trigger and the clear button; the clear button is a sibling positioned `absolute end-1` so buttons are never nested. - The calendar mounts each time the popover opens, so `defaultMonth` follows the current value and `autoFocus` moves focus into the grid. - Field text is built with `Intl.DateTimeFormat#format` pieces rather than `formatRange`, whose separator spacing differs between ICU builds and broke hydration. Same-year ranges drop the year from the start date. - Presets compare by day with `isSameDay` to mark the active one. - The presets column is `bg-muted/40` with a hairline end border; the active preset uses the raised recipe (`bg-card ring-hairline shadow-raised-xs inset-shadow-highlight`) plus an ember dot. <!-- PEBBLE-LICENSE -->
View source
registry/input/date-picker/date-picker.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: date-picker.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { cn } from "@/lib/utils";
import {
Calendar,
isSameDay,
type DateMatcher,
type DateRange,
} from "@/registry/data/calendar/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/registry/overlay/popover/popover";
// Re-export the local binding so source-copy installers only rewrite the import.
export type { DateRange };
export type DatePreset<T> = { label: string; value: T };
type DatePickerBaseProps = {
placeholder?: string;
min?: Date;
max?: Date;
/** Blocked days, passed to the calendar. */
disabledDates?: DateMatcher;
/** Disables the whole field. */
disabled?: boolean;
invalid?: boolean;
/** Show the clear button while a value is set. */
clearable?: boolean;
/** Day marked as today in the calendar. */
today?: Date;
locale?: string;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Intl options for the field text. */
formatOptions?: Intl.DateTimeFormatOptions;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
id?: string;
name?: string;
className?: string;
"aria-label"?: string;
"aria-labelledby"?: string;
"aria-describedby"?: string;
};
export type DatePickerSingleProps = DatePickerBaseProps & {
mode?: "single";
value?: Date | undefined;
defaultValue?: Date;
onValueChange?: (value: Date | undefined) => void;
presets?: DatePreset<Date>[];
numberOfMonths?: 1 | 2;
};
export type DatePickerRangeProps = DatePickerBaseProps & {
mode: "range";
value?: DateRange | undefined;
defaultValue?: DateRange;
onValueChange?: (value: DateRange | undefined) => void;
presets?: DatePreset<DateRange>[];
numberOfMonths?: 1 | 2;
};
export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps;
const DEFAULT_FORMAT: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
year: "numeric",
};
function toIso(date: Date) {
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${m}-${d}`;
}
function CalendarIcon() {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="size-4"
>
<rect x="2.5" y="3.5" width="11" height="10" rx="2" />
<path d="M2.5 6.75h11M5.5 2v2.5M10.5 2v2.5" />
</svg>
);
}
function XIcon() {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
aria-hidden
className="size-3.5"
>
<path d="m4.5 4.5 7 7m0-7-7 7" />
</svg>
);
}
export function DatePicker(props: DatePickerProps) {
const {
mode = "single",
placeholder = mode === "range" ? "Pick a date range" : "Pick a date",
min,
max,
disabledDates,
disabled = false,
invalid = false,
clearable = true,
today,
locale = "en-US",
weekStartsOn,
formatOptions = DEFAULT_FORMAT,
open: openProp,
defaultOpen = false,
onOpenChange,
id,
name,
className,
} = props;
const range = mode === "range";
// The trigger's name is its label plus the visible value (label in name).
const autoId = React.useId();
const triggerId = id ?? `${autoId}-trigger`;
const valueId = `${autoId}-value`;
const labelledBy = props["aria-labelledby"]
? `${props["aria-labelledby"]} ${valueId}`
: props["aria-label"]
? `${triggerId} ${valueId}`
: undefined;
const numberOfMonths = props.numberOfMonths ?? (range ? 2 : 1);
/* ----- value ----- */
const controlled = Object.prototype.hasOwnProperty.call(props, "value");
const [internal, setInternal] = React.useState<Date | DateRange | undefined>(props.defaultValue);
const value = controlled ? props.value : internal;
const single = !range ? (value as Date | undefined) : undefined;
const rangeValue = range ? (value as DateRange | undefined) : undefined;
const hasValue = range ? !!rangeValue?.from : !!single;
const commit = (next: Date | DateRange | undefined) => {
setInternal(next);
if (range) (props.onValueChange as DatePickerRangeProps["onValueChange"])?.(next as DateRange);
else (props.onValueChange as DatePickerSingleProps["onValueChange"])?.(next as Date);
};
/* ----- open ----- */
const [openState, setOpenState] = React.useState(defaultOpen);
const open = (openProp ?? openState) && !disabled;
const setOpen = (next: boolean) => {
setOpenState(next);
onOpenChange?.(next);
};
/* ----- text ----- */
const fmt = new Intl.DateTimeFormat(locale, formatOptions);
// Built by hand rather than with formatRange: its separator spacing differs
// between ICU builds, which would break hydration.
let text = "";
if (single) text = fmt.format(single);
else if (rangeValue?.from) {
const { from, to } = rangeValue;
if (!to) text = `${fmt.format(from)} – …`;
else {
const sameYear = from.getFullYear() === to.getFullYear() && !!formatOptions.year;
const fromFmt = sameYear
? new Intl.DateTimeFormat(locale, { ...formatOptions, year: undefined })
: fmt;
text = `${fromFmt.format(from)} – ${fmt.format(to)}`;
}
}
const presets = props.presets as DatePreset<Date | DateRange>[] | undefined;
const presetMatches = (p: DatePreset<Date | DateRange>) => {
if (!range) return isSameDay(p.value as Date, single);
const r = p.value as DateRange;
return !!rangeValue && isSameDay(r.from, rangeValue.from) && isSameDay(r.to, rangeValue.to);
};
const calendarCommon = {
min,
max,
disabled: disabledDates,
today,
locale,
weekStartsOn,
numberOfMonths,
autoFocus: true,
} as const;
return (
<Popover open={open} onOpenChange={setOpen}>
<div data-slot="date-picker" className={cn("group/date-picker relative w-full", className)}>
<PopoverTrigger asChild>
<button
type="button"
id={triggerId}
disabled={disabled}
aria-haspopup="dialog"
data-invalid={invalid || undefined}
aria-label={props["aria-label"]}
aria-labelledby={labelledBy}
aria-describedby={props["aria-describedby"]}
data-empty={!hasValue || undefined}
className={cn(
"flex h-9 w-full cursor-pointer items-center gap-2 rounded-md border border-border-control bg-card ps-3 text-start text-sm text-foreground inset-shadow-xs",
clearable && hasValue ? "pe-9" : "pe-3",
"transition-[border-color,box-shadow,background-color] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
"hover:border-muted-foreground data-[state=open]:border-muted-foreground",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
"data-invalid:border-danger data-invalid:ring-[3px] data-invalid:ring-danger/20 data-invalid:focus-visible:ring-2 data-invalid:focus-visible:ring-danger/40",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 disabled:inset-shadow-none",
)}
>
<span className="text-muted-foreground transition-colors duration-(--motion-fast) group-focus-within/date-picker:text-foreground">
<CalendarIcon />
</span>
<span
id={valueId}
data-slot="date-picker-value"
className={cn(
"min-w-0 flex-1 truncate tabular-nums",
!hasValue && "text-muted-foreground",
)}
>
{text || placeholder}
</span>
</button>
</PopoverTrigger>
{clearable && hasValue && !disabled && (
<button
type="button"
data-slot="date-picker-clear"
aria-label="Clear date"
onClick={() => commit(undefined)}
className={cn(
"absolute inset-y-0 end-1 my-auto grid size-7 cursor-pointer place-items-center rounded-md text-muted-foreground",
"transition-[background-color,color,scale,opacity] duration-(--motion-fast) ease-enter hover:bg-muted hover:text-foreground motion-reduce:transition-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
"active:scale-90 starting:scale-75 starting:opacity-0",
)}
>
<XIcon />
</button>
)}
{name && single && <input type="hidden" name={name} value={toIso(single)} />}
{name && rangeValue?.from && (
<>
<input type="hidden" name={`${name}From`} value={toIso(rangeValue.from)} />
{rangeValue.to && (
<input type="hidden" name={`${name}To`} value={toIso(rangeValue.to)} />
)}
</>
)}
</div>
<PopoverContent align="start" className="flex w-auto max-w-[calc(100vw-1rem)] gap-0 p-0">
{presets && presets.length > 0 && (
<div
role="group"
aria-label="Presets"
data-slot="date-picker-presets"
className="flex w-36 shrink-0 flex-col gap-0.5 border-e border-border bg-muted/40 p-2 max-sm:hidden"
>
<span className="px-2 pt-1 pb-1.5 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
Quick pick
</span>
{presets.map((p) => {
const active = presetMatches(p);
return (
<button
key={p.label}
type="button"
aria-pressed={active}
onClick={() => {
commit(p.value);
setOpen(false);
}}
className={cn(
"flex h-8 cursor-pointer items-center justify-between gap-2 rounded-md px-2 text-start text-[13px] text-foreground",
"transition-[background-color,box-shadow] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
"hover:bg-card hover:shadow-raised-xs hover:ring-1 hover:ring-hairline",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
active &&
"bg-card font-medium shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline",
)}
>
{p.label}
{active && <span aria-hidden className="size-1.5 rounded-full bg-accent" />}
</button>
);
})}
</div>
)}
<div className="p-3">
{range ? (
<Calendar
{...calendarCommon}
mode="range"
defaultMonth={rangeValue?.from}
selected={rangeValue}
onSelect={(r) => {
commit(r);
if (r?.from && r.to) setOpen(false);
}}
/>
) : (
<Calendar
{...calendarCommon}
mode="single"
defaultMonth={single}
selected={single}
onSelect={(d) => {
commit(d);
if (d) setOpen(false);
}}
/>
)}
</div>
</PopoverContent>
</Popover>
);
}
