← All components

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.

Composes Calendar and DropMenu and Select.

Install

terminal1 line
npm i mihirmodi-ui motion

The 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

DatePicker — usage13 lines
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

PropNotes
mode"single" | "range"Passed through to the calendar.
value / onChangeDate | null or DateRange | null / (value) => voidControlled.
placeholderstringShown while nothing is picked.
formatIntl.DateTimeFormatOptionsHow the trigger writes the date. A range uses formatRange, so a shared month is written once.
align"start" | "end"Which edge of the trigger the calendar lines up with.
min / max / isDisabled / weekStartsOn / locale—Passed through to the calendar.
id / aria-labelstringPair `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.

packages/ui/src/components/forms/DatePicker.tsx176 lines
"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>
  );
}
packages/ui/src/components/forms/Calendar.tsx353 lines
"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>
  );
}
packages/ui/src/components/DropMenu.tsx105 lines
"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>
  );
}