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.
npx shadcn@latest add @paragon/date-field"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>
);
}