← All components

HoverCard

A preview of where a link goes, once the pointer has rested on it.

Drawn and animated by @mihirmodi, in the open.

The idea

It waits for intent: 400ms is long enough that sweeping across a paragraph of links opens nothing, and short enough that resting on one feels answered. Leaving, it holds for 150ms so the pointer can cross into the card. Keyboard focus opens it at once, so tabbing through links gets the same preview. It is never the only way to anything — the link still goes where it says, and touch, which cannot hover, just follows it.

Composes DropMenu.

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

HoverCard — usage7 lines
import { HoverCard } from "mihirmodi-ui";

// Opens after the pointer rests for 400ms, or at once on keyboard
// focus. The link still goes where it says.
<HoverCard content={<ProfilePreview user={user} />}>
  <a href={user.url}>@{user.handle}</a>
</HoverCard>

Props

PropNotes
childrenReactElementThe trigger, usually a link.
contentReactNodeThe preview.
openDelay / closeDelaynumberIntent before opening, and grace before closing, in ms.
side / align"top" | "bottom" / "start" | "center" | "end"Where it opens. Flips and clamps to stay on screen.

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/overlays/HoverCard.tsx105 lines
"use client";

import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
import { Floating, withTrigger, type Align } from "./layer.js";

export type HoverCardProps = {
  /** The trigger — usually a link. It keeps doing what it does; the card is a preview. */
  children: ReactElement;
  /** The preview. */
  content: ReactNode;
  side?: "top" | "bottom";
  align?: Align;
  /** How long the pointer must rest before it opens, in ms. */
  openDelay?: number;
  /** How long it waits after the pointer leaves, so the pointer can travel into it. */
  closeDelay?: number;
  /** On the card — a width, mostly. Default `w-80`. */
  className?: string;
};

/**
 * A preview of what a link leads to — a person, a repository, a page — shown
 * when the pointer rests on it, on the menu glass.
 *
 * It waits for intent. Four hundred milliseconds is long enough that sweeping
 * the pointer across a paragraph of links opens nothing, and short enough that
 * resting on one feels answered. Leaving, it waits a beat before closing, so
 * the pointer can cross the gap into the card to read it or press something
 * in it.
 *
 * Keyboard focus opens it too, at once, so a keyboard reader tabbing through links
 * gets the same preview. It is supplementary, never the only way to a thing:
 * the link still goes where it says, and touch, which cannot hover, just
 * follows the link.
 */
export function HoverCard({ children, content, side = "bottom", align = "center", openDelay = 400, closeDelay = 150, className = "w-80" }: HoverCardProps) {
  const [open, setOpen] = useState(false);
  const trigger = useRef<HTMLElement | null>(null);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  const later = (v: boolean, ms: number) => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setOpen(v), ms);
  };
  const now = (v: boolean) => {
    clearTimeout(timer.current);
    setOpen(v);
  };

  useEffect(() => () => clearTimeout(timer.current), []);

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") now(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  const enter = (e: { pointerType: string }) => {
    if (e.pointerType !== "touch") later(true, open ? 0 : openDelay);
  };
  const leave = (e: { pointerType: string }) => {
    if (e.pointerType !== "touch") later(false, closeDelay);
  };

  return (
    <>
      {withTrigger(
        children,
        {
          onPointerEnter: enter,
          onPointerLeave: leave,
          // Keyboard focus only: a click on the link focuses it too, and that is not a request to preview.
          onFocus: (e: { target: Element }) => {
            if (e.target.matches(":focus-visible")) now(true);
          },
          onBlur: () => later(false, closeDelay),
        },
        (el) => {
          trigger.current = el;
        }
      )}
      <Floating
        open={open}
        anchor={() => trigger.current?.getBoundingClientRect()}
        side={side}
        align={align}
        offset={8}
        inkFrom={() => trigger.current}
        inside={() => [trigger.current]}
        onOutside={() => now(false)}
      >
        <div
          onPointerEnter={enter}
          onPointerLeave={leave}
          className={`glass-menu dock-shadow rounded-[14px] border-[0.5px] border-gray-alpha-400 p-4 text-[13px] text-gray-1000 ${className}`}
        >
          {content}
        </div>
      </Floating>
    </>
  );
}
packages/ui/src/components/overlays/layer.tsx543 lines
"use client";

