Origin-aware open / close transition
Namespaced classes and motion tokens you can retune. The reduced-motion guard ships with the snippet.
/* Transitions.dev — Menu dropdown */
/*
Usage:
<div class="t-dropdown" data-origin="top-center">
<!-- your menu contents -->
</div>
State:
- Add `.is-open` to show.
- On close, swap `.is-open` for `.is-closing`, then remove
`.is-closing` after --dropdown-close-dur.
data-origin values: top-left | top-center | top-right |
bottom-left | bottom-center | bottom-right.
*/
: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; }
}
Self-contained component; the styles inject themselves on first import.
// Transitions.dev — Menu dropdown (React, self-contained)
// Drop into any React project — no extra CSS file needed.
import { useEffect, useState } from "react";
// ── Styles ──────────────────────────────────────────────
// Auto-injected on first import. Idempotent (guarded by
// the element id) and SSR-safe (no-ops without document).
const __TRANSITION_STYLES = `
: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; }
}
`;
if (typeof document !== "undefined" && !document.getElementById("transitions-p2")) {
const __style = document.createElement("style");
__style.id = "transitions-p2";
__style.textContent = __TRANSITION_STYLES;
document.head.appendChild(__style);
}
// Pair with the CSS from the CSS tab (.t-dropdown + .is-open / .is-closing).
export function MenuDropdown() {
// "closed" | "open" | "closing"
const [state, setState] = useState("closed");
// Hold `.is-closing` for the exit duration, then fully unmount state.
useEffect(() => {
if (state !== "closing") return;
const ms = readMs("--dropdown-close-dur", 200);
const id = window.setTimeout(() => setState("closed"), ms);
return () => window.clearTimeout(id);
}, [state]);
const toggle = () =>
setState((s) => (s === "open" ? "closing" : "open"));
return (
<>
<button type="button" onClick={toggle}>Toggle menu</button>
<div
role="menu"
data-origin="top-center"
className={
"t-dropdown" +
(state === "open" ? " is-open" : "") +
(state === "closing" ? " is-closing" : "")
}
>
<button type="button" role="menuitem">Item 1</button>
<button type="button" role="menuitem">Item 2</button>
</div>
</>
);
}
function readMs(name, fallback) {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
const n = parseFloat(raw);
return Number.isFinite(n) ? n : fallback;
}