A date input that parses as you type — 7/14, Jul 14, 2026-07-14 — with an origin-aware calendar popover and a clear affordance that appears only when needed.
npx shadcn@latest add @paragon/date-pickerAlso installs: calendar, popover
"use client";
import * as React from "react";
import { CalendarDays, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import {
Calendar,
compareDay,
formatDate,
MONTH_NAMES,
startOfDay,
} from "@/registry/paragon/ui/calendar";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from "@/registry/paragon/ui/popover";
import { cn } from "@/lib/utils";
const daysIn = (year: number, month1: number) =>
new Date(year, month1, 0).getDate();
function build(y: number, m1: number, d: number): Date | null {
if (m1 < 1 || m1 > 12 || d < 1 || d > daysIn(y, m1)) return null;
return new Date(y, m1 - 1, d);
}
/**
* Forgiving hand-rolled parser: "2026-07-14", "7/14/2026", "7/14",
* "Jul 14", "July 14, 2026", "14 Jul 2026". Missing years resolve
* against `referenceYear` so relative typing still lands nearby.
*/
export function parseDateInput(
text: string,
referenceYear: number,
): Date | null {
const t = text.trim().toLowerCase().replace(/\s+/g, " ");
if (!t) return null;
const year = (raw: string | undefined) =>
raw === undefined
? referenceYear
: raw.length <= 2
? 2000 + Number(raw)
: Number(raw);
let m = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (m) return build(Number(m[1]), Number(m[2]), Number(m[3]));
m = t.match(/^(\d{1,2})[/\-.](\d{1,2})(?:[/\-.](\d{2,4}))?$/);
if (m) return build(year(m[3]), Number(m[1]), Number(m[2]));
const monthIndex = (token: string) =>
MONTH_NAMES.findIndex((n) => n.toLowerCase().startsWith(token));
m = t.match(/^([a-z]{3,9})\.? (\d{1,2})(?:st|nd|rd|th)?(?:,? (\d{2,4}))?$/);
if (m) {
const mi = monthIndex(m[1]);
if (mi >= 0) return build(year(m[3]), mi + 1, Number(m[2]));
}
m = t.match(/^(\d{1,2})(?:st|nd|rd|th)? ([a-z]{3,9})\.?(?:,? (\d{2,4}))?$/);
if (m) {
const mi = monthIndex(m[2]);
if (mi >= 0) return build(year(m[3]), mi + 1, Number(m[1]));
}
return null;
}
export interface DatePickerProps {
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (date: Date | null) => void;
min?: Date;
max?: Date;
isDateDisabled?: (date: Date) => boolean;
weekStartsOn?: 0 | 1;
/** Reference "today" for the calendar ring and year-less parsing. */
now?: Date;
placeholder?: string;
disabled?: boolean;
/** Form field name; submits `yyyy-mm-dd` via a hidden input. */
name?: string;
"aria-label"?: string;
className?: string;
id?: string;
}
/**
* A date input that parses as you type — "7/14", "Jul 14 2026",
* "2026-07-14" all land — with an origin-aware calendar popover and a
* clear affordance that appears only once there is something to clear.
*/
export function DatePicker({
value: valueProp,
defaultValue = null,
onValueChange,
min,
max,
isDateDisabled,
weekStartsOn = 1,
now,
placeholder = "Jul 14, 2026",
disabled = false,
name,
"aria-label": ariaLabel = "Date",
className,
id,
}: DatePickerProps) {
const [fallbackNow] = React.useState(() => startOfDay(new Date()));
const today = now ? startOfDay(now) : fallbackNow;
const [valueState, setValueState] = React.useState<Date | null>(defaultValue);
const value = valueProp !== undefined ? valueProp : valueState;
const [text, setText] = React.useState(() => (value ? formatDate(value) : ""));
const [open, setOpen] = React.useState(false);
const [month, setMonth] = React.useState<Date>(() => value ?? today);
const inputRef = React.useRef<HTMLInputElement>(null);
const allowed = React.useCallback(
(d: Date) =>
!(
(min && compareDay(d, min) < 0) ||
(max && compareDay(d, max) > 0) ||
isDateDisabled?.(d)
),
[min, max, isDateDisabled],
);
const commit = (next: Date | null) => {
setValueState(next);
onValueChange?.(next);
if (next) setMonth(next);
};
const handleType = (raw: string) => {
setText(raw);
if (raw.trim() === "") {
commit(null);
return;
}
const parsed = parseDateInput(raw, today.getFullYear());
if (parsed && allowed(parsed)) commit(parsed);
};
const settle = () => {
// Re-canonicalize on blur/Enter: valid value wins, junk reverts.
setText(value ? formatDate(value) : "");
};
const clear = () => {
commit(null);
setText("");
inputRef.current?.focus();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div
className={cn(
"relative flex h-9 w-56 items-center rounded-lg border border-input bg-transparent transition-[border-color,box-shadow] duration-150 ease-out focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
disabled && "pointer-events-none opacity-50",
className,
)}
>
<input
ref={inputRef}
id={id}
type="text"
role="combobox"
aria-expanded={open}
aria-haspopup="dialog"
aria-label={ariaLabel}
autoComplete="off"
spellCheck={false}
disabled={disabled}
placeholder={placeholder}
value={text}
onChange={(e) => handleType(e.target.value)}
onBlur={settle}
onKeyDown={(e) => {
if (e.key === "Enter") {
settle();
setOpen(false);
} else if (e.key === "ArrowDown" && !open) {
e.preventDefault();
setOpen(true);
}
}}
className="h-full min-w-0 flex-1 rounded-lg bg-transparent pr-14 pl-3 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
<span className="absolute right-1 flex items-center">
<AnimatePresence initial={false}>
{value && !disabled && (
<motion.button
key="clear"
type="button"
aria-label="Clear date"
tabIndex={-1}
initial={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.25, bounce: 0 }}
onPointerDown={(e) => e.preventDefault()}
onClick={clear}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground"
>
<X className="size-3.5" aria-hidden />
</motion.button>
)}
</AnimatePresence>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Open calendar"
disabled={disabled}
className="pressable flex size-7 items-center justify-center rounded-md text-muted-foreground transition-[background-color,color] duration-150 ease-out outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<CalendarDays className="size-4" aria-hidden />
</button>
</PopoverTrigger>
</span>
{name && (
<input
type="hidden"
name={name}
value={
value
? `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`
: ""
}
/>
)}
</div>
</PopoverAnchor>
<PopoverContent align="end" sideOffset={8} className="w-auto p-3">
<Calendar
aria-label={ariaLabel}
now={today}
selected={value}
onSelect={(d) => {
commit(d);
setText(formatDate(d));
setOpen(false);
inputRef.current?.focus();
}}
month={month}
onMonthChange={setMonth}
min={min}
max={max}
isDateDisabled={isDateDisabled}
weekStartsOn={weekStartsOn}
/>
</PopoverContent>
</Popover>
);
}