← Overlays

Dropdown Stagger

A menu that unfolds from its button while the items cascade in one after another, then folds away quickly when you’re done. It’s a proper menu button too: the arrow keys, Home, End, type-ahead and Esc all work.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-dropdown-stagger to a wrapper holding a <button> and then a list.
  3. Put a link or button in each list item. An empty <li role="separator"> draws a divider.

Options

Add any of these attributes to the same element.

data-alignright lines the menu up with the button’s right edge.
data-staggerGap between items. Default 40 ms.
data-durationTime for each item to arrive. Default 420 ms.

The script sets aria-expanded, aria-haspopup and the menu roles for you. Enter, Space or Down opens it on the first item, Up on the last; Esc closes it and returns to the button.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Dropdown Stagger: a menu that unfolds from its button while the items
    // cascade in one after another, then folds away quickly when closed.
    // It's a proper menu button: Enter, Space or the arrow keys open it, the
    // arrow keys, Home and End move between items, typing a letter jumps to
    // an item, and Esc or Tab closes it.
    // Usage:
    //   <div data-dropdown-stagger>
    //     <button>Options</button>
    //     <ul>
    //       <li><a href="/edit">Edit</a></li>
    //       <li><button>Duplicate</button></li>
    //       <li><button>Delete</button></li>
    //     </ul>
    //   </div>
    // The first child is the button and the second the menu; each link or
    // button in the menu becomes an item (an <hr> or <li role="separator">
    // draws a divider). Optional: data-align="right" (line the menu up with the
    // button's right edge), data-stagger (ms between items, default 40),
    // data-duration (ms per item, default 420), data-autoplay (ms: open, point
    // at items and close on a timer while on screen, for previews).
    var DEFAULTS = { stagger: 40, duration: 420 };
    var uid = 0;

    // :where() keeps these easy to restyle.
    var css =
      ':where([data-dropdown-stagger]){position:relative;display:inline-block}' +
      ':where([data-dropdown-stagger])>:nth-child(2){position:absolute;top:calc(100% + 8px);left:0;z-index:20;min-width:200px;margin:0;padding:6px;list-style:none;' +
      'background:#fff;border-radius:14px;box-shadow:0 0 0 1px rgba(15,15,30,.06),0 18px 40px rgba(15,15,30,.16);text-align:left;transform-origin:top left}' +
      ':where([data-dropdown-stagger][data-align="right"])>:nth-child(2){left:auto;right:0;transform-origin:top right}' +
      ':where([data-dropdown-stagger])>[hidden]{display:none}' +
      ':where(.dds-item){display:flex;align-items:center;gap:10px;width:100%;padding:9px 12px;border:0;border-radius:9px;background:none;font:inherit;font-size:15px;' +
      'color:inherit;text-align:left;text-decoration:none;cursor:pointer;outline:none}' +
      ':where(.dds-item.dds-on){background:var(--dds-hover,#f3ecfd)}' +
      ':where(.dds-item:focus-visible){box-shadow:inset 0 0 0 2px var(--accent,#6a0dad)}' +
      ':where(.dds-sep){height:1px;margin:6px 8px;border:0;background:#ececf1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(root) {
      var d = root.dataset;
      var btn = root.children[0], menu = root.children[1];
      if (!btn || !menu) return;
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var inThumb = !!root.closest('[aria-hidden="true"]');
      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var duration = +d.duration || DEFAULTS.duration;
      var items = Array.from(menu.querySelectorAll('a,button,[data-item]'));
      var rows = Array.from(menu.children);
      var open = false, active = -1, typed = '', typedAt = 0, stopped = false;

      items.forEach(function (it) { it.classList.add('dds-item'); });
      menu.querySelectorAll('hr,[role="separator"]').forEach(function (s) { s.classList.add('dds-sep'); });

      if (!inThumb) {
        var id = menu.id || (menu.id = 'dds-menu-' + uid++);
        if (!btn.id) btn.id = id + '-button';
        btn.setAttribute('aria-haspopup', 'menu');
        btn.setAttribute('aria-expanded', 'false');
        btn.setAttribute('aria-controls', id);
        menu.setAttribute('role', 'menu');
        menu.setAttribute('aria-labelledby', btn.id);
        rows.forEach(function (r) { if (r.tagName === 'LI' && !r.classList.contains('dds-sep')) r.setAttribute('role', 'none'); });
        menu.querySelectorAll('hr').forEach(function (s) { s.setAttribute('role', 'separator'); });
        items.forEach(function (it) { it.setAttribute('role', 'menuitem'); it.tabIndex = -1; });
      }
      menu.hidden = true;

      // An item's text, without any icon or emoji in front.
      function name(it) { return it.textContent.trim().replace(/^[^\p{L}\p{N}]+/u, '').toLowerCase(); }

      function highlight(i, focus) {
        if (active >= 0 && items[active]) items[active].classList.remove('dds-on');
        active = i;
        if (i < 0) return;
        items[i].classList.add('dds-on');
        if (focus && !inThumb) items[i].focus();
      }

      function show(focusIndex) {
        if (open) { if (focusIndex !== undefined) highlight(focusIndex, true); return; }
        open = true;
        menu.hidden = false;
        if (!inThumb) btn.setAttribute('aria-expanded', 'true');
        anime.utils.remove([menu].concat(rows));
        if (reduced) anime.utils.set([menu].concat(rows), { opacity: 1, scale: 1, y: 0, x: 0 });
        else {
          anime.animate(menu, { opacity: [0, 1], scale: [0.92, 1], y: [-6, 0], duration: 320, ease: 'out(4)' });
          anime.animate(rows, {
            opacity: [0, 1], y: [-10, 0], x: [d.align === 'right' ? 8 : -8, 0],
            duration: duration, delay: anime.stagger(stagger, { start: 40 }), ease: 'outBack(1.6)'
          });
        }
        if (focusIndex !== undefined) highlight(focusIndex, true);
      }

      function hide(returnFocus) {
        if (!open) return;
        open = false;
        highlight(-1);
        if (!inThumb) btn.setAttribute('aria-expanded', 'false');
        if (returnFocus && !inThumb) btn.focus();
        anime.utils.remove([menu].concat(rows));
        if (reduced) { menu.hidden = true; return; }
        // Out is quicker than in, and the items leave bottom first.
        anime.animate(rows, { opacity: 0, y: -4, duration: 140, delay: anime.stagger(18, { from: 'last' }), ease: 'in(2)' });
        anime.animate(menu, {
          opacity: 0, scale: 0.96, y: -4, duration: 180, delay: Math.min(rows.length * 18, 90), ease: 'in(2)',
          onComplete: function () { if (!open) menu.hidden = true; }
        });
      }

      function move(k) { show(); highlight((Math.max(active, k > 0 ? -1 : 0) + k + items.length) % items.length, true); }

      if (!inThumb) {
        btn.addEventListener('click', function () { stopped = true; if (open) hide(false); else show(); });
        btn.addEventListener('keydown', function (e) {
          if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); stopped = true; show(0); }
          else if (e.key === 'ArrowUp') { e.preventDefault(); stopped = true; show(items.length - 1); }
          else if (e.key === 'Escape' && open) { e.preventDefault(); hide(true); }
        });
        menu.addEventListener('keydown', function (e) {
          var k = e.key;
          if (k === 'ArrowDown') move(1);
          else if (k === 'ArrowUp') move(-1);
          else if (k === 'Home') highlight(0, true);
          else if (k === 'End') highlight(items.length - 1, true);
          else if (k === 'Escape') hide(true);
          else if (k === 'Tab') { hide(false); return; }
          else if (k.length === 1 && /\S/.test(k)) {
            // Type-ahead: jump to the next item starting with what was typed.
            typed = (e.timeStamp - typedAt > 600 ? '' : typed) + k.toLowerCase();
            typedAt = e.timeStamp;
            var start = typed.length > 1 ? Math.max(active, 0) : active + 1;
            for (var n = 0; n < items.length; n++) {
              var i = (start + n) % items.length;
              if (name(items[i]).indexOf(typed) === 0) { highlight(i, true); break; }
            }
          } else return;
          e.preventDefault();
        });
        // Choosing an item closes the menu (links still go where they point).
        menu.addEventListener('click', function (e) { if (e.target.closest('.dds-item')) hide(true); });
        document.addEventListener('pointerdown', function (e) { if (open && !root.contains(e.target)) hide(false); });
        root.addEventListener('focusout', function (e) { if (open && e.relatedTarget && !root.contains(e.relatedTarget)) hide(false); });
      }
      items.forEach(function (it, i) {
        it.addEventListener('pointermove', function () { if (open && active !== i) highlight(i, !inThumb); });
      });
      menu.addEventListener('pointerleave', function () { if (inThumb) highlight(-1); });

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced) {
        var every = +d.autoplay || 900, beat = 0, timer = null;
        new IntersectionObserver(function (entries) {
          clearInterval(timer);
          if (!entries[0].isIntersecting || stopped) { if (stopped) return; hide(false); beat = 0; return; }
          // Open, point at a few items, close, rest.
          timer = setInterval(function () {
            if (stopped || root.closest('[hidden]')) return;
            var step = beat++ % 6;
            if (step === 0) show();
            else if (step < 4) highlight(Math.min(items.length - 1, step - 1 + (step > 2 ? 1 : 0)));
            else if (step === 4) hide(false);
          }, every);
        }).observe(root);
      }
    }

    function init() {
      document.querySelectorAll('[data-dropdown-stagger]').forEach(function (el) {
        if (el._dds) return;
        el._dds = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-dropdown-stagger>
  <button>Options</button>
  <ul>
    <li><a href="/edit">Edit</a></li>
    <li><button>Duplicate</button></li>
    <li role="separator"></li>
    <li><button>Delete</button></li>
  </ul>
</div>