Date Picker
Inputs & Forms

Date Picker

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.

Install

npx shadcn@latest add @paragon/date-picker

Also installs: calendar, popover

date-picker.tsx

"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>
  );
}