Inputs & Forms

Date Field

A segmented MM/DD/YYYY input where arrow keys increment with a digit roll, complete segments auto-advance, and invalid entries shake — no calendar popover.

Install

npx shadcn@latest add @paragon/date-field

date-field.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

type SegKey = "month" | "day" | "year";

interface SegmentDef {
  key: SegKey;
  label: string;
  placeholder: string;
  len: number;
  min: number;
}

const SEGMENTS: SegmentDef[] = [
  { key: "month", label: "Month", placeholder: "MM", len: 2, min: 1 },
  { key: "day", label: "Day", placeholder: "DD", len: 2, min: 1 },
  { key: "year", label: "Year", placeholder: "YYYY", len: 4, min: 1 },
];

const daysInMonth = (month: number, year: number) =>
  new Date(year, month, 0).getDate();

export interface DateFieldProps
  extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
  /** Controlled date. Partial input reports as `null` until complete. */
  value?: Date | null;
  defaultValue?: Date;
  onValueChange?: (date: Date | null) => void;
  disabled?: boolean;
  /** Form field name; renders a hidden input with `yyyy-mm-dd`. */
  name?: string;
}

/** Digits for one segment; rolls direction-aware on arrow changes, instant on typing. */
function SegmentChars({
  text,
  roll,
  dir,
}: {
  text: string;
  roll: boolean;
  dir: 1 | -1;
}) {
  return (
    <span className="pointer-events-none flex overflow-hidden" aria-hidden>
      {text.split("").map((ch, ci) => (
        <span key={ci} className="relative flex w-[1ch] justify-center">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={ch}
              initial={
                roll ? { y: dir > 0 ? "100%" : "-100%", opacity: 0.25 } : false
              }
              animate={{ y: "0%", opacity: 1 }}
              exit={
                roll
                  ? {
                      y: dir > 0 ? "-100%" : "100%",
                      opacity: 0.25,
                      transition: { duration: 0.15, ease: EASE_EXIT },
                    }
                  : { opacity: 0, transition: { duration: 0 } }
              }
              transition={{ duration: 0.15, ease: EASE_OUT }}
              className="inline-block"
            >
              {ch}
            </motion.span>
          </AnimatePresence>
        </span>
      ))}
    </span>
  );
}

/**
 * A segmented MM / DD / YYYY date input. Arrow keys increment with a
 * digit roll, completed segments auto-advance, and invalid entries shake.
 * No calendar popover — dependency-light by design.
 */
