Tree Menu Expand
A nested menu, like a file tree or a docs sidebar. Open a branch and its arrow turns as the branch slides open, then its guide lines draw in: a line runs down from the parent, and a short tick reaches out to each child just as the line passes it, with the child fading in beside it.
How to use it
- Paste the snippet just before
</head>. - Add
data-tree-menu-expandto a nested list. Any<li>holding its own<ul>becomes a branch. - If a branch’s label is a link, the script adds a small arrow button before it; otherwise the label itself becomes the button.
Options
Add any of these attributes to the same element.
data-single | Opening a branch closes its open siblings, like an accordion. |
data-open | Start with every branch open. Or put data-open on one <li> to open just that branch. |
data-color | Colour of the guide lines. Default #d4c4e6. |
data-indent | How far each level steps in, in px. Default 18. |
data-duration | How long a branch takes to open. Default 420 ms. |
data-autoplay | It opens and closes the branches by itself while in view, pausing when someone taps. Handy for demos. |
Branch buttons get aria-expanded and aria-controls, and closed branches are hidden from screen readers. Each branch fires a toggle event (e.detail.open), and animacious.treeMenu(li) opens or closes one from code. Branches inside a closed branch keep their state for next time.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Tree Menu Expand: a nested menu, like a file tree or docs sidebar. Open
// a branch and its arrow turns, the branch slides open, and its guide
// lines draw in: a line runs down from the parent and a short tick reaches
// out to each child just as the line passes it, with the child fading in
// beside it. Closing reverses it.
// Usage: <ul data-tree-menu-expand>
// <li><a href="/">Home</a></li>
// <li><span>Docs</span>
// <ul><li><a href="/intro">Intro</a></li>…</ul>
// </li>
// </ul>
// Any <li> holding a nested <ul> becomes a branch. If its label is a link
// the script adds a small arrow button before it; otherwise the label
// itself becomes the button.
// Optional: data-open (open every branch to begin with), data-single (opening
// a branch closes its open siblings), data-color (colour of the guide
// lines, default #d4c4e6), data-indent (px each level steps in, default
// 18), data-duration (ms, default 420),
// data-autoplay (it opens and closes branches by itself while in view,
// pausing when someone taps; handy for demos).
// A branch can start open with data-open on its <li>. Listen for the
// "toggle" event on the <li>: e.detail.open is true or false.
// Script use: animacious.treeMenu(li) opens or closes a branch.
var DEFAULTS = { color: '#d4c4e6', duration: 420, indent: 18 };
var css =
'.tme,.tme ul{list-style:none;margin:0;padding:0}' +
'.tme li{position:relative}' +
'.tme-row{display:flex;align-items:center;gap:.4em;min-width:0}' +
'.tme-toggle{display:inline-flex;align-items:center;gap:.4em;padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.tme-caret{flex:none;display:inline-grid;place-items:center;width:1em;height:1em;color:inherit;opacity:.7}' +
'.tme-caret svg{width:.75em;height:.75em;display:block}' +
'.tme-leaf::before{content:"";flex:none;width:1em}' +
'.tme-sub .tme-leaf::before{width:.6em}' +
'.tme-sub{position:relative;overflow:hidden;padding-left:var(--tme-indent,18px)!important}' +
'.tme-rail{position:absolute;left:0;top:0;width:2px;border-radius:2px;background:var(--tme-line);transform-origin:50% 0;pointer-events:none}' +
'.tme-tick{position:absolute;height:2px;margin-top:-1px;border-radius:2px;background:var(--tme-line);transform-origin:0 50%;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var CARET = '<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 2.5 8 6l-3.5 3.5"/></svg>';
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function kids(ul) {
return Array.prototype.filter.call(ul.children, function (c) { return c.tagName === 'LI'; });
}
// Where each child's tick sits (the middle of its label row), and how
// long the rail must be to reach the last one.
function measure(b) {
var ys = b.items.map(function (li) { return li.offsetTop + li._tmeRow.offsetHeight / 2; });
b.ys = ys;
// The rail hangs under the middle of the parent's arrow; each tick runs
// from the rail to just short of its child's arrow or label.
var x = b.caret.offsetLeft + b.caret.offsetWidth / 2 - b.ul.offsetLeft;
b.rail.style.left = (x - 1) + 'px';
b.items.forEach(function (li, k) {
var t = li._tmeTick, to = li._tmeBranch ? li._tmeBranch.caret : li._tmeRow.firstElementChild;
var from = x - li.offsetLeft;
var end = to ? to.offsetLeft + (li._tmeBranch ? 0 : parseFloat(getComputedStyle(to).paddingLeft) || 0) - 4 : 0;
t.style.top = ys[k] + 'px';
t.style.left = from + 'px';
t.style.width = Math.max(4, end - from) + 'px';
});
b.rail.style.height = (ys.length ? ys[ys.length - 1] : 0) + 'px';
}
// Rails of open branches grow when a branch inside them opens.
function relayout(root) {
root._tme.branches.forEach(function (b) { if (b.open) measure(b); });
}
function toggle(li, open, quick) {
var b = li._tmeBranch;
if (!b) return;
if (open === undefined) open = !b.open;
if (open === b.open) return;
var root = b.root, s = root._tme;
if (open && s.single) {
kids(li.parentNode).forEach(function (sib) { if (sib !== li && sib._tmeBranch && sib._tmeBranch.open) toggle(sib, false); });
}
b.open = open;
li.classList.toggle('tme-open', open);
if (b.btn.tagName === 'BUTTON') b.btn.setAttribute('aria-expanded', open);
li.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { open: open } }));
var ul = b.ul, labels = b.items.map(function (it) { return it._tmeRow; });
var ticks = b.items.map(function (it) { return it._tmeTick; });
anime.utils.remove([ul, b.rail, b.caret].concat(ticks, labels));
var D = s.duration;
if (open) {
ul.hidden = false;
ul.style.height = 'auto';
measure(b);
var full = ul.offsetHeight;
if (quick || reduced()) {
anime.utils.set(b.caret, { rotate: 90 });
anime.utils.set(b.rail, { scaleY: 1 });
anime.utils.set(ticks, { scaleX: 1 });
anime.utils.set(labels, { opacity: 1, x: 0 });
relayout(root);
return;
}
var railH = b.ys.length ? b.ys[b.ys.length - 1] : 1;
var railT = D * 0.95;
// Each tick (and its label) starts as the rail reaches it.
var at = function (k) { return railT * Math.min(1, b.ys[k] / railH) * 0.85; };
anime.animate(b.caret, { rotate: 90, duration: D * 0.7, ease: 'out(3)' });
anime.animate(ul, {
height: [0, full], duration: D, ease: 'out(3)',
onRender: function () { relayout(root); },
onComplete: function () { ul.style.height = 'auto'; relayout(root); }
});
anime.animate(b.rail, { scaleY: [0, 1], duration: railT, ease: 'inOut(2)' });
anime.animate(ticks, { scaleX: [0, 1], duration: 220, delay: function (t, k) { return at(k); }, ease: 'out(2)' });
anime.animate(labels, { opacity: [0, 1], x: [-8, 0], duration: 320, delay: function (t, k) { return at(k) + 60; }, ease: 'out(3)' });
} else {
var hide = function () { if (!b.open) { ul.hidden = true; ul.style.height = ''; } relayout(root); };
if (quick || reduced()) {
anime.utils.set(b.caret, { rotate: 0 });
anime.utils.set(b.rail, { scaleY: 0 });
anime.utils.set(ticks, { scaleX: 0 });
anime.utils.set(labels, { opacity: 0 });
hide();
return;
}
anime.animate(b.caret, { rotate: 0, duration: D * 0.6, ease: 'out(3)' });
anime.animate(labels, { opacity: 0, x: -6, duration: 180, delay: anime.stagger(25, { from: 'last' }), ease: 'in(2)' });
anime.animate(ticks, { scaleX: 0, duration: 160, delay: anime.stagger(25, { from: 'last' }), ease: 'in(2)' });
anime.animate(b.rail, { scaleY: 0, duration: D * 0.6, delay: 60, ease: 'in(2)' });
anime.animate(ul, {
height: [ul.offsetHeight, 0], duration: D * 0.75, delay: 80, ease: 'inOut(3)',
onRender: function () { relayout(root); },
onComplete: hide
});
}
}
function setup(root) {
var d = root.dataset;
var a11y = !root.closest('[aria-hidden="true"]');
var color = d.color || DEFAULTS.color;
var s = root._tme = {
branches: [],
single: d.single !== undefined && d.single !== 'false',
duration: parseFloat(d.duration) || DEFAULTS.duration
};
root.classList.add('tme');
root.style.setProperty('--tme-line', color);
root.style.setProperty('--tme-indent', (parseFloat(d.indent) || DEFAULTS.indent) + 'px');
var allOpen = d.open !== undefined && d.open !== 'false';
var uid = 0;
// Wrap every item's own label (everything except its nested list) in a row.
var lis = Array.prototype.slice.call(root.querySelectorAll('li'));
lis.forEach(function (li) {
var sub = null;
Array.prototype.forEach.call(li.children, function (c) { if (!sub && (c.tagName === 'UL' || c.tagName === 'OL')) sub = c; });
var row = document.createElement('div');
row.className = 'tme-row';
while (li.firstChild && li.firstChild !== sub) row.appendChild(li.firstChild);
li.insertBefore(row, li.firstChild);
li._tmeRow = row;
li._tmeSub = sub;
if (!sub) row.classList.add('tme-leaf');
});
lis.forEach(function (li) {
var sub = li._tmeSub, row = li._tmeRow;
if (!sub) return;
var caret = document.createElement('span');
caret.className = 'tme-caret';
caret.setAttribute('aria-hidden', 'true');
caret.innerHTML = CARET;
var label = row.firstElementChild;
var btn;
if (label && label.tagName === 'A' && row.children.length === 1) {
// A link label keeps navigating; the arrow beside it opens the branch.
btn = document.createElement(a11y ? 'button' : 'span');
btn.className = 'tme-toggle';
btn.appendChild(caret);
if (a11y) btn.setAttribute('aria-label', 'Show ' + label.textContent.trim());
row.insertBefore(btn, label);
} else {
btn = document.createElement(a11y ? 'button' : 'span');
btn.className = 'tme-toggle';
btn.appendChild(caret);
while (row.firstChild) btn.appendChild(row.firstChild);
row.appendChild(btn);
}
if (a11y) {
btn.type = 'button';
sub.id = sub.id || 'tree-menu-expand-' + Math.random().toString(36).slice(2, 7) + (++uid);
btn.setAttribute('aria-controls', sub.id);
btn.setAttribute('aria-expanded', 'false');
}
sub.classList.add('tme-sub');
var rail = document.createElement('span');
rail.className = 'tme-rail';
rail.setAttribute('aria-hidden', 'true');
sub.insertBefore(rail, sub.firstChild);
var items = kids(sub);
items.forEach(function (it) {
var tick = document.createElement('span');
tick.className = 'tme-tick';
tick.setAttribute('aria-hidden', 'true');
it.insertBefore(tick, it.firstChild);
it._tmeTick = tick;
});
var b = li._tmeBranch = { root: root, li: li, ul: sub, btn: btn, caret: caret, rail: rail, items: items, ys: [], open: false };
s.branches.push(b);
sub.hidden = true;
btn.addEventListener('click', function () { toggle(li); });
});
s.branches.forEach(function (b) {
anime.utils.set(b.rail, { scaleY: 0 });
anime.utils.set(b.items.map(function (it) { return it._tmeTick; }), { scaleX: 0 });
});
// Branches marked open (or all, with data-open) start open.
s.branches.forEach(function (b) {
if (allOpen || b.li.dataset.open !== undefined) toggle(b.li, true, true);
});
window.addEventListener('resize', function () { relayout(root); });
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var timer = null, idleUntil = 0, step = 0;
root.addEventListener('pointerdown', function () { idleUntil = Date.now() + 8000; });
// Open each branch in turn (deeper ones only when their parent is
// open), then fold everything away and begin again.
var tick = function () {
var wait = 1300;
if (Date.now() > idleUntil && !root.contains(document.activeElement)) {
var next = null;
for (var k = step; k < s.branches.length; k++) {
var b = s.branches[k], up = b.li.parentNode.closest('li');
if (!b.open && (!up || !up._tmeBranch || up._tmeBranch.open || !root.contains(up))) { next = b; step = k + 1; break; }
}
if (next) toggle(next.li, true);
else {
s.branches.forEach(function (b) { if (b.open && b.li.parentNode === root) toggle(b.li, false); });
// Once folded, shut the branches inside too, ready to open again.
setTimeout(function () { s.branches.forEach(function (b) { if (b.open) toggle(b.li, false, true); }); }, s.duration + 200);
step = 0;
wait = 1100;
}
}
timer = setTimeout(tick, wait);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(tick, 600);
}).observe(root);
}
}
function init() {
document.querySelectorAll('[data-tree-menu-expand]').forEach(function (el) {
if (el._tme) return;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.treeMenu = function (li) { toggle(li); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><ul data-tree-menu-expand>
<li><a href="/start">Getting started</a></li>
<li><span>Components</span>
<ul>
<li><a href="/buttons">Buttons</a></li>
<li><a href="/forms">Forms</a></li>
</ul>
</li>
</ul>