Back to Code Bytes
3 min read
Menu Dropdown

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

Click "Options" to open and close

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

VariableDefaultNotes
--dropdown-open-dur250mssourced from --p2-open-dur
--dropdown-close-dur150mssourced from --p2-close-dur
--dropdown-pre-scale0.97sourced from --p2-pre-scale
--dropdown-closing-scale0.99sourced from --p2-closing-scale
--dropdown-easecubic-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.