DatePicker
A field that opens a calendar out of itself, closing once the date — or both ends — are in.
The idea
The trigger is a Select's trigger, so a form that mixes the two reads as one set of fields, and the calendar grows out of it with the same squash and stretch as every menu, on the near-opaque panel glass — a month grid is dense and must stay legible over whatever is under it. Opening moves focus to the chosen day, so the arrows work at once. A single date closes the panel as it is picked; a range stays open after the first end and closes on the second. Escape, a click outside or tabbing away close it, and focus goes back to the trigger.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { DatePicker } from "mihirmodi-ui";
<label htmlFor="due">Invoice due</label>
<DatePicker id="due" value={due} onChange={setDue} />
// A range stays open after the first day and closes on the second.
<DatePicker
mode="range"
value={trip}
onChange={setTrip}
min={new Date()}
align="end"
/>Props
| Prop | Type | Notes |
|---|---|---|
| mode"single" | "range" | "single" | "range"= "single" | Passed through to the calendar. |
| value / onChangeDate | null or DateRange | null / (value) => void | Date | null or DateRange | null / (value) => void | Controlled. |
| placeholderstring | string= "Pick a date" | Shown while nothing is picked. |
| formatIntl.DateTimeFormatOptions | Intl.DateTimeFormatOptions= { day, month: "short", year } | How the trigger writes the date. A range uses formatRange, so a shared month is written once. |
| align"start" | "end" | "start" | "end"= "start" | Which edge of the trigger the calendar lines up with. |
| min / max / isDisabled / weekStartsOn / locale— | — | Passed through to the calendar. |
| id / aria-labelstring | string | Pair `id` with a `<label for>`, or give it an `aria-label`. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { DropMenu } from "../DropMenu.js";
import { Calendar, type DateRange, type Weekday } from "./Calendar.js";
import { ChevronDownIcon, glyphProps } from "../../lib/glyph.js";
type SinglePicker = { mode?: "single"; value: Date | null; onChange: (date: Date) => void };
type RangePicker = { mode: "range"; value: DateRange | null; onChange: (range: DateRange) => void };
export type DatePickerProps = (SinglePicker | RangePicker) & {
placeholder?: string;
/** How the chosen date is written on the trigger. Defaults to "16 Oct 2026" in the locale's order. */
format?: Intl.DateTimeFormatOptions;
locale?: string;
weekStartsOn?: Weekday;
min?: Date;
max?: Date;
isDisabled?: (date: Date) => boolean;
/** Which edge of the trigger the calendar lines up with. `end` for a picker near the right of its container. */
align?: "start" | "end";
disabled?: boolean;
id?: string;
/** The accessible name, when there is no visible `<label for>`. */
"aria-label"?: string;
className?: string;
};
function CalendarGlyph() {
return (
<svg {...glyphProps} className="h-4 w-4">
<rect x="3.75" y="5" width="16.5" height="15.25" rx="3" />
<path d="M3.75 9.75h16.5M8 3.25v3.5M16 3.25v3.5" />
</svg>
);
}
/**
* A field that opens a calendar. The trigger is a Select's trigger — the same
* height, hairline and open-state ring — so a form that mixes the two reads as
* one set of fields; the calendar grows out of it with the same squash and
* stretch as every menu, on the near-opaque panel glass, because a month grid
* is dense and has to stay legible over whatever is under it.
*
* Opening moves focus to the chosen day (or today), so the arrow keys work at
* once. A single date closes the panel as soon as it is picked; a range stays
* open after the first end — you are halfway through — and closes on the
* second. Escape, a click outside, or tabbing away closes it too, and focus
* goes back to the trigger unless you had already moved it somewhere else.
*/
export function DatePicker(props: DatePickerProps) {
const {
placeholder = props.mode === "range" ? "Pick dates" : "Pick a date",
format,
locale,
weekStartsOn,
min,
max,
isDisabled,
align = "start",
disabled,
id,
"aria-label": ariaLabel,
className = "",
} = props;
const panelId = useId();
const [open, setOpen] = useState(false);
const btn = useRef<HTMLButtonElement>(null);
const root = useRef<HTMLDivElement>(null);
const hide = (refocus = true) => {
setOpen(false);
if (refocus) btn.current?.focus();
};
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!root.current?.contains(e.target as Node)) hide(false);
};
window.addEventListener("pointerdown", onDown);
return () => window.removeEventListener("pointerdown", onDown);
}, [open]);
const fmt = new Intl.DateTimeFormat(locale, format ?? { day: "numeric", month: "short", year: "numeric" });
let text: string | null = null;
if (props.mode === "range") {
const r = props.value;
if (r?.from && r.to) text = fmt.formatRange(r.from, r.to);
else if (r?.from) text = `${fmt.format(r.from)} – …`;
} else if (props.value) {
text = fmt.format(props.value);
}
const onKey = (e: KeyboardEvent) => {
if (disabled) return;
if (open && e.key === "Escape") {
e.preventDefault();
hide();
} else if (!open && e.key === "ArrowDown" && e.target === btn.current) {
e.preventDefault();
setOpen(true);
}
};
const shared = { locale, weekStartsOn, min, max, isDisabled, autoFocus: true };
return (
<div
ref={root}
className={`relative ${className}`}
onKeyDown={onKey}
onBlur={(e) => {
// Only a real move elsewhere closes it; a click on the panel's own padding blurs to nothing.
if (open && e.relatedTarget && !root.current?.contains(e.relatedTarget as Node)) hide(false);
}}
>
<button
ref={btn}
id={id}
type="button"
disabled={disabled}
aria-label={ariaLabel}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={panelId}
onClick={() => (open ? hide() : setOpen(true))}
className={`flex h-10 w-full items-center gap-2.5 rounded-[10px] border-[0.5px] bg-background-100 px-3 text-left text-[14px] transition-[border-color,box-shadow] duration-150 ease-geist disabled:cursor-not-allowed disabled:opacity-50 ${
open ? "border-gray-alpha-600 shadow-[0_0_0_3px_var(--color-gray-alpha-200)]" : "border-gray-alpha-400 hover:border-gray-alpha-600"
}`}
>
<span className="shrink-0 text-quiet">
<CalendarGlyph />
</span>
<span className={`min-w-0 flex-1 truncate tabular-nums ${text ? "text-gray-1000" : "text-faint"}`}>{text ?? placeholder}</span>
<span className={`shrink-0 text-quiet transition-transform duration-200 ease-geist ${open ? "rotate-180" : ""}`}>
<ChevronDownIcon className="h-3.5 w-3.5" />
</span>
</button>
<DropMenu
id={panelId}
open={open}
placement="down"
origin={align === "end" ? "origin-top-right" : "origin-top-left"}
className={`absolute top-full z-50 mt-1 ${align === "end" ? "right-0" : "left-0"}`}
>
<div
role="dialog"
aria-label={props.mode === "range" ? "Choose dates" : "Choose a date"}
className="glass-panel dock-shadow w-max rounded-[14px] border-[0.5px] border-gray-alpha-400 p-1.5"
>
{props.mode === "range" ? (
<Calendar
mode="range"
value={props.value}
onChange={(r) => {
props.onChange(r);
if (r.to) hide();
}}
{...shared}
/>
) : (
<Calendar
value={props.value}
onChange={(d) => {
props.onChange(d);
hide();
}}
{...shared}
/>
)}
</div>
</DropMenu>
</div>
);
}
"use client";
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion, type Variants } from "motion/react";
import { Button } from "../actions/Button.js";
import { ChevronDownIcon } from "../../lib/glyph.js";
import { CLOSE, EASE_OUT_EXPO, FLAT, LIFT } from "../../lib/motion.js";
/** A stretch of days. `to` is null while the second end is still being picked. */
export type DateRange = { from: Date | null; to: Date | null };
/** 0 is Sunday, as `Date.getDay()` counts. */
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
type SingleCalendar = {
mode?: "single";
value?: Date | null;
defaultValue?: Date | null;
onChange?: (date: Date) => void;
};
type RangeCalendar = {
mode: "range";
value?: DateRange | null;
defaultValue?: DateRange | null;
onChange?: (range: DateRange) => void;
};
export type CalendarProps = (SingleCalendar | RangeCalendar) & {
/** The month on show, controlled. Any day in it will do. */
month?: Date;
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
weekStartsOn?: Weekday;
/** For month and day names. Pass one when rendering on the server, so both sides agree. */
locale?: string;
min?: Date;
max?: Date;
/** Days that cannot be chosen — weekends, booked nights. Still reachable by keyboard. */
isDisabled?: (date: Date) => boolean;
/** Show the neighbouring months' days in the first and last rows, faint and inert. */
showOutsideDays?: boolean;
/** Focus the chosen day (or today) on mount — what a picker wants when it opens. */
autoFocus?: boolean;
"aria-label"?: string;
className?: string;
};
// ── Dates, in local time, at midnight. No library: these are all it needs. ──
const day0 = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const month0 = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
function addMonths(d: Date, n: number) {
const first = new Date(d.getFullYear(), d.getMonth() + n, 1);
const last = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
return new Date(first.getFullYear(), first.getMonth(), Math.min(d.getDate(), last));
}
const cmp = (a: Date, b: Date) => day0(a).getTime() - day0(b).getTime();
const same = (a: Date | null | undefined, b: Date | null | undefined) => !!a && !!b && cmp(a, b) === 0;
const pad = (n: number) => String(n).padStart(2, "0");
const dayKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const monthKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
/** A known Sunday, for reading weekday names out of Intl. */
const SUNDAY = new Date(2026, 0, 4);
/**
* A month of days to choose one from, or a stretch of them.
*
* Chosen days are ink tiles that land with a small settle when picked; the
* days between the two ends of a range sit on a grey band that runs edge to
* edge under the tiles, so the stretch reads as one continuous thing rather
* than a row of separately selected squares. While the second end is still
* open the band follows the pointer (or the keyboard) at half strength, so you
* see the stay before you commit to it. Today is a dot under the number, not a
* colour, so it still reads in greyscale and on top of an ink tile.
*
* Changing month slides the grid a short way in the direction of travel — later
* months come in from the right — and fades, because the grid is the thing that
* changed and the header and weekdays did not. It is short on purpose: you page
* through months several times in a row, and a long slide would make every
* press wait for the last. There are always six rows, so the height never jumps.
*
* It is an ARIA grid with one tab stop: the arrows move by day and week,
* Home and End go to the ends of the week, Page Up and Down change month (with
* Shift, year), and crossing a month edge turns the page under you. Names come
* from `Intl.DateTimeFormat`, so the month, the weekdays and every day's spoken
* label follow the locale without a date library.
*/
export function Calendar(props: CalendarProps) {
const {
month,
defaultMonth,
onMonthChange,
weekStartsOn = 0,
locale,
min,
max,
isDisabled,
showOutsideDays = true,
autoFocus,
"aria-label": ariaLabel,
className = "",
} = props;
const reduce = useReducedMotion();
const capId = useId();
const wrap = useRef<HTMLDivElement>(null);
// ── Selection, controlled or not ──
const [innerDay, setInnerDay] = useState<Date | null>(() => (props.mode === "range" ? null : (props.defaultValue ?? null)));
const [innerSpan, setInnerSpan] = useState<DateRange | null>(() => (props.mode === "range" ? (props.defaultValue ?? null) : null));
const chosen = props.mode === "range" ? null : props.value !== undefined ? props.value : innerDay;
const span = props.mode === "range" ? (props.value !== undefined ? props.value : innerSpan) : null;
const anchor = chosen ?? span?.from ?? null;
// ── The month on show ──
const [innerMonth, setInnerMonth] = useState(() => month0(defaultMonth ?? anchor ?? new Date()));
const view = month ? month0(month) : innerMonth;
const [dir, setDir] = useState(1);
const inView = (d: Date) => d.getFullYear() === view.getFullYear() && d.getMonth() === view.getMonth();
const turn = (to: Date) => {
const next = month0(to);
const d = cmp(next, view);
if (d === 0) return;
setDir(d > 0 ? 1 : -1);
if (!month) setInnerMonth(next);
onMonthChange?.(next);
};
// Today is read after mount, so a server in another timezone cannot mark a different day.
const [today, setToday] = useState<Date | null>(null);
useEffect(() => setToday(day0(new Date())), []);
// ── The roving tab stop ──
const [focus, setFocus] = useState<Date>(() => day0(anchor ?? new Date()));
const tabbable = inView(focus) ? focus : ([chosen, span?.from, span?.to, today].find((d) => d && inView(d)) ?? view);
const wantFocus = useRef(!!autoFocus);
useEffect(() => {
if (!wantFocus.current) return;
wantFocus.current = false;
wrap.current?.querySelector<HTMLButtonElement>(`[data-month="${monthKey(view)}"] button[tabindex="0"]`)?.focus({ preventScroll: true });
});
const off = (d: Date) => (!!min && cmp(d, min) < 0) || (!!max && cmp(d, max) > 0) || !!isDisabled?.(d);
const clamp = (d: Date) => (min && cmp(d, min) < 0 ? day0(min) : max && cmp(d, max) > 0 ? day0(max) : day0(d));
const moveTo = (d: Date) => {
const n = clamp(d);
setFocus(n);
if (!inView(n)) turn(n);
wantFocus.current = true;
};
const page = (n: number) => {
turn(addMonths(view, n));
setFocus(clamp(addMonths(tabbable, n)));
};
// The tile that was just picked lands; every other chosen tile is simply there.
const [landed, setLanded] = useState<string | null>(null);
const choose = (d: Date) => {
if (off(d)) return;
setFocus(d);
setLanded(dayKey(d));
if (props.mode === "range") {
const cur = span;
const next: DateRange =
!cur?.from || cur.to ? { from: d, to: null } : cmp(d, cur.from) < 0 ? { from: d, to: cur.from } : { from: cur.from, to: d };
if (props.value === undefined) setInnerSpan(next);
props.onChange?.(next);
} else {
if (props.value === undefined) setInnerDay(d);
props.onChange?.(d);
}
};
const onKey = (e: KeyboardEvent) => {
const f = tabbable;
const col = (f.getDay() - weekStartsOn + 7) % 7;
const to: Record<string, () => Date> = {
ArrowLeft: () => addDays(f, -1),
ArrowRight: () => addDays(f, 1),
ArrowUp: () => addDays(f, -7),
ArrowDown: () => addDays(f, 7),
Home: () => addDays(f, -col),
End: () => addDays(f, 6 - col),
PageUp: () => addMonths(f, e.shiftKey ? -12 : -1),
PageDown: () => addMonths(f, e.shiftKey ? 12 : 1),
};
if (!to[e.key]) return;
e.preventDefault();
moveTo(to[e.key]());
};
// ── The range band, settled or previewed ──
const [hover, setHover] = useState<Date | null>(null);
const [inGrid, setInGrid] = useState(false);
let lo: Date | null = null;
let hi: Date | null = null;
let preview = false;
if (span?.from && span.to) {
lo = span.from;
hi = span.to;
} else if (span?.from) {
const end = hover ?? (inGrid ? tabbable : null);
if (end) {
preview = true;
[lo, hi] = cmp(end, span.from) < 0 ? [end, span.from] : [span.from, end];
}
}
const fmt = useMemo(
() => ({
caption: new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
spoken: new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", year: "numeric" }),
num: new Intl.DateTimeFormat(locale, { day: "numeric" }),
weekday: new Intl.DateTimeFormat(locale, { weekday: "short" }),
}),
[locale]
);
const lead = (view.getDay() - weekStartsOn + 7) % 7;
const start = addDays(view, -lead);
const weeks = Array.from({ length: 6 }, (_, w) => Array.from({ length: 7 }, (_, c) => addDays(start, w * 7 + c)));
const weekdays = Array.from({ length: 7 }, (_, c) => fmt.weekday.format(addDays(SUNDAY, (weekStartsOn + c) % 7)));
const prevOff = !!min && cmp(addDays(view, -1), min) < 0;
const nextOff = !!max && cmp(addMonths(view, 1), max) > 0;
const slide: Variants = {
enter: (d: number) => (reduce ? { opacity: 0 } : { x: d * 28, opacity: 0 }),
center: {
x: 0,
opacity: 1,
transition: reduce ? FLAT : { x: { duration: 0.26, ease: EASE_OUT_EXPO }, opacity: { duration: 0.18, ease: "easeOut" } },
},
exit: (d: number) => (reduce ? { opacity: 0, transition: CLOSE } : { x: d * -28, opacity: 0, transition: CLOSE }),
};
return (
<LazyMotion features={domAnimation} strict>
<div role="group" aria-label={ariaLabel} className={`inline-flex w-max flex-col ${className}`}>
<div className="flex h-9 items-center justify-between gap-2 pl-2.5">
<div id={capId} aria-live="polite" className="text-[13px] font-medium text-gray-1000">
{fmt.caption.format(view)}
</div>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon-sm" aria-label="Previous month" disabled={prevOff} onClick={() => page(-1)}>
<ChevronDownIcon className="h-3.5 w-3.5 rotate-90" />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Next month" disabled={nextOff} onClick={() => page(1)}>
<ChevronDownIcon className="h-3.5 w-3.5 -rotate-90" />
</Button>
</div>
</div>
{/* Every day's button carries its full spoken date, so these are for the eye only. */}
<div aria-hidden className="grid grid-cols-7">
{weekdays.map((w, c) => (
<span key={c} className="flex h-8 w-9 items-center justify-center text-[11px] text-faint">
{w}
</span>
))}
</div>
{/* Padded out and pulled back, so the clip that hides the slide does not also clip a focus ring. */}
<div ref={wrap} className="relative -mx-1.5 -my-1 overflow-hidden px-1.5 py-1">
<AnimatePresence mode="popLayout" initial={false} custom={dir}>
<m.table
key={monthKey(view)}
data-month={monthKey(view)}
custom={dir}
variants={slide}
initial="enter"
animate="center"
exit="exit"
role="grid"
aria-labelledby={capId}
onKeyDown={onKey}
onMouseLeave={() => setHover(null)}
onFocus={() => setInGrid(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setInGrid(false);
}}
className="border-collapse"
>
<tbody>
{weeks.map((week, w) => (
<tr key={w}>
{week.map((d, c) => {
const outside = !inView(d);
const key = dayKey(d);
const isEnd = props.mode === "range" ? same(d, span?.from) || same(d, span?.to) : same(d, chosen);
const tile = !outside && isEnd;
const banded = !!lo && !!hi && !same(lo, hi) && cmp(d, lo) >= 0 && cmp(d, hi) <= 0 && (!outside || showOutsideDays);
const place = same(d, lo) ? "left-1/2 right-0" : same(d, hi) ? "left-0 right-1/2" : "inset-x-0";
const edge = `${c === 0 ? "rounded-l-[8px]" : ""} ${c === 6 ? "rounded-r-[8px]" : ""}`;
const dis = off(d);
const isToday = same(d, today);
return (
<td key={c} aria-selected={outside ? undefined : isEnd || banded} className="relative h-9 w-9 p-0">
{banded && (
<span aria-hidden className={`absolute inset-y-0 ${place} ${edge} ${preview ? "bg-gray-alpha-100" : "bg-gray-alpha-200"}`} />
)}
{outside ? (
showOutsideDays && (
<span aria-hidden className="relative flex h-9 w-9 items-center justify-center text-[13px] tabular-nums text-faint">
{fmt.num.format(d)}
</span>
)
) : (
<button
type="button"
data-day={key}
tabIndex={same(d, tabbable) ? 0 : -1}
aria-label={fmt.spoken.format(d)}
aria-current={isToday ? "date" : undefined}
aria-disabled={dis || undefined}
onClick={() => choose(d)}
onFocus={() => !same(d, focus) && setFocus(d)}
onMouseEnter={() => setHover(d)}
className={`relative flex h-9 w-9 items-center justify-center rounded-[8px] text-[13px] tabular-nums transition-[background-color,transform] duration-150 ease-geist motion-reduce:transition-none ${
tile
? "font-medium text-background-100 active:scale-[0.97]"
: dis
? "cursor-not-allowed text-faint line-through decoration-gray-alpha-600"
: "text-gray-1000 hover:bg-gray-alpha-200 active:scale-[0.97]"
}`}
>
{tile && (
<m.span
aria-hidden
className="absolute inset-0 rounded-[8px] bg-ink"
initial={landed === key ? (reduce ? { opacity: 0 } : { scale: 0.85 }) : false}
animate={{ scale: 1, opacity: 1 }}
transition={reduce ? FLAT : LIFT}
/>
)}
<span className="relative">{fmt.num.format(d)}</span>
{isToday && <span aria-hidden className="absolute bottom-1 left-1/2 h-[3px] w-[3px] -translate-x-1/2 rounded-full bg-current" />}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</m.table>
</AnimatePresence>
</div>
</div>
</LazyMotion>
);
}
"use client";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import type { CSSProperties, ReactNode } from "react";
/* Two springs of deliberately different character — the effect comes from letting
them disagree. The grow axis is light and underdamped, so it sails past its
final size; the cross axis is stiffer, heavier-damped and slower, so it arrives
late. Through the overshoot the panel is briefly taller and narrower than it
ends up, then it settles: squash and stretch out of physics rather than out of
a hand-authored timeline. */
const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
/* Closing is quick and flat. A bounce on the way out reads as indecision — the
panel looks like it is not sure it wants to go. Open and close are not mirror
images of each other, and should not be. */
const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
export type DropPlacement = "up" | "down";
export type DropMenuProps = {
open: boolean;
/** Which way it grows. `up` for a bottom dock, `down` for a header. */
placement?: DropPlacement;
/** `transform-origin` utility. Defaults to the edge the menu grows from. */
origin?: string;
id?: string;
className?: string;
style?: CSSProperties;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
children: ReactNode;
};
/**
* The open/close motion for a floating menu that grows out of a trigger sitting
* against one edge — a drop-up over a dock, a sheet over a tab bar, a popover
* under a header.
*
* It animates a wrapper and nothing else, so what you put inside is yours: give
* the child the glass, the border and the padding. That split is deliberate — a
* `backdrop-filter` on an element that is itself being transformed is weakened
* or dropped entirely by most engines, so the blur must live on a child that is
* holding still.
*
* The panel mounts only while open, which keeps a closed menu out of the tab
* order and off the paint. The wrapper is always present, so a trigger's
* `aria-controls` still resolves to something. Under reduced motion the elastic
* scaling drops out and a plain fade remains.
*
* You own `open` — this is not a popover with its own state. Escape handling,
* click-outside and hover intent belong to the trigger, which knows what it is.
*/
export function DropMenu({
open,
placement = "up",
origin,
id,
className = "",
style,
onMouseEnter,
onMouseLeave,
children,
}: DropMenuProps) {
const reduce = useReducedMotion();
// Start nudged back toward the trigger, so it reads as coming out of it.
const away = placement === "up" ? 4 : -4;
const anchor = origin ?? (placement === "up" ? "origin-bottom" : "origin-top");
return (
<div
id={id}
style={style}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
// Empty and inert when closed, so it can never sit over the page as a trap.
className={`${className} ${open ? "" : "pointer-events-none"}`}
>
<LazyMotion features={domAnimation} strict>
<AnimatePresence>
{open && (
<m.div
className={anchor}
initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: away }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scaleY: 1, scaleX: 1, y: 0 }}
exit={
reduce
? { opacity: 0, transition: CLOSE }
: { opacity: 0, scaleY: 0.9, scaleX: 0.97, y: away, transition: CLOSE }
}
transition={
reduce
? { duration: 0.15 }
: { scaleY: GROW, y: GROW, scaleX: CROSS, opacity: { duration: 0.14, ease: "easeOut" } }
}
>
{children}
</m.div>
)}
</AnimatePresence>
</LazyMotion>
</div>
);
}