ns-ui
Date Picker Moon
Masked date input whose popover calendar doubles as a moon-phase almanac: every day cell carries a canvas moon with a real synodic terminator, the field icon tracks the focused date's phase live, and committing runs an eclipse transit across the chosen numeral.
Usage
registry/core/date-picker-moon/demo.tsx"use client";
import { useState } from "react";
import { TerminatorDateField } from "./component";
export default function TerminatorDateFieldDemo() {
const [date, setDate] = useState<Date | null>(() => new Date());
const [saved, setSaved] = useState(false);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
ns-ui / date-picker-moon
</p>
<div className="w-full max-w-md rounded-md border border-border bg-surface">
<div className="border-b border-border px-6 py-5">
<h2 className="text-sm font-semibold text-foreground">
New journal entry
</h2>
<p className="mt-1 text-sm text-ns-muted">
The date picker doubles as a moon-phase almanac — every day cell
carries its computed terminator, and committing a date runs a tiny
eclipse across the numeral.
</p>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<div>
<label
htmlFor="journal-title"
className="mb-1.5 block text-[13px] font-medium text-foreground"
>
Title
</label>
<input
id="journal-title"
type="text"
defaultValue="Night sky over the ridge"
onChange={() => setSaved(false)}
className="h-9 w-full rounded-sm border border-border bg-background px-3 text-sm text-foreground placeholder:text-ns-muted/60 transition-colors hover:border-foreground/25 focus:border-ns-accent focus:outline-none focus:ring-2 focus:ring-ns-accent/25"
/>
</div>
<TerminatorDateField
label="Entry date"
value={date}
onValueChange={(d) => {
setDate(d);
setSaved(false);
}}
/>
<div>
<label
htmlFor="journal-notes"
className="mb-1.5 block text-[13px] font-medium text-foreground"
>
Notes
</label>
<textarea
id="journal-notes"
rows={3}
defaultValue="Clear seeing after midnight. Terminator shadows sharp along the Apennines; worth dragging the dob out again tomorrow."
onChange={() => setSaved(false)}
className="w-full resize-none rounded-sm border border-border bg-background px-3 py-2 text-sm leading-relaxed text-foreground placeholder:text-ns-muted/60 transition-colors hover:border-foreground/25 focus:border-ns-accent focus:outline-none focus:ring-2 focus:ring-ns-accent/25"
/>
</div>
</div>
<div className="flex items-center justify-between border-t border-border px-6 py-4">
<p className="font-mono text-xs text-ns-muted" aria-live="polite">
{saved
? "Entry saved"
: date
? `Dated ${date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}`
: "No date set"}
</p>
<button
type="button"
onClick={() => setSaved(true)}
className="rounded-sm bg-ns-accent px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-ns-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Save entry
</button>
</div>
</div>
<p className="max-w-md text-center text-xs text-ns-muted">
Open the calendar (or press Arrow Down in the field), arrow through the
month and watch the trailing moon track your focus. Enter commits with
an eclipse transit across the chosen day.
</p>
</div>
);
}Component
registry/core/date-picker-moon/component.tsx"use client";
import {
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
// ---------------------------------------------------------------------------
// TerminatorDateField — masked MM/DD/YYYY date input whose popover calendar
// doubles as a moon-phase almanac. Every one of the 42 day cells carries a
// 14px canvas moon whose terminator curve comes from the real mean synodic
// month (29.530588853 d, reference new moon 2000-01-06 18:14 UTC); a 20px
// canvas in the input's trailing slot mirrors the focused date's phase live
// (150ms crossfade), and committing a date sweeps an eclipse shadow-disc
// across the chosen numeral (420ms ease-out-expo, cleared and redrawn every
// frame — zero accumulation). ONE canvas sits behind the whole grid; glyph
// positions derive from measured cell rects after layout settles. All ink is
// getComputedStyle-token-derived at mount and re-derived on documentElement
// class changes. rAF runs only during crossfade/transit; the static almanac
// costs zero frames.
// ---------------------------------------------------------------------------
const SYNODIC = 29.530588853; // mean synodic month, days
const EPOCH_MS = Date.UTC(2000, 0, 6, 18, 14); // reference new moon, UTC
const DAY_MS = 86400000;
const ICON = 20; // trailing-slot canvas, px
const ICON_R = 8;
const MOON_R = 7; // 14px grid discs
const NUM_Y = 13; // numeral center within a 44px cell
const MOON_Y = 31; // moon center within a 44px cell
const CROSSFADE_MS = 150;
const TRANSIT_MS = 420;
const PHASE_NAMES = [
"New moon",
"Waxing crescent",
"First quarter",
"Waxing gibbous",
"Full moon",
"Waning gibbous",
"Last quarter",
"Waning crescent",
] as const;
const WEEKDAYS = [
["Su", "Sunday"],
["Mo", "Monday"],
["Tu", "Tuesday"],
["We", "Wednesday"],
["Th", "Thursday"],
["Fr", "Friday"],
["Sa", "Saturday"],
] as const;
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] as const;
type RGB = readonly [number, number, number];
interface Ink {
fg: RGB;
bd: RGB;
bg: RGB;
}
interface CellGeom {
x: number;
y: number;
w: number;
h: number;
cx: number;
numY: number;
moonY: number;
f: number; // phase fraction for this cell's date
dim: boolean; // outside the viewed month
}
interface GridGeom {
w: number;
h: number;
dpr: number;
cells: (CellGeom | null)[];
}
function parseColor(raw: string): RGB | null {
const s = raw.trim();
if (s.startsWith("#")) {
const hex = s.slice(1);
if (hex.length === 3) {
const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);
const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);
const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);
return Number.isNaN(r + g + b) ? null : [r, g, b];
}
if (hex.length >= 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return Number.isNaN(r + g + b) ? null : [r, g, b];
}
return null;
}
const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
const rgba = (c: RGB, a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
/** phase fraction f in [0,1): 0 = new, 0.5 = full. Sampled at local noon. */
function phaseFraction(d: Date): number {
const noon = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 12);
const days = (noon.getTime() - EPOCH_MS) / DAY_MS;
return (((days % SYNODIC) + SYNODIC) % SYNODIC) / SYNODIC;
}
const phaseIndex = (f: number) => Math.round(f * 8) % 8;
const illumination = (f: number) => (1 - Math.cos(2 * Math.PI * f)) / 2;
/**
* Disc stroked --border; lit region --foreground at 85% alpha. Terminator is
* a semi-ellipse with x-radius r*|cos(2πf)|; the lit limb sits right while
* waxing (f < 0.5) and left while waning, ellipse sweep flips crescent/gibbous.
*/
function drawMoon(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
r: number,
f: number,
ink: Ink,
alpha: number
) {
const phi = 2 * Math.PI * f;
const c = Math.cos(phi);
const k = (1 - c) / 2;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = rgba(ink.bd, 0.9 * alpha);
ctx.lineWidth = 1;
ctx.stroke();
if (k < 0.015) return; // new moon — outline only
const ri = r - 0.5;
ctx.beginPath();
if (k > 0.985) {
ctx.arc(cx, cy, ri, 0, Math.PI * 2);
} else {
const waxing = f < 0.5;
const rx = Math.min(ri, Math.max(0.02, ri * Math.abs(c)));
// lit limb: right semicircle for waxing, left for waning (top -> bottom)
ctx.arc(cx, cy, ri, -Math.PI / 2, Math.PI / 2, !waxing);
// terminator: bottom -> top, bulging toward or away from the lit limb
ctx.ellipse(cx, cy, rx, ri, 0, Math.PI / 2, -Math.PI / 2, waxing ? c > 0 : c < 0);
}
ctx.fillStyle = rgba(ink.fg, 0.85 * alpha);
ctx.fill();
}
const easeOutExpo = (t: number) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t));
const pad2 = (n: number) => String(n).padStart(2, "0");
const pad4 = (n: number) => String(n).padStart(4, "0");
const fmt = (d: Date) =>
`${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}/${pad4(d.getFullYear())}`;
const addDays = (d: Date, n: number) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
function addMonths(d: Date, n: number) {
const y = d.getFullYear();
const m = d.getMonth() + n;
const last = new Date(y, m + 1, 0).getDate();
return new Date(y, m, Math.min(d.getDate(), last));
}
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
/** 42-cell month sheet starting on the Sunday on/before the 1st. */
function cellDates(y: number, m: number): Date[] {
const offset = new Date(y, m, 1).getDay();
return Array.from({ length: 42 }, (_, i) => new Date(y, m, i + 1 - offset));
}
/** MM/DD/YYYY mask: digits only, slashes inserted as you type. */
function maskText(raw: string): string {
const d = raw.replace(/\D/g, "").slice(0, 8);
let out = d.slice(0, 2);
if (d.length > 2) out += "/" + d.slice(2, 4);
if (d.length > 4) out += "/" + d.slice(4, 8);
return out;
}
function parseMasked(text: string): Date | null {
const m = text.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (!m) return null;
const mo = Number(m[1]);
const da = Number(m[2]);
const yr = Number(m[3]);
if (yr < 1000 || yr > 9999) return null; // reject non-4-digit-real years
const d = new Date(yr, mo - 1, da);
return d.getFullYear() === yr && d.getMonth() === mo - 1 && d.getDate() === da
? d
: null;
}
export function TerminatorDateField({
value,
defaultValue = null,
onValueChange,
label = "Date",
disabled = false,
className = "",
}: {
/** controlled selected date; omit for uncontrolled */
value?: Date | null;
/** uncontrolled initial selected date */
defaultValue?: Date | null;
/** called with the new date when a day is picked */
onValueChange?: (date: Date) => void;
/** field label rendered above the trigger */
label?: string;
/** blocks the trigger and closes the calendar if open */
disabled?: boolean;
/** extra classes merged onto the rendered root element */
className?: string;
}) {
const uid = useId();
const inputId = `${uid}-input`;
const helperId = `${uid}-helper`;
const popId = `${uid}-pop`;
const isControlled = value !== undefined;
const [internal, setInternal] = useState<Date | null>(defaultValue);
const selected = isControlled ? (value ?? null) : internal;
const [text, setText] = useState(() => (selected ? fmt(selected) : ""));
const [open, setOpen] = useState(false);
const [focusedDate, setFocusedDate] = useState<Date>(
() => selected ?? new Date()
);
const [view, setView] = useState(() => {
const b = selected ?? new Date();
return { y: b.getFullYear(), m: b.getMonth() };
});
const rootRef = useRef<HTMLDivElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const iconCanvasRef = useRef<HTMLCanvasElement>(null);
const gridWrapRef = useRef<HTMLDivElement>(null);
const gridCanvasRef = useRef<HTMLCanvasElement>(null);
const cellRefs = useRef<(HTMLButtonElement | null)[]>([]);
const geomRef = useRef<GridGeom | null>(null);
const justOpenedRef = useRef(false);
const closeTimerRef = useRef(0);
const pendingCaretRef = useRef<number | null>(null);
const animRef = useRef<{
iconF: number | null;
iconAnim: { from: number; to: number; start: number } | null;
transit: { idx: number; start: number } | null;
}>({ iconF: null, iconAnim: null, transit: null });
const engineRef = useRef<{
setIcon: (f: number) => void;
startTransit: (idx: number) => boolean;
redrawGrid: () => void;
} | null>(null);
const cells = useMemo(() => cellDates(view.y, view.m), [view.y, view.m]);
const today = useMemo(() => new Date(), []);
const invalid = text.length === 10 && parseMasked(text) === null;
const previewDate = open
? focusedDate
: (parseMasked(text) ?? selected ?? today);
const previewF = phaseFraction(previewDate);
const previewName = PHASE_NAMES[phaseIndex(previewF)];
// -- engine: token ink, icon crossfade, grid redraw, eclipse transit -------
useEffect(() => {
const icon = iconCanvasRef.current;
const root = rootRef.current;
if (!icon || !root) return;
const ictx = icon.getContext("2d");
if (!ictx) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const dpr = Math.min(2, window.devicePixelRatio || 1);
// <canvas> is a replaced element — size it explicitly, never via inset
icon.style.width = `${ICON}px`;
icon.style.height = `${ICON}px`;
icon.width = ICON * dpr;
icon.height = ICON * dpr;
const ink: Ink = {
fg: [237, 237, 237],
bd: [46, 46, 46],
bg: [10, 10, 10],
};
const derive = () => {
const cs = getComputedStyle(document.documentElement);
ink.fg = parseColor(cs.getPropertyValue("--foreground")) ?? ink.fg;
ink.bd = parseColor(cs.getPropertyValue("--border")) ?? ink.bd;
ink.bg = parseColor(cs.getPropertyValue("--background")) ?? ink.bg;
};
derive();
let raf = 0;
let visible = true;
const drawIcon = (from: number | null, to: number, t: number) => {
ictx.setTransform(dpr, 0, 0, dpr, 0, 0);
ictx.clearRect(0, 0, ICON, ICON);
if (from !== null && t < 1)
drawMoon(ictx, ICON / 2, ICON / 2, ICON_R, from, ink, 1 - t);
drawMoon(
ictx,
ICON / 2,
ICON / 2,
ICON_R,
to,
ink,
from !== null ? Math.min(1, t) : 1
);
};
const drawGridStatic = () => {
const canvas = gridCanvasRef.current;
const g = geomRef.current;
if (!canvas || !g) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(g.dpr, 0, 0, g.dpr, 0, 0);
ctx.clearRect(0, 0, g.w, g.h);
for (const c of g.cells) {
if (!c) continue;
drawMoon(ctx, c.cx, c.moonY, MOON_R, c.f, ink, c.dim ? 0.45 : 1);
}
};
// full clear + full redraw every transit frame — no accumulation
const drawGridFrame = (p: number, idx: number) => {
const canvas = gridCanvasRef.current;
const g = geomRef.current;
if (!canvas || !g) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
drawGridStatic();
const cell = g.cells[idx];
if (!cell) return;
const e = easeOutExpo(p);
const env = p < 0.15 ? p / 0.15 : p > 0.85 ? (1 - p) / 0.15 : 1;
const R = 10;
ctx.save();
ctx.beginPath();
ctx.rect(cell.x, cell.y, cell.w, cell.h);
ctx.clip();
ctx.globalAlpha = env;
// lit corona behind the numeral...
ctx.beginPath();
ctx.arc(cell.cx, cell.numY, R, 0, Math.PI * 2);
ctx.fillStyle = rgba(ink.fg, 0.22);
ctx.fill();
// ...eclipsed by a background-ink shadow disc sweeping across it
const sx = cell.cx - R * 2.6 + e * R * 5.2;
ctx.beginPath();
ctx.arc(sx, cell.numY, R + 1, 0, Math.PI * 2);
ctx.fillStyle = rgba(ink.bg, 1);
ctx.fill();
ctx.strokeStyle = rgba(ink.bd, 1);
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
};
const tick = (now: number) => {
raf = 0;
if (document.hidden || !visible) return; // parked; wake resumes
const A = animRef.current;
let active = false;
if (A.iconAnim) {
const t = Math.min(1, (now - A.iconAnim.start) / CROSSFADE_MS);
drawIcon(A.iconAnim.from, A.iconAnim.to, t);
if (t >= 1) A.iconAnim = null;
else active = true;
}
if (A.transit) {
const p = Math.min(1, (now - A.transit.start) / TRANSIT_MS);
drawGridFrame(p, A.transit.idx);
if (p >= 1) {
A.transit = null;
drawGridStatic();
} else active = true;
}
if (active) raf = requestAnimationFrame(tick);
};
const wake = () => {
if (!raf && !document.hidden && visible) raf = requestAnimationFrame(tick);
};
const setIcon = (f: number) => {
const A = animRef.current;
if (A.iconF === null || reduced || Math.abs(f - A.iconF) < 1e-4) {
A.iconF = f;
A.iconAnim = null;
drawIcon(null, f, 1);
return;
}
A.iconAnim = { from: A.iconF, to: f, start: performance.now() };
A.iconF = f;
wake();
};
const startTransit = (idx: number) => {
const g = geomRef.current;
if (reduced || !g || !g.cells[idx] || !gridCanvasRef.current)
return false;
animRef.current.transit = { idx, start: performance.now() };
wake();
return true;
};
engineRef.current = { setIcon, startTransit, redrawGrid: drawGridStatic };
// live theme re-derive: watch documentElement class flips
const mo = new MutationObserver(() => {
derive();
const A = animRef.current;
if (A.iconF !== null) drawIcon(null, A.iconF, 1);
drawGridStatic();
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const io = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible) wake();
});
io.observe(root);
const onVis = () => {
if (!document.hidden) wake();
};
document.addEventListener("visibilitychange", onVis);
return () => {
cancelAnimationFrame(raf);
mo.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
animRef.current.iconAnim = null;
animRef.current.transit = null;
engineRef.current = null;
};
}, []);
// trailing icon tracks the focused/typed date's phase
useEffect(() => {
engineRef.current?.setIcon(previewF);
}, [previewF]);
// -- grid canvas: measure real cell rects after layout settles, draw once --
useEffect(() => {
if (!open) {
geomRef.current = null;
return;
}
const wrap = gridWrapRef.current;
const canvas = gridCanvasRef.current;
if (!wrap || !canvas) return;
let raf1 = 0;
let raf2 = 0;
const measure = () => {
const rect = wrap.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) {
geomRef.current = null; // zero-size guard
return;
}
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.style.width = `${rect.width}px`; // explicit — replaced element
canvas.style.height = `${rect.height}px`;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
const gcells: (CellGeom | null)[] = new Array(42).fill(null);
cells.forEach((date, i) => {
const el = cellRefs.current[i];
if (!el) return;
const b = el.getBoundingClientRect();
const x = b.left - rect.left;
const y = b.top - rect.top;
gcells[i] = {
x,
y,
w: b.width,
h: b.height,
cx: x + b.width / 2,
numY: y + NUM_Y,
moonY: y + MOON_Y,
f: phaseFraction(date),
dim: date.getMonth() !== view.m,
};
});
geomRef.current = { w: rect.width, h: rect.height, dpr, cells: gcells };
engineRef.current?.redrawGrid();
};
// double rAF: let popover layout settle before reading cell offsets
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(measure);
});
const ro = new ResizeObserver(() => {
cancelAnimationFrame(raf2);
raf2 = requestAnimationFrame(measure);
});
ro.observe(wrap);
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
ro.disconnect();
geomRef.current = null;
animRef.current.transit = null;
};
}, [open, cells, view.m]);
// roving focus follows the focused cell while the grid owns focus
useEffect(() => {
if (!open) return;
const idx = cells.findIndex((c) => sameDay(c, focusedDate));
if (idx < 0) return;
const el = cellRefs.current[idx];
if (!el) return;
const ae = document.activeElement;
const inGrid =
ae instanceof HTMLElement && ae.getAttribute("role") === "gridcell";
if (justOpenedRef.current || (popRef.current?.contains(ae) && inGrid)) {
el.focus();
justOpenedRef.current = false;
}
}, [open, focusedDate, cells]);
// outside click closes without stealing focus
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) close(false);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// sync typed text when a controlled value changes from outside
const controlledTime = isControlled ? (value ? value.getTime() : -1) : null;
useEffect(() => {
if (controlledTime === null) return;
setText(value ? fmt(value) : "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlledTime]);
useEffect(() => {
return () => window.clearTimeout(closeTimerRef.current);
}, []);
// restore caret after the mask reformats `text` — React resets the DOM
// value on every render, which otherwise collapses the caret to the end
useLayoutEffect(() => {
const pos = pendingCaretRef.current;
if (pos === null) return;
pendingCaretRef.current = null;
inputRef.current?.setSelectionRange(pos, pos);
}, [text]);
const close = (focusBack: boolean) => {
window.clearTimeout(closeTimerRef.current);
animRef.current.transit = null;
setOpen(false);
if (focusBack) inputRef.current?.focus();
};
const openPopover = () => {
if (disabled) return;
const base = parseMasked(text) ?? selected ?? new Date();
setFocusedDate(base);
setView({ y: base.getFullYear(), m: base.getMonth() });
justOpenedRef.current = true;
setOpen(true);
};
const moveFocus = (d: Date) => {
setFocusedDate(d);
setView({ y: d.getFullYear(), m: d.getMonth() });
};
const commit = (date: Date, idx: number | null) => {
if (!isControlled) setInternal(date);
onValueChange?.(date);
setText(fmt(date));
setFocusedDate(date);
if (open) {
if (idx !== null && engineRef.current?.startTransit(idx)) {
closeTimerRef.current = window.setTimeout(
() => close(true),
TRANSIT_MS + 60
);
} else close(true);
}
};
const onGridKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
let d: Date | null = null;
switch (e.key) {
case "ArrowLeft":
d = addDays(focusedDate, -1);
break;
case "ArrowRight":
d = addDays(focusedDate, 1);
break;
case "ArrowUp":
d = addDays(focusedDate, -7);
break;
case "ArrowDown":
d = addDays(focusedDate, 7);
break;
case "Home":
d = addDays(focusedDate, -focusedDate.getDay());
break;
case "End":
d = addDays(focusedDate, 6 - focusedDate.getDay());
break;
case "PageUp":
d = addMonths(focusedDate, e.shiftKey ? -12 : -1);
break;
case "PageDown":
d = addMonths(focusedDate, e.shiftKey ? 12 : 1);
break;
default:
return;
}
e.preventDefault();
if (d) moveFocus(d);
};
const focusedIdx = cells.findIndex((c) => sameDay(c, focusedDate));
const tabbableIdx =
focusedIdx >= 0 ? focusedIdx : new Date(view.y, view.m, 1).getDay();
const monthLabel = `${MONTHS[view.m]} ${view.y}`;
return (
<div ref={rootRef} className={`relative ${className}`}>
<label
htmlFor={inputId}
className="mb-1.5 block text-[13px] font-medium text-foreground"
>
{label}
</label>
<div
className={`flex items-center gap-1.5 rounded-sm border bg-background pl-3 pr-1.5 transition-colors ${
invalid ? "border-foreground/40" : "border-border"
} ${
disabled
? "cursor-not-allowed opacity-60"
: "focus-within:border-ns-accent focus-within:ring-2 focus-within:ring-ns-accent/25 hover:border-foreground/25"
}`}
>
<input
ref={inputRef}
id={inputId}
type="text"
inputMode="numeric"
autoComplete="off"
placeholder="MM/DD/YYYY"
disabled={disabled}
value={text}
aria-invalid={invalid || undefined}
aria-describedby={helperId}
onChange={(e) => {
const raw = e.target.value;
const caret = e.target.selectionStart ?? raw.length;
// caret position expressed as "how many digits precede it" is
// stable across reformatting; slash count shifts around it
const digitsBeforeCaret = raw
.slice(0, caret)
.replace(/\D/g, "").length;
const next = maskText(raw);
let pos = next.length;
if (digitsBeforeCaret <= 0) {
pos = 0;
} else {
let seen = 0;
for (let i = 0; i < next.length; i++) {
if (/\d/.test(next[i])) {
seen++;
if (seen === digitsBeforeCaret) {
pos = i + 1;
break;
}
}
}
}
pendingCaretRef.current = pos;
setText(next);
const d = parseMasked(next);
if (d) {
if (!isControlled) setInternal(d);
onValueChange?.(d);
setFocusedDate(d);
setView({ y: d.getFullYear(), m: d.getMonth() });
}
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
openPopover();
} else if (e.key === "Escape" && open) {
e.preventDefault();
close(true);
} else if (e.key === "Enter" && open) {
e.preventDefault();
close(false);
}
}}
onBlur={(e) => {
if (rootRef.current?.contains(e.relatedTarget as Node)) return;
if (text !== "" && parseMasked(text) === null)
setText(selected ? fmt(selected) : "");
}}
className="h-9 w-full bg-transparent font-mono text-sm text-foreground placeholder:text-ns-muted/60 focus:outline-none disabled:cursor-not-allowed"
/>
<button
type="button"
disabled={disabled}
aria-label={`${open ? "Close" : "Open"} calendar. ${previewName}.`}
aria-expanded={open}
aria-haspopup="grid"
aria-controls={open ? popId : undefined}
onClick={() => (open ? close(false) : openPopover())}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm text-ns-muted transition-colors hover:bg-foreground/[0.06] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ns-accent disabled:cursor-not-allowed"
>
<canvas
ref={iconCanvasRef}
width={ICON}
height={ICON}
aria-hidden
className="pointer-events-none"
/>
</button>
</div>
<p id={helperId} className="mt-1.5 font-mono text-xs text-ns-muted">
{invalid
? "Not a valid date"
: `${previewName} · ${Math.round(illumination(previewF) * 100)}% illuminated`}
</p>
{open && (
<div
ref={popRef}
id={popId}
role="dialog"
aria-label="Choose date"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
close(true);
}
}}
className="absolute left-0 top-full z-30 mt-2 w-[292px] rounded-md border border-border bg-surface p-3 shadow-[0_12px_32px_-8px_rgba(0,0,0,0.35)]"
>
<div className="mb-2 flex items-center justify-between">
<button
type="button"
aria-label="Previous month"
onClick={() => moveFocus(addMonths(focusedDate, -1))}
className="flex h-7 w-7 items-center justify-center rounded-sm text-ns-muted transition-colors hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ns-accent"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<p
aria-live="polite"
className="text-[13px] font-semibold text-foreground"
>
{monthLabel}
</p>
<button
type="button"
aria-label="Next month"
onClick={() => moveFocus(addMonths(focusedDate, 1))}
className="flex h-7 w-7 items-center justify-center rounded-sm text-ns-muted transition-colors hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ns-accent"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
<div ref={gridWrapRef} className="relative">
{/* one canvas behind the whole grid — all 42 moons + transit */}
<canvas
ref={gridCanvasRef}
aria-hidden
className="pointer-events-none absolute left-0 top-0"
/>
<div
role="grid"
aria-label={monthLabel}
onKeyDown={onGridKeyDown}
className="relative z-[1]"
>
<div role="row" className="grid grid-cols-7">
{WEEKDAYS.map(([abbr, full]) => (
<div
key={abbr}
role="columnheader"
aria-label={full}
className="pb-1 text-center font-mono text-[10px] uppercase tracking-wider text-ns-muted"
>
{abbr}
</div>
))}
</div>
{Array.from({ length: 6 }, (_, r) => (
<div key={r} role="row" className="grid grid-cols-7">
{cells.slice(r * 7, r * 7 + 7).map((date, c) => {
const i = r * 7 + c;
const inMonth = date.getMonth() === view.m;
const isSel = selected !== null && sameDay(date, selected);
const isToday = sameDay(date, today);
const f = phaseFraction(date);
return (
<button
key={i}
ref={(el) => {
cellRefs.current[i] = el;
}}
type="button"
role="gridcell"
tabIndex={i === tabbableIdx ? 0 : -1}
aria-selected={isSel}
aria-current={isToday ? "date" : undefined}
aria-label={`${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}. ${PHASE_NAMES[phaseIndex(f)]}.`}
onClick={() => commit(date, i)}
onFocus={() => setFocusedDate(date)}
className={`relative flex h-11 flex-col items-center rounded-sm pt-[7px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ns-accent ${
isSel
? "bg-ns-accent/10 text-ns-accent"
: inMonth
? "text-foreground hover:bg-foreground/[0.06]"
: "text-ns-muted/70 hover:bg-foreground/[0.04]"
}`}
>
<span
className={`text-[13px] leading-none ${
isToday && !isSel
? "underline decoration-ns-muted underline-offset-[3px]"
: ""
}`}
>
{date.getDate()}
</span>
</button>
);
})}
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/date-picker-moon.tsx, so point the import there.
Use when a single calendar date where the lunar phase of the choice is genuinely relevant or worth the ceremony (launch dates, observation windows, editorial calendars), with a masked MM/DD/YYYY field for typists and a 42-cell grid for pointers; pick date-range-tape instead when a span and its duration are what is being decided rather than one day, or time-picker-sundial when the value is a time of day rather than a date.
Install
npx shadcn add https://design.helpmarq.com /r/date-picker-moon.jsonBuild spec
Build a date input whose popover calendar doubles as a moon-phase almanac. RENDER: masked text input (MM/DD/YYYY, digits auto-slashed, full validation with revert-on-blur) plus a popover role=grid month calendar of 42 real button cells (role=gridcell, roving tabindex); ONE canvas absolutely positioned behind the whole grid draws all 42 moon glyphs (14px discs) at cell centers measured via getBoundingClientRect after a double-rAF layout settle, plus a 20px canvas in the input's trailing icon slot; both canvases get EXPLICIT style.width/height and dpr-clamped backing stores (a canvas is a replaced element — inset never sizes it). PHASE MATH: mean synodic month 29.530588853 d, reference new moon epoch 2000-01-06 18:14 UTC, sampled at local noon; f = ((days since epoch) mod syn)/syn; illuminated fraction k = (1 - cos(2*pi*f))/2; disc stroked --border, lit region --foreground at 85% alpha, terminator drawn as a semi-ellipse with x-radius r*|cos(2*pi*f)|, lit limb right while waxing (f < 0.5) and left while waning, ellipse sweep direction flipping crescent vs gibbous. MOTION: trailing icon crossfades 150ms (dual-alpha draw, cleared per frame) whenever focus movement or typing changes the previewed date; committing from the grid fires an eclipse transit — a foreground corona behind the chosen numeral occluded by an opaque background-ink shadow disc sweeping left-to-right over 420ms ease-out-expo, clipped to the cell, with the entire grid canvas cleared and all 42 moons redrawn every frame (zero accumulation), then the popover closes and focus returns to the input; month changes re-measure and re-lay glyphs after cell layout settles (double rAF + ResizeObserver). rAF runs only during crossfade/transit and is parked offscreen (IntersectionObserver) and on document.hidden — the static almanac costs zero frames. INTERACTION: Arrows move a day/week, Home/End week edges, PageUp/Down month, Shift+PageUp/Down year (view follows focus), Enter/click commits, Esc or outside pointerdown closes (Esc returns focus), ArrowDown in the input opens; aria-selected and aria-current=date on cells; phase name (e.g. Waxing gibbous) in every cell's accessible label and as visible helper text with percent illumination under the field. REDUCED MOTION: no transit or crossfade, instant selection and close; static moons stay as information. TOKENS: all canvas ink parsed from getComputedStyle(--foreground/--border/--background) at mount and re-derived via MutationObserver on documentElement class; hover/focus affordances token-relative (foreground-alpha tints, accent focus rings). Guard zero-size grids before measuring, and tear down every rAF, timer, observer, and document listener on unmount.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value? | Date | null | — | controlled selected date; omit for uncontrolled |
| defaultValue? | Date | null | null | uncontrolled initial selected date |
| onValueChange? | (date: Date) => void | — | called with the new date when a day is picked |
| label? | string | "Date" | field label rendered above the trigger |
| disabled? | boolean | false | blocks the trigger and closes the calendar if open |
| className? | string | — | extra classes merged onto the rendered root element |