Description
Menu Dropdown gives a floating menu a fast, springy entrance that visually connects it to the trigger that opened it. The menu scales from a data-origin anchor point so it appears to grow out of the button rather than materialize from thin air. Closing plays a tighter scale-in so the menu retreats quickly and cleanly. The element is removed from the DOM only after the closing animation completes.
Example
CSS
:root {
--dropdown-open-dur: 250ms;
--dropdown-close-dur: 150ms;
--dropdown-pre-scale: 0.97;
--dropdown-closing-scale: 0.99;
--dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
.t-dropdown {
transform-origin: top left;
transform: scale(var(--dropdown-pre-scale));
opacity: 0;
pointer-events: none;
transition:
transform var(--dropdown-open-dur) var(--dropdown-ease),
opacity var(--dropdown-open-dur) var(--dropdown-ease);
will-change: transform, opacity;
}
.t-dropdown[data-origin="top-right"] {
transform-origin: top right;
}
.t-dropdown[data-origin="top-center"] {
transform-origin: top center;
}
.t-dropdown[data-origin="bottom-left"] {
transform-origin: bottom left;
}
.t-dropdown[data-origin="bottom-center"] {
transform-origin: bottom center;
}
.t-dropdown[data-origin="bottom-right"] {
transform-origin: bottom right;
}
.t-dropdown.is-open {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.t-dropdown.is-closing {
transform: scale(var(--dropdown-closing-scale));
opacity: 0;
pointer-events: none;
transition:
transform var(--dropdown-close-dur) var(--dropdown-ease),
opacity var(--dropdown-close-dur) var(--dropdown-ease);
}
@media (prefers-reduced-motion: reduce) {
.t-dropdown {
transition: none !important;
}
}
React
import { useCallback, useEffect, useRef, useState } from "react";
import "./menu-dropdown.css"; // paste the CSS above
type Phase = "closed" | "open" | "closing";
function useOpenClose(closeVar: string, fallbackMs: number) {
const [phase, setPhase] = useState<Phase>("closed");
const timer = useRef<number | undefined>(undefined);
const open = useCallback(() => {
window.clearTimeout(timer.current);
setPhase("open");
}, []);
const close = useCallback(() => {
setPhase("closing");
const ms =
parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(closeVar),
) || fallbackMs;
timer.current = window.setTimeout(() => setPhase("closed"), ms);
}, [closeVar, fallbackMs]);
useEffect(() => () => window.clearTimeout(timer.current), []);
return { phase, open, close };
}
export function MenuDropdown() {
const { phase, open, close } = useOpenClose("--dropdown-close-dur", 150);
const stateClass =
phase === "open" ? " is-open" : phase === "closing" ? " is-closing" : "";
return (
<div style={{ position: "relative" }}>
<button
type="button"
onClick={() => (phase === "open" ? close() : open())}
aria-haspopup="menu"
aria-expanded={phase === "open"}
>
Options
</button>
{phase !== "closed" && (
<div
className={"t-dropdown" + stateClass}
data-origin="top-center"
role="menu"
style={{
position: "absolute",
top: "calc(100% + 8px)",
left: 0,
minWidth: 160,
}}
>
<button type="button" role="menuitem">
Edit
</button>
<button type="button" role="menuitem">
Duplicate
</button>
<button type="button" role="menuitem">
Archive
</button>
<button type="button" role="menuitem">
Delete
</button>
</div>
)}
</div>
);
}
Variables
| Variable | Default | Notes |
|---|---|---|
--dropdown-open-dur | 250ms | sourced from --p2-open-dur |
--dropdown-close-dur | 150ms | sourced from --p2-close-dur |
--dropdown-pre-scale | 0.97 | sourced from --p2-pre-scale |
--dropdown-closing-scale | 0.99 | sourced from --p2-closing-scale |
--dropdown-ease | cubic-bezier(0.22, 1, 0.36, 1) | sourced from --p2-ease |
Credit
Adapted from Menu Dropdown on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.