import {
  Fragment,
  cloneElement,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  version,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactElement,
  type ReactNode,
  type RefObject,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, LazyMotion, domAnimation, m } from "motion/react";
import { DropMenu } from "../DropMenu.js";
import { CLOSE } from "../../lib/motion.js";

/*
 * The one layer every overlay in this directory stands on: a portal, a scroll
 * lock, a focus trap that gives focus back, Escape, a press outside, and a
 * position anchored to a trigger that flips when there is no room. Nothing here
 * is exported from the package — the components are the API; this is how they
 * agree with each other when one opens inside another.
 */

/** Layout effects warn on the server in React 18, and there is nothing to measure there anyway. */
export const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;

/* ── Portal ───────────────────────────────────────────────────────────────── */

export type PortalProps = {
  children: ReactNode;
  /** Re-read the ink each time this turns true. */
  open: boolean;
  /**
   * Where the layer was opened from. A host can recolour `--color-ink` for one
   * region only (the docs site does it per stage); a layer moved to the body
   * would lose that, so it copies the opener's ink onto its own root.
   */
  inkFrom?: () => Element | null | undefined;
};

/**
 * Renders into a root of its own at the end of `<body>`, so a layer escapes any
 * `overflow: hidden`, transform or stacking context its trigger sits in. Nothing
 * is rendered until the client has mounted, so server and client markup agree.
 */
export function Portal({ children, open, inkFrom }: PortalProps) {
  const [host, setHost] = useState<HTMLDivElement | null>(null);
  const from = useRef(inkFrom);
  from.current = inkFrom;

  useEffect(() => {
    const el = document.createElement("div");
    el.setAttribute("data-overlay-root", "");
    document.body.appendChild(el);
    setHost(el);
    return () => {
      el.remove();
      setHost(null);
    };
  }, []);

  useIsoLayoutEffect(() => {
    if (!host || !open) return;
    const source = from.current?.();
    const ink = source ? getComputedStyle(source).getPropertyValue("--color-ink").trim() : "";
    const page = getComputedStyle(document.documentElement).getPropertyValue("--color-ink").trim();
    if (ink && ink !== page) host.style.setProperty("--color-ink", ink);
    else host.style.removeProperty("--color-ink");
  }, [host, open]);

  return host ? createPortal(children, host) : null;
}

/* ── Scroll lock ──────────────────────────────────────────────────────────── */

let locks = 0;
let unlock: (() => void) | null = null;

/**
 * Stops the page scrolling under a modal. Counted, so a dialog opened from a
 * drawer does not release the lock when it closes. The scrollbar's gutter is
 * kept rather than padded back in, so neither the page nor anything fixed to the
 * viewport shifts sideways when the bar disappears.
 */
export function useScrollLock(on: boolean) {
  useEffect(() => {
    if (!on) return;
    if (locks++ === 0) {
      const html = document.documentElement;
      const before = { overflow: html.style.overflow, gutter: html.style.scrollbarGutter };
      const hasBar = window.innerWidth - html.clientWidth > 0;
      html.style.overflow = "hidden";
      if (hasBar) html.style.scrollbarGutter = "stable";
      unlock = () => {
        html.style.overflow = before.overflow;
        html.style.scrollbarGutter = before.gutter;
      };
    }
    return () => {
      if (--locks === 0) {
        unlock?.();
        unlock = null;
      }
    };
  }, [on]);
}

/* ── Focus ────────────────────────────────────────────────────────────────── */

const TABBABLE =
  'a[href],area[href],button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),iframe,audio[controls],video[controls],[contenteditable]:not([contenteditable="false"]),[tabindex]';

