Date Picker
A date input field that combines a text trigger with a Calendar popover dropdown. Supports single, range, and multiple selection modes with built-in label, helper text, and validation messaging.
AccessibleDark ModePopover3 Modes
Install
$
npx react-principles add date-picker01
Live Demo
Explore all variants and interactive states in Storybook.
Open Storybookopen_in_newSingle mode
Select the deployment day.
Error state
Please choose a valid date in the future.
Range mode
Placeholder
Selected value: 2026-04-10
02
Code Snippet
src/ui/DatePicker.tsx
import { DatePicker } from "@/ui/DatePicker"; <DatePicker label="Launch date" description="Select the deployment day." onChange={(value) => console.log(value)} /> // Range mode <DatePicker label="Date range" mode="range" onChange={(value) => console.log(value)} />
03
Copy-Paste (Single File)
DatePicker.tsx
"use client"; import { forwardRef, useCallback, useEffect, useRef, useState, type InputHTMLAttributes, } from "react"; import { cn } from "@/lib/utils"; import { Calendar } from "./Calendar"; import type { CalendarMode, CalendarSelected } from "./Calendar"; export interface DatePickerProps extends Omit< InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "defaultValue" > { label?: string; description?: string; error?: string; value?: string; defaultValue?: string; onChange?: (value: string) => void; mode?: CalendarMode; placeholder?: string; } function formatDate(dateStr: string): string { if (!dateStr) return ""; const parts = dateStr.split("-").map(Number); const y = parts[0] ?? 0; const m = parts[1] ?? 1; const d = parts[2] ?? 1; const date = new Date(y, m - 1, d); return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); } function toDate(dateStr: string): Date { const parts = dateStr.split("-").map(Number); const y = parts[0] ?? 0; const m = parts[1] ?? 1; const d = parts[2] ?? 1; return new Date(y, m - 1, d); } function dateToString(date: Date): string { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>( function DatePickerRoot( { label, description, error, value, defaultValue, onChange, mode = "single", placeholder = "Pick a date", className, id, ...props }, ref, ) { const [open, setOpen] = useState(false); const [internalValue, setInternalValue] = useState(defaultValue ?? ""); const containerRef = useRef<HTMLDivElement>(null); const isControlled = value !== undefined; const currentValue = isControlled ? value : internalValue; const calendarSelected: CalendarSelected | undefined = currentValue ? toDate(currentValue) : undefined; const handleSelect = useCallback( (selected: CalendarSelected) => { let dateStr = ""; if (mode === "single" && selected instanceof Date) { dateStr = dateToString(selected); } else if ( mode === "range" && selected !== null && typeof selected === "object" && "from" in selected ) { const range = selected as { from: Date; to?: Date }; if (range.to) { dateStr = `${dateToString(range.from)} → ${dateToString(range.to)}`; } else { dateStr = dateToString(range.from); } } else if (mode === "multiple" && Array.isArray(selected)) { dateStr = selected.map(dateToString).join(", "); } if (!isControlled) setInternalValue(dateStr); onChange?.(dateStr); if (mode === "single") setOpen(false); }, [mode, isControlled, onChange], ); useEffect(() => { if (!open) return; const onPointerDown = (event: MouseEvent) => { if (!containerRef.current?.contains(event.target as Node)) { setOpen(false); } }; window.addEventListener("mousedown", onPointerDown); return () => window.removeEventListener("mousedown", onPointerDown); }, [open]); const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined); return ( <div className={cn("flex flex-col gap-1.5", className)}> {label && ( <label htmlFor={inputId} className="text-sm font-medium text-slate-700 dark:text-slate-300" > {label} </label> )} <div ref={containerRef} className="relative"> <button type="button" onClick={() => setOpen(!open)} className={cn( "h-10 w-full rounded-lg border border-slate-200 bg-white px-3.5 text-left text-sm text-slate-900 outline-hidden transition-all", "hover:border-slate-300 focus:border-primary focus:ring-2 focus:ring-primary/20", "dark:border-[#1f2937] dark:bg-[#0d1117] dark:text-white dark:hover:border-slate-600", error && "border-red-400 focus:border-red-400 focus:ring-red-400/20 dark:border-red-500", !currentValue && "text-slate-400 dark:text-slate-500", )} > <span className="flex items-center justify-between gap-2"> {currentValue ? formatDate(currentValue) : placeholder} <span className="material-symbols-outlined text-[18px] text-slate-400"> calendar_month </span> </span> </button> <input ref={ref} id={inputId} type="hidden" value={currentValue} name={props.name} /> {open && ( <div className="absolute z-40 mt-2 left-0 w-full"> <Calendar mode={mode} selected={calendarSelected} onSelect={handleSelect} className="w-full" /> </div> )} </div> {description && !error && ( <p className="text-xs text-slate-500 dark:text-slate-400"> {description} </p> )} {error && ( <p className="text-xs text-red-500 dark:text-red-400">{error}</p> )} </div> ); }, );
04
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Optional label displayed above the date picker. |
description | string | — | Helper text shown below the field when there is no error. |
error | string | — | Error message that replaces the helper text and applies error styling. |
value | string | — | Controlled value as an ISO date string (YYYY-MM-DD). |
defaultValue | string | — | Initial value for uncontrolled usage. |
onChange | (value: string) => void | — | Callback fired when a date is selected. Receives the formatted date string. |
mode | "single" | "range" | "multiple" | "single" | Selection mode passed to the underlying Calendar. |
placeholder | string | "Pick a date" | Placeholder text shown when no date is selected. |
className | string | — | Additional classes merged into the root wrapper. |