Date Picker Open
The calendar unfolds from the field and the days arrive in a wave, today already marked.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Date Picker Open
*
* The calendar unfolds from under the field and the days arrive in a
* wave that runs down the weeks, with today already marked and the
* chosen day already filled. A real grid: arrow keys walk the month and
* cross its edges, Page keys change month, Escape returns to the field.
*
* Self-contained: depends only on `react` and `motion`. The panel uses
* the `Canvas` system colors so it stays opaque over whatever it covers,
* on a light page and on a dark one.
* Works with zero props; tune via `variant`, `label`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type DatePickerOpenProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Field label above the control. */
label?: string;
/** Field width. */
width?: number;
/** Selection, marker and focus color. */
accent?: string;
/** Fires with the chosen date. */
onSelect?: (date: Date) => void;
};
type VariantConfig = {
/** Carries the panel down from under the field. */
panel: { type: "spring"; stiffness: number; damping: number };
/** How far the panel starts above its resting place, in pixels. */
lift: number;
/** Seconds between one week of the wave and the next. */
row: number;
/** Seconds between one day and the next within a week. */
cell: number;
/** Grows the fill under the chosen day. */
fill: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: both springs sit at or above a 0.8 damping ratio. The
// panel is full of numbers and a wobble would drag forty glyphs with it,
// and the day fill is a disc behind text that must not push the numeral
// around. The wave is opacity and a few pixels of travel — nothing in a
// calendar scales.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// The grid is simply there. For a form with a date field in the middle
// of it.
subtle: {
panel: { type: "spring", stiffness: 640, damping: 46 },
lift: 4,
row: 0.014,
cell: 0.004,
fill: { type: "spring", stiffness: 700, damping: 48 },
},
// The wave runs down the weeks fast enough to read as one unfold.
// All-purpose.
default: {
panel: { type: "spring", stiffness: 520, damping: 40 },
lift: 8,
row: 0.028,
cell: 0.008,
fill: { type: "spring", stiffness: 560, damping: 42 },
},
// A longer drop and a slower wave, for a booking screen where picking
// the date is the task.
playful: {
panel: { type: "spring", stiffness: 400, damping: 33 },
lift: 12,
row: 0.042,
cell: 0.012,
fill: { type: "spring", stiffness: 420, damping: 34 },
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or hover fill
* that is correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
type Ymd = { y: number; m: number; d: number };
const same = (a: Ymd, b: Ymd) => a.y === b.y && a.m === b.m && a.d === b.d;
const fromDate = (date: Date): Ymd => ({
y: date.getFullYear(),
m: date.getMonth(),
d: date.getDate(),
});
/** Monday-first weeks, padded with nulls so every row holds seven cells. */
function weeksOf(year: number, month: number): (number | null)[][] {
const lead = (new Date(year, month, 1).getDay() + 6) % 7;
const length = new Date(year, month + 1, 0).getDate();
const cells: (number | null)[] = [
...Array<null>(lead).fill(null),
...Array.from({ length }, (_, index) => index + 1),
];
while (cells.length % 7 !== 0) cells.push(null);
const weeks: (number | null)[][] = [];
for (let index = 0; index < cells.length; index += 7) {
weeks.push(cells.slice(index, index + 7));
}
return weeks;
}
export default function DatePickerOpen({
variant = "default",
label = "Delivery date",
width = 268,
accent = "#5B5BD6",
onSelect,
}: DatePickerOpenProps) {
// Read the clock once, so a component that lives across midnight does
// not silently disagree with itself about which day is today.
const [today] = useState<Ymd>(() => fromDate(new Date()));
const [selected, setSelected] = useState<Ymd>(today);
const [view, setView] = useState({ y: today.y, m: today.m });
const [cursor, setCursor] = useState(today.d);
const [open, setOpen] = useState(false);
const [ring, setRing] = useState(false);
const fieldRef = useRef<HTMLButtonElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const baseId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const weeks = weeksOf(view.y, view.m);
// Pointer down anywhere else closes the panel.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
const root = fieldRef.current?.parentElement;
if (root && !root.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
// The grid owns the focus while it is open: opening lands on the day
// the user would edit, and every arrow key moves that focus with it.
useEffect(() => {
if (!open) return;
const cell = gridRef.current?.querySelector<HTMLButtonElement>(
`[data-day="${cursor}"]`
);
cell?.focus();
}, [open, cursor, view.y, view.m]);
const openPanel = () => {
setView({ y: selected.y, m: selected.m });
setCursor(selected.d);
setOpen(true);
};
const close = () => {
setOpen(false);
fieldRef.current?.focus();
};
/** Walk the cursor by whole days, letting the month change under it. */
const move = (deltaDays: number) => {
const next = new Date(view.y, view.m, cursor + deltaDays);
setView({ y: next.getFullYear(), m: next.getMonth() });
setCursor(next.getDate());
};
const shiftMonth = (delta: number) => {
const next = new Date(view.y, view.m + delta, 1);
const length = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
setView({ y: next.getFullYear(), m: next.getMonth() });
setCursor(Math.min(cursor, length));
};
const choose = (day: number) => {
const picked = { y: view.y, m: view.m, d: day };
setSelected(picked);
setCursor(day);
setOpen(false);
onSelect?.(new Date(picked.y, picked.m, picked.d));
fieldRef.current?.focus();
};
const onGridKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const key = event.key;
if (key === "Escape") {
event.preventDefault();
close();
} else if (key === "ArrowRight") {
event.preventDefault();
move(1);
} else if (key === "ArrowLeft") {
event.preventDefault();
move(-1);
} else if (key === "ArrowDown") {
event.preventDefault();
move(7);
} else if (key === "ArrowUp") {
event.preventDefault();
move(-7);
} else if (key === "Home") {
event.preventDefault();
// Monday of the cursor's week.
move(-((new Date(view.y, view.m, cursor).getDay() + 6) % 7));
} else if (key === "End") {
event.preventDefault();
move(6 - ((new Date(view.y, view.m, cursor).getDay() + 6) % 7));
} else if (key === "PageUp") {
event.preventDefault();
shiftMonth(-1);
} else if (key === "PageDown") {
event.preventDefault();
shiftMonth(1);
}
};
const fieldText = `${selected.d} ${MONTHS[selected.m].slice(0, 3)} ${selected.y}`;
const navButton = (delta: number, path: string, name: string) => (
<button
type="button"
aria-label={name}
onClick={() => shiftMonth(delta)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
padding: 0,
borderRadius: 7,
border: "none",
background: "transparent",
color: "inherit",
opacity: 0.6,
cursor: "pointer",
}}
>
<svg viewBox="0 0 16 16" width={14} height={14} fill="none" aria-hidden>
<path
d={path}
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
);
return (
<div style={{ position: "relative", width, color: "inherit" }}>
<div
id={`${baseId}-label`}
style={{ fontSize: 12.5, fontWeight: 600, opacity: 0.6, marginBottom: 7 }}
>
{label}
</div>
<button
ref={fieldRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-labelledby={`${baseId}-label`}
onClick={() => (open ? setOpen(false) : openPanel())}
onKeyDown={(event) => {
if (event.key === "ArrowDown" && !open) {
event.preventDefault();
openPanel();
}
}}
// The ring is for keyboard users only. `:focus-visible` is the
// browser's own answer to "was this focus deliberate?" — read it
// instead of guessing at the input modality.
onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
onBlur={() => setRing(false)}
style={{
display: "flex",
alignItems: "center",
gap: 9,
width: "100%",
padding: "10px 12px",
borderRadius: 10,
fontSize: 13.5,
fontFamily: "inherit",
textAlign: "left",
color: "inherit",
background: tone(6),
border: `1px solid ${open ? accent : tone(14)}`,
cursor: "pointer",
outline: "none",
boxShadow: ring ? `0 0 0 3px ${accent}66` : "none",
transition: "border-color 160ms ease-out, box-shadow 140ms ease-out",
WebkitTapHighlightColor: "transparent",
}}
>
<svg viewBox="0 0 18 18" width={15} height={15} fill="none" aria-hidden>
<rect
x={2.2}
y={3.4}
width={13.6}
height={12}
rx={2.4}
stroke="currentColor"
strokeWidth={1.5}
/>
<path
d="M2.2 7.2 H15.8 M6.2 1.9 V4.4 M11.8 1.9 V4.4"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
/>
</svg>
<span style={{ flex: 1 }}>{fieldText}</span>
</button>
<AnimatePresence>
{open ? (
<motion.div
role="dialog"
aria-label={`${MONTHS[view.m]} ${view.y}`}
initial={{ opacity: 0, y: reduceMotion ? 0 : -cfg.lift }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -cfg.lift * 0.6 }}
transition={
reduceMotion
? { duration: 0.1 }
: { ...cfg.panel, opacity: { duration: 0.14 } }
}
style={{
position: "absolute",
top: "100%",
left: 0,
zIndex: 20,
marginTop: 8,
padding: 12,
borderRadius: 14,
border: `1px solid ${tone(14)}`,
// The panel sits over page content, so it needs a real
// opaque background. `Canvas`/`CanvasText` are the CSS
// system colors for page background and page text: they
// follow the user's light or dark setting without this file
// hard-coding either one.
background: "Canvas",
color: "CanvasText",
boxShadow: "0 16px 40px rgba(0,0,0,0.24)",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 8,
}}
>
{navButton(-1, "M10 3.5 L5.5 8 L10 12.5", "Previous month")}
<div id={`${baseId}-month`} style={{ fontSize: 13, fontWeight: 650 }}>
{MONTHS[view.m]} {view.y}
</div>
{navButton(1, "M6 3.5 L10.5 8 L6 12.5", "Next month")}
</div>
<div
ref={gridRef}
role="grid"
aria-labelledby={`${baseId}-month`}
onKeyDown={onGridKeyDown}
>
<div role="row" style={{ display: "flex" }}>
{WEEKDAYS.map((day) => (
<div
key={day}
role="columnheader"
aria-label={day}
style={{
width: 34,
height: 22,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 650,
opacity: 0.4,
}}
>
{day}
</div>
))}
</div>
{weeks.map((week, rowIndex) => (
<div
key={`${view.y}-${view.m}-${rowIndex}`}
role="row"
style={{ display: "flex" }}
>
{week.map((day, columnIndex) => {
if (day === null) {
return (
// An empty cell rather than no cell: a grid row
// that is short of columns is a grid a screen
// reader cannot navigate.
<div
key={`pad-${columnIndex}`}
role="gridcell"
style={{ width: 34, height: 29 }}
/>
);
}
const cell = { y: view.y, m: view.m, d: day };
const isSelected = same(cell, selected);
const isToday = same(cell, today);
return (
<div
key={day}
role="gridcell"
aria-selected={isSelected}
style={{ width: 34, height: 29 }}
>
{/* The wave: each day fades up a few pixels, later
down the weeks and later across each one. It
gives the grid a reading order without anything
having to scale. */}
<motion.button
type="button"
data-day={day}
// Roving tabindex: the grid is one tab stop and
// the arrow keys move inside it.
tabIndex={day === cursor ? 0 : -1}
aria-label={`${day} ${MONTHS[view.m]} ${view.y}`}
aria-current={isToday ? "date" : undefined}
onClick={() => choose(day)}
initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduceMotion ? 0 : 0.16,
delay: reduceMotion
? 0
: rowIndex * cfg.row + columnIndex * cfg.cell,
ease: "easeOut",
}}
style={{
position: "relative",
width: 34,
height: 29,
padding: 0,
borderRadius: 9,
border: "none",
background: "transparent",
fontFamily: "inherit",
fontSize: 12.5,
fontWeight: isSelected || isToday ? 700 : 500,
color: "inherit",
cursor: "pointer",
outline: "none",
boxShadow:
day === cursor && !isSelected
? `inset 0 0 0 1.5px ${accent}80`
: "none",
WebkitTapHighlightColor: "transparent",
}}
>
{isSelected ? (
// A disc behind the numeral, so the fill can
// spring in while the number itself holds its
// size — text must never ride a scale.
<motion.span
aria-hidden
initial={{ scale: reduceMotion ? 1 : 0.4, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={
reduceMotion
? { duration: 0.1 }
: { ...cfg.fill, opacity: { duration: 0.1 } }
}
style={{
position: "absolute",
inset: 2,
borderRadius: 9,
background: accent,
}}
/>
) : null}
<span
style={{
position: "relative",
color: isSelected ? "#FFFFFF" : "inherit",
}}
>
{day}
</span>
{/* Today keeps a marker of its own, so the grid
still answers "where am I?" after another
day is chosen. */}
{isToday && !isSelected ? (
<span
aria-hidden
style={{
position: "absolute",
left: "50%",
bottom: 4,
width: 3,
height: 3,
marginLeft: -1.5,
borderRadius: 3,
background: accent,
}}
/>
) : null}
</motion.button>
</div>
);
})}
</div>
))}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}About this pattern
Forty small numbers appearing at once is a wall; the same forty arriving in a wave that runs down the weeks and across each one gives the grid a reading order for free, and it costs only opacity and four pixels of travel. Nothing scales, because everything in a calendar is a numeral. The chosen day is a disc that springs in behind its number rather than under it, so the digit never rides the fill. Today keeps a marker of its own after another day is picked, and the grid is a real one: arrows walk it, crossing into the next month when they run off the edge, Page keys change month, Escape hands focus back to the field.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Calendar
A calendar that opens onto the current month with the day already marked.
Related patterns
- Signature Draw CaptureThe scrawl strokes on at hand speed and the baseline confirms once the pen lifts.
- Color Picker SwatchA ring grows around the chosen swatch and travels to the next one, while the preview eases to the new color.
- Currency Input FormatSeparators fade in where they belong as the amount groups itself, and the caret holds its place.