/** What Tab would visit inside `root`, in order. */
export function tabbables(root: Element): HTMLElement[] {
  return Array.from(root.querySelectorAll<HTMLElement>(TABBABLE)).filter(
    (el) => el.tabIndex >= 0 && !el.closest("[inert]") && el.getClientRects().length > 0
  );
}

/** Keep Tab inside `root`: off the last element wraps to the first, and back. */
export function trapTab(e: ReactKeyboardEvent, root: HTMLElement | null) {
  if (e.key !== "Tab" || !root || !root.contains(e.target as Node)) return;
  const list = tabbables(root);
  if (list.length === 0) {
    e.preventDefault();
    return;
  }
  // From the panel itself (not in the list), Tab goes to the first control and
  // Shift+Tab to the last, rather than out to the page behind.
  const i = list.indexOf(document.activeElement as HTMLElement);
  if (e.shiftKey && i <= 0) {
    e.preventDefault();
    list[list.length - 1].focus();
  } else if (!e.shiftKey && (i === -1 || i === list.length - 1)) {
    e.preventDefault();
    list[0].focus();
  }
}

/** Focus something without the page jumping to it. */
export function focusQuietly(el: Element | null | undefined) {
  if (el && "focus" in el && (el as HTMLElement).isConnected) (el as HTMLElement).focus({ preventScroll: true });
}

/** The tabbable that comes after `el` in the page, skipping every overlay root. */
export function nextInPage(el: Element | null, dir: 1 | -1 = 1): HTMLElement | null {
  if (!el) return null;
  const list = tabbables(document.body).filter((n) => !n.closest("[data-overlay-root]"));
  const i = list.indexOf(el as HTMLElement);
  return i < 0 ? null : (list[i + dir] ?? null);
}

/* ── Pressing outside ─────────────────────────────────────────────────────── */

type Layer = { nodes: () => (Element | null | undefined)[] };
const stack: Layer[] = [];

/**
 * Registers an open layer, and calls `onOutside` for a press that lands outside
 * it. A press inside any layer opened *after* this one — a submenu, a popover
 * opened from inside a popover — counts as inside, since those belong to it even
 * though they live elsewhere in the DOM.
 *
 * It listens in the capture phase, so it hears the press even when something
 * under the pointer stops it from bubbling.
 */
export function useOutside(open: boolean, nodes: () => (Element | null | undefined)[], onOutside?: () => void) {
  const get = useRef(nodes);
  get.current = nodes;
  const cb = useRef(onOutside);
  cb.current = onOutside;

  useEffect(() => {
    if (!open) return;
    const me: Layer = { nodes: () => get.current() };
    stack.push(me);
    const onDown = (e: PointerEvent) => {
      if (!cb.current) return;
      const t = e.target as Node;
      const mine = stack.slice(stack.indexOf(me));
      if (!mine.some((l) => l.nodes().some((n) => n?.contains(t)))) cb.current();
    };
    document.addEventListener("pointerdown", onDown, true);
    return () => {
      document.removeEventListener("pointerdown", onDown, true);
      stack.splice(stack.indexOf(me), 1);
    };
  }, [open]);
}

/* ── Anchored position ────────────────────────────────────────────────────── */

export type Side = "top" | "bottom" | "left" | "right";
export type Align = "start" | "center" | "end";
export type Placed = { top: number; left: number; side: Side; align: Align };
export type PlaceOptions = { side: Side; align: Align; offset?: number; alignOffset?: number; margin?: number };

const opposite: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };

/**
 * Where a box of `w × h` goes beside `a`. It takes the side asked for unless the
 * box does not fit there and the opposite side has more room; an edge-aligned
 * box swaps to the other edge before it is pushed; and then it is clamped inside
 * the viewport, so a menu is never cut off — it just stops being perfectly
 * aligned, which is the smaller loss.
 */
