Transitions.dev

Home / Transitions

Menu dropdown

Origin-aware open / close transition

Open the live demo Install with the Skill

CSS

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; }
}

React

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;
}