DropMenu
Two springs that disagree, so a panel squashes and stretches out of its trigger.
The idea
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, and then it settles. Nobody authored that squash — it falls out of two springs being given different characters.
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 { useState } from "react";
import { DropMenu } from "@mihirmodi/ui";
const [open, setOpen] = useState(false);
// The wrapper animates; the child carries the glass, the border and the
// padding. That split is not stylistic: a backdrop-filter on an element
// that is itself being transformed is weakened or dropped by most
// engines, so the blur has to live on a child that is holding still.
<DropMenu open={open} placement="up" className="absolute bottom-full left-0 pb-2">
<div className="glass-panel dock-shadow rounded-2xl border-[0.5px] border-gray-alpha-400 p-1">
{children}
</div>
</DropMenu>Props
| Prop | Type | Notes |
|---|---|---|
| openboolean | boolean | You own this. Escape, click-outside and hover intent belong to the trigger, which knows what it is. |
| placement"up" | "down" | "up" | "down"= "up" | Which edge it grows from. |
| originstring | string | `transform-origin` utility. Defaults to the edge it grows from. |
| classNamestring | string | On the always-present wrapper — put the positioning here, not on the panel. |
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 { 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>
);
}