export function place(a: DOMRect, w: number, h: number, o: PlaceOptions): Placed {
  const vw = document.documentElement.clientWidth;
  const vh = window.innerHeight;
  const gap = o.offset ?? 6;
  const shift = o.alignOffset ?? 0;
  const margin = o.margin ?? 8;
  const room: Record<Side, number> = {
    bottom: vh - a.bottom - gap - margin,
    top: a.top - gap - margin,
    right: vw - a.right - gap - margin,
    left: a.left - gap - margin,
  };
  const need = o.side === "top" || o.side === "bottom" ? h : w;
  const side = room[o.side] < need && room[opposite[o.side]] > room[o.side] ? opposite[o.side] : o.side;
  let align = o.align;
  let top: number;
  let left: number;

  if (side === "top" || side === "bottom") {
    top = side === "bottom" ? a.bottom + gap : a.top - gap - h;
    const at = (al: Align) => (al === "start" ? a.left + shift : al === "end" ? a.right - w - shift : a.left + a.width / 2 - w / 2);
    left = at(align);
    if (align === "start" && left + w > vw - margin) align = "end";
    else if (align === "end" && left < margin) align = "start";
    left = at(align);
  } else {
    left = side === "right" ? a.right + gap : a.left - gap - w;
    const at = (al: Align) => (al === "start" ? a.top + shift : al === "end" ? a.bottom - h - shift : a.top + a.height / 2 - h / 2);
    top = at(align);
    if (align === "start" && top + h > vh - margin) align = "end";
    else if (align === "end" && top < margin) align = "start";
    top = at(align);
  }
  left = Math.max(margin, Math.min(left, vw - w - margin));
  top = Math.max(margin, Math.min(top, vh - h - margin));
  return { top: Math.round(top), left: Math.round(left), side, align };
}

/**
 * Keeps `box` placed beside the anchor while open — measured before paint, and
 * again on any scroll (in any container), resize, or change in the box's own
 * size. The box is measured by its layout size, not its bounding rect, so the
 * opening scale does not shrink what is measured.
 */
export function useAnchored(
  open: boolean,
  anchor: () => DOMRect | null | undefined,
  box: RefObject<HTMLElement | null>,
  options: PlaceOptions
): Placed | null {
  const [pos, setPos] = useState<Placed | null>(null);
  const a = useRef(anchor);
  a.current = anchor;
  const o = useRef(options);
  o.current = options;

  useIsoLayoutEffect(() => {
    if (!open) return;
    let raf = 0;
    const update = () => {
      raf = 0;
      const r = a.current();
      const el = box.current;
      if (!r || !el) return;
      const next = place(r, el.offsetWidth, el.offsetHeight, o.current);
      setPos((p) =>
        p && p.top === next.top && p.left === next.left && p.side === next.side && p.align === next.align ? p : next
      );
    };
    const schedule = () => {
      if (!raf) raf = requestAnimationFrame(update);
    };
    update();
    window.addEventListener("scroll", schedule, { capture: true, passive: true });
    window.addEventListener("resize", schedule);
    const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
    if (box.current) ro?.observe(box.current);
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("scroll", schedule, { capture: true });
      window.removeEventListener("resize", schedule);
      ro?.disconnect();
    };
  }, [open]);

  return pos;
}

const ORIGIN: Record<Side, Record<Align, string>> = {
  bottom: { start: "origin-top-left", center: "origin-top", end: "origin-top-right" },
  top: { start: "origin-bottom-left", center: "origin-bottom", end: "origin-bottom-right" },
  right: { start: "origin-top-left", center: "origin-left", end: "origin-bottom-left" },
  left: { start: "origin-top-right", center: "origin-right", end: "origin-bottom-right" },
};

export type FloatingProps = {
  open: boolean;
  anchor: () => DOMRect | null | undefined;
  side?: Side;
  align?: Align;
  offset?: number;
  alignOffset?: number;
  /** Where the ink is copied from — usually the trigger. */
  inkFrom?: () => Element | null | undefined;
  /** Extra elements a press may land on without counting as outside — the trigger. */
  inside?: () => (Element | null | undefined)[];
  /** A press outside the panel, the trigger, and anything opened from it. */
  onOutside?: () => void;
  children: ReactNode;
};

