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
- Paste the snippet just before
</head>. - Add
data-dropdown-staggerto a wrapper holding a<button>and then a list. - 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-align | right lines the menu up with the button’s right edge. |
data-stagger | Gap between items. Default 40 ms. |
data-duration | Time 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.
<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><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>