export function DateField({
  value: valueProp,
  defaultValue,
  onValueChange,
  disabled = false,
  name,
  className,
  "aria-label": ariaLabel = "Date",
  ...props
}: DateFieldProps) {
  const reduced = useReducedMotion() ?? false;
  const [segs, setSegs] = React.useState<Record<SegKey, number | null>>(() => {
    const init = valueProp ?? defaultValue ?? null;
    return init
      ? {
          month: init.getMonth() + 1,
          day: init.getDate(),
          year: init.getFullYear(),
        }
      : { month: null, day: null, year: null };
  });
  const [shakes, setShakes] = React.useState<Record<SegKey, number>>({
    month: 0,
    day: 0,
    year: 0,
  });
  const enteredRef = React.useRef<Record<SegKey, number>>({
    month: 0,
    day: 0,
    year: 0,
  });
  const lastEditRef = React.useRef<{ mode: "type" | "arrow"; dir: 1 | -1 }>({
    mode: "type",
    dir: 1,
  });
  const segRefs = React.useRef<Record<SegKey, HTMLDivElement | null>>({
    month: null,
    day: null,
    year: null,
  });

  // Sync internal segments when a controlled value changes externally.
  const controlledTime = valueProp instanceof Date ? valueProp.getTime() : null;
  React.useEffect(() => {
    if (controlledTime === null) return;
    const next = new Date(controlledTime);
    const m = next.getMonth() + 1;
    const d = next.getDate();
    const y = next.getFullYear();
    setSegs((prev) =>
      prev.month === m && prev.day === d && prev.year === y
        ? prev
        : { month: m, day: d, year: y },
    );
  }, [controlledTime]);

  const maxFor = (key: SegKey) =>
    key === "month"
      ? 12
      : key === "year"
        ? 9999
        : segs.month
          ? daysInMonth(segs.month, segs.year ?? 2000)
          : 31;

  const triggerShake = (key: SegKey) =>
    setShakes((prev) => ({ ...prev, [key]: prev[key] + 1 }));

  const focusSeg = (i: number) => {
    const clamped = Math.max(0, Math.min(SEGMENTS.length - 1, i));
    segRefs.current[SEGMENTS[clamped].key]?.focus();
  };

  const applyChange = (key: SegKey, val: number | null) => {
    const next = { ...segs, [key]: val };
    if (next.day !== null) {
      const dim = next.month
        ? daysInMonth(next.month, next.year ?? 2000)
        : 31;
      if (next.day > dim) {
        next.day = dim;
        if (key !== "day") triggerShake("day");
      }
    }
    setSegs(next);
    onValueChange?.(
      next.month !== null && next.day !== null && next.year !== null
        ? new Date(next.year, next.month - 1, next.day)
        : null,
    );
  };

  const onSegKeyDown = (e: React.KeyboardEvent, i: number) => {
    if (disabled) return;
    const seg = SEGMENTS[i];
    const key = seg.key;
    const max = maxFor(key);
    const cur = segs[key];

    if (e.key === "ArrowUp" || e.key === "ArrowDown") {
      e.preventDefault();
      const dir: 1 | -1 = e.key === "ArrowUp" ? 1 : -1;
      enteredRef.current[key] = 0;
      lastEditRef.current = { mode: "arrow", dir };
      let next: number;
      if (cur === null) {
        const today = new Date();
        next =
          key === "month"
            ? today.getMonth() + 1
            : key === "day"
              ? today.getDate()
              : today.getFullYear();
      } else if (key === "year") {
        next = Math.min(9999, Math.max(1, cur + dir));
      } else {
        next = cur + dir;
        if (next > max) next = seg.min;
        if (next < seg.min) next = max;
      }
      applyChange(key, next);
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      focusSeg(i - 1);
    } else if (e.key === "ArrowRight") {
      e.preventDefault();
      focusSeg(i + 1);
    } else if (e.key === "Backspace" || e.key === "Delete") {
      e.preventDefault();
      enteredRef.current[key] = 0;
      lastEditRef.current = { mode: "type", dir: 1 };
      applyChange(key, null);
    } else if (/^[0-9]$/.test(e.key)) {
      e.preventDefault();
      lastEditRef.current = { mode: "type", dir: 1 };
      const digit = Number(e.key);
      const already = enteredRef.current[key];
      let val: number;
      let entered: number;
      if (already === 0 || cur === null) {
        val = digit;
        entered = 1;
      } else {
        const candidate = cur * 10 + digit;
        if (candidate > max) {
          // Overflow: shake and restart the segment with this digit.
          triggerShake(key);
          val = digit;
          entered = 1;
        } else {
          val = candidate;
          entered = already + 1;
        }
      }
      if (entered === seg.len && val < seg.min) {
        // Invalid completion like "00" — shake and keep waiting.
        triggerShake(key);
        entered = seg.len - 1;
      }
      enteredRef.current[key] = entered;
      applyChange(key, val);
      if (entered >= seg.len || val * 10 > max) {
        enteredRef.current[key] = 0;
        focusSeg(i + 1);
      }
    }
  };

  const complete =
    segs.month !== null && segs.day !== null && segs.year !== null;
  const formValue = complete
    ? `${String(segs.year).padStart(4, "0")}-${String(segs.month).padStart(2, "0")}-${String(segs.day).padStart(2, "0")}`
    : "";
  const edit = lastEditRef.current;

  return (
    <div
      role="group"
      aria-label={ariaLabel}
      aria-disabled={disabled || undefined}
      className={cn(
        "flex h-9 w-fit items-center gap-0.5 rounded-lg border border-input bg-transparent px-2 transition-[border-color,box-shadow] duration-150 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30",
        disabled && "pointer-events-none opacity-50",
        className,
      )}
      {...props}
    >
      <style href="paragon-date-field" precedence="paragon">{`
        @keyframes pg-date-field-shake {
          0%, 100% { transform: translateX(0); }
          20% { transform: translateX(-3px); }
          40% { transform: translateX(3px); }
          60% { transform: translateX(-2px); }
          80% { transform: translateX(1px); }
        }
        @media (prefers-reduced-motion: reduce) {
          @keyframes pg-date-field-shake {
            0%, 100% { transform: none; }
          }
        }
      `}</style>
      {SEGMENTS.map((seg, i) => {
        const val = segs[seg.key];
        const shakeCount = shakes[seg.key];
        return (
          <React.Fragment key={seg.key}>
            {i > 0 && (
              <span className="text-muted-foreground/60 select-none" aria-hidden>
                /
              </span>
            )}
            <div
              ref={(node) => {
                segRefs.current[seg.key] = node;
              }}
              role="spinbutton"
              tabIndex={disabled ? -1 : 0}
              aria-label={seg.label}
              aria-valuemin={seg.min}
              aria-valuemax={maxFor(seg.key)}
              aria-valuenow={val ?? undefined}
              aria-valuetext={val === null ? "Empty" : undefined}
              onKeyDown={(e) => onSegKeyDown(e, i)}
              onFocus={() => {
                enteredRef.current[seg.key] = 0;
              }}
              className={cn(
                "cursor-text rounded-sm px-1 py-0.5 text-sm tabular-nums outline-none transition-colors duration-100 select-none focus:bg-accent focus:text-accent-foreground",
                val === null && "text-muted-foreground",
              )}
            >
              <span
                key={shakeCount}
                className="flex"
                style={
                  shakeCount > 0
                    ? {
                        animation: `pg-date-field-shake 250ms var(--ease-in-out)`,
                      }
                    : undefined
                }
              >
                {val === null ? (
                  <span aria-hidden className="select-none">
                    {seg.placeholder}
                  </span>
                ) : (
                  <SegmentChars
                    text={String(val).padStart(seg.len, "0")}
                    roll={edit.mode === "arrow" && !reduced}
                    dir={edit.dir}
                  />
                )}
              </span>
            </div>
          </React.Fragment>
        );
      })}
      {name && <input type="hidden" name={name} value={formValue} />}
    </div>
  );
}