/**
 * A panel anchored to a trigger, in the body, growing out of whichever edge it
 * ended up on with DropMenu's squash-and-stretch. The transform-origin follows
 * the flip, so a menu that had to open upward still grows out of its trigger
 * rather than toward it.
 */
export function Floating({
  open,
  anchor,
  side = "bottom",
  align = "start",
  offset,
  alignOffset,
  inkFrom,
  inside,
  onOutside,
  children,
}: FloatingProps) {
  const box = useRef<HTMLDivElement>(null);
  const pos = useAnchored(open, anchor, box, { side, align, offset, alignOffset });
  useOutside(open, () => [box.current, ...(inside?.() ?? [])], onOutside);
  const s = pos?.side ?? side;
  const al = pos?.align ?? align;
  // Each opening gets fresh contents. Reopened while it is still leaving, the
  // panel is caught mid-exit and reused, and without a new key whatever it
  // does on mount (take focus, start on the first row) would not happen again.
  const opens = useRef(0);
  const was = useRef(false);
  if (open && !was.current) opens.current++;
  was.current = open;

  return (
    <Portal open={open} inkFrom={inkFrom}>
      <div
        ref={box}
        // Not hidden before it is measured: the measure happens before paint, and
        // a hidden ancestor would make the panel refuse focus on mount.
        className="pointer-events-none fixed z-[62] w-max"
        style={{ top: pos?.top ?? 0, left: pos?.left ?? 0 }}
      >
        <DropMenu open={open} placement={s === "top" ? "up" : "down"} origin={ORIGIN[s][al]} className={open ? "pointer-events-auto" : ""}>
          <Fragment key={opens.current}>{children}</Fragment>
        </DropMenu>
      </div>
    </Portal>
  );
}

/* ── Modal layer ──────────────────────────────────────────────────────────── */

export type ModalLayerProps = {
  open: boolean;
  onDismiss: () => void;
  /** A press on the scrim dismisses. Off for a decision that must be made. */
  backdrop?: boolean;
  /** Escape dismisses. */
  escape?: boolean;
  /** What takes focus on open. Default: the first control that is not the corner close button. */
  initialFocus?: () => HTMLElement | null | undefined;
  /** Where the panel stands: centred, high (a palette), or on the bottom edge (a sheet). */
  placement?: "center" | "top" | "bottom";
  /** The panel — a motion element with its own enter and exit. */
  children: ReactNode;
};

/* The last thing pressed, for browsers that do not focus a button on click
   (Safari): without it a dialog opened by a click would have no opener to give
   focus back to, or to take its ink from. */
let lastPressed: Element | null = null;
let listening = false;
function listenForPresses() {
  if (listening || typeof document === "undefined") return;
  listening = true;
  document.addEventListener("pointerdown", (e) => (lastPressed = e.target as Element), true);
}

function currentOpener(): Element | null {
  const a = document.activeElement;
  if (a && a !== document.body) return a;
  return lastPressed?.isConnected ? (lastPressed.closest("button, a[href], [tabindex]") ?? lastPressed) : null;
}

const PLACE = {
  center: "items-center p-4",
  top: "items-start px-4 pt-[14vh] pb-4",
  bottom: "items-end p-2",
} as const;

/**
 * Everything a modal shares: the scrim, the scroll lock, focus moved in and given
 * back to whatever had it, Tab kept inside, Escape and a press on the scrim.
 *
 * While it is up, keys and the wheel stop at its edge. A page is free to listen
 * for arrows or scroll on the window (a carousel, a slide deck), and a modal is
 * a promise that nothing behind it reacts. Modified keys still pass, so a global
 * shortcut can close what it opened.
 */
export function ModalLayer({ open, onDismiss, backdrop = true, escape = true, initialFocus, placement = "center", children }: ModalLayerProps) {
  const [layer, setLayer] = useState<HTMLDivElement | null>(null);
  const opener = useRef<Element | null>(null);
  const was = useRef(false);
  const pressed = useRef(false);
  const first = useRef(initialFocus);
  first.current = initialFocus;

  // Who opened it is whoever had focus (or was pressed) when `open` turned true.
  if (open && !was.current && typeof document !== "undefined") opener.current = currentOpener();
  was.current = open;

  useEffect(listenForPresses, []);

  useScrollLock(open);

  useEffect(() => {
    if (!open || !layer) return;
    const target =
      first.current?.() ??
      tabbables(layer).find((el) => !el.hasAttribute("data-dialog-close")) ??
      layer.querySelector<HTMLElement>("[data-modal-panel]");
    focusQuietly(target);
  }, [open, layer]);

  useEffect(() => {
    if (!open) return;
    return () => {
      const was = document.activeElement as HTMLElement | null;
      focusQuietly(opener.current);
      // Opened from nowhere focusable (a hotkey on the page): let go of the
      // field in the leaving panel rather than leave typing aimed at it.
      if (was && document.activeElement === was && was.closest("[data-overlay-root]")) was.blur();
    };
  }, [open]);

  return (
    <Portal open={open} inkFrom={() => opener.current}>
      <LazyMotion features={domAnimation} strict>
        <AnimatePresence>
          {open && (
            <m.div
              key="scrim"
              aria-hidden
              className="overlay-scrim fixed inset-0 z-[58]"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0, transition: CLOSE }}
              transition={{ duration: 0.2, ease: "easeOut" }}
            />
          )}
          {open && (
            <div
              key="layer"
              ref={setLayer}
              className={`fixed inset-0 z-[58] flex justify-center ${PLACE[placement]}`}
              onPointerDown={(e) => {
                pressed.current = e.target === e.currentTarget;
              }}
              onClick={(e) => {
                if (backdrop && pressed.current && e.target === e.currentTarget) onDismiss();
                pressed.current = false;
              }}
              onKeyDown={(e) => {
                if (e.key === "Escape" && !e.defaultPrevented) {
                  e.preventDefault();
                  if (escape) onDismiss();
                } else trapTab(e, layer);
                if (!e.metaKey && !e.ctrlKey) e.stopPropagation();
              }}
              onWheel={(e) => e.stopPropagation()}
            >
              {children}
            </div>
          )}
        </AnimatePresence>
      </LazyMotion>
    </Portal>
  );
}

/* ── Triggers ─────────────────────────────────────────────────────────────── */

type AnyProps = Record<string, unknown>;
type Handler = (e: { defaultPrevented?: boolean }) => void;
const REACT_19 = parseInt(version, 10) >= 19;

function assignRef(ref: unknown, el: HTMLElement | null) {
  if (typeof ref === "function") ref(el);
  else if (ref && typeof ref === "object" && "current" in ref) (ref as { current: unknown }).current = el;
}

/** The trigger's own `id` if it has one, so ARIA can point at it; otherwise ours. */
export function triggerId(child: ReactElement, fallback: string): string {
  const id = (child.props as AnyProps).id;
  return typeof id === "string" && id ? id : fallback;
}

/**
 * Hands a trigger its ARIA, its handlers and a ref without wrapping it in
 * anything, so your own Button stays the element that is focused and pressed.
 * Handlers are chained: the child's own runs first, and calling
 * `preventDefault()` in it keeps ours from running.
 */
export function withTrigger(child: ReactElement, ours: AnyProps, ref: (el: HTMLElement | null) => void) {
  const own = child.props as AnyProps;
  const merged: AnyProps = { ...ours };
  for (const k of Object.keys(ours)) {
    const a = own[k];
    const b = ours[k];
    if (/^on[A-Z]/.test(k) && typeof a === "function" && typeof b === "function") {
      merged[k] = (e: { defaultPrevented?: boolean }) => {
        (a as Handler)(e);
        if (!e?.defaultPrevented) (b as Handler)(e);
      };
    }
  }
  const childRef = REACT_19 ? own.ref : (child as unknown as { ref?: unknown }).ref;
  merged.ref = (el: HTMLElement | null) => {
    ref(el);
    assignRef(childRef, el);
  };
  return cloneElement(child, merged);
}
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>
  );
}