ui-011

Collapsible Sidebar Menu

Free

A collapsible sidebar menu for docs and admin apps: sections open on a measured height tween and an indicator slides to the current link.

intermediate
4 more details
micro-interactionclick-togglekeyboard-navigationtimeline-control

This element reads better at your own screen size than in the frame below:

Updated

About this component

A sidebar navigation column with collapsible sections. Every section opens on a measured height tween rather than a height auto jump, the chevrons rotate with the state, and a slim rail slides down the left edge to sit against whichever link is current.

Read the full effect overview

One section is open at a time by default; add data-multi="true" to the root and they stack. Ships in light and dark themes, switched by one data-variant attribute and defined entirely in CSS custom properties, so the same script serves both. GSAP core only: no plugins, no framework.

Collapsible Sidebar Menu - UI component preview

What's included

8 items
  • Sections open on a measured pixel height, never a height auto jump
  • Panels return to height auto once open, so they reflow with the viewport
  • Chevrons rotate through the state with their own tween
  • Indicator rail follows the current link and hides when its section is closed
  • One section open at a time, with a data-multi opt-out for stacking
  • Real buttons with aria-expanded, plus arrow, Home and End key navigation
  • Panels stay open with JavaScript off, so nothing is hidden from a crawler
  • Light and dark themes on one attribute, every token defined in both

Perfect for

4 use cases
  • Documentation and knowledge-base sidebars
  • Dashboard and admin navigation with grouped destinations
  • Settings screens with collapsible groups
  • Any long nav that needs to fold down to a scannable list

How it works

3 sections

Measured height, not auto

Opening a section reads the panel's current offsetHeight, flips it to height auto to measure the natural height, restores the starting value and then tweens between the two numbers. On completion an open panel is set back to auto so it reflows if the viewport or content changes. Nothing ever tweens toward the string auto, which is what produces the jump most accordions ship with.

The sliding rail

The rail keeps left: 0 in CSS and takes its entire offset from a measurement, y and height from the current link's rectangle relative to the nav, so a CSS offset can never be added twice. It re-measures on the height tween's onUpdate, on resize, and on document.fonts.ready, and fades out when the current link's section is collapsed.

Keyboard and state

Each section header is a real button carrying aria-expanded, so screen readers get the state for free; arrow keys, Home and End move between the headers. The trigger label colour is read from the --trigger-ink-open custom property at the moment the tween runs, and re-read on resize, so a theme swap needs no JavaScript.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Collapsible Sidebar Menu &mdash; GSAP Vault</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="assets/style.css">
  <style>
    /* Demo-only toolbar; not part of the component. */
    .showcase-toolbar {
      position: fixed; inset: 0 0 auto 0; z-index: 1000; height: 44px;
      display: flex; align-items: center; gap: 10px; padding: 0 12px;
      flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
      background: var(--ground-2); border-bottom: 1px solid var(--line);
      color: var(--ink-3); font-family: var(--font); font-size: 0.75rem;
      white-space: nowrap;
    }
    .showcase-toolbar::-webkit-scrollbar { display: none; }
    .showcase-toolbar__prompt { color: var(--ink-2); margin-right: auto; }
    .showcase-toolbar__group { display: flex; gap: 2px; padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--line); background: var(--raised); }
    .showcase-toolbar button {
      appearance: none; border: 0; cursor: pointer; font: inherit;
      min-height: 26px; padding: 0 9px; border-radius: 4px;
      background: transparent; color: var(--ink-2);
    }
    .showcase-toolbar button:hover { background: var(--hover); color: var(--ink); }
    .showcase-toolbar button[aria-pressed="true"] { background: var(--ink); color: var(--ground); }
    .showcase-toolbar button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    /* One row on a phone: the prompt goes rather than wrapping, so the
       strip stays 44px and the body's reserved padding keeps matching. */
    @media (max-width: 620px) { .showcase-toolbar__prompt { display: none; } }
  </style>
</head>
<body data-variant="light">

  <header class="showcase-toolbar">
    <span class="showcase-toolbar__prompt">Open a section: each panel tweens to its own measured height.</span>
    <div class="showcase-toolbar__group" role="group" aria-label="Theme">
      <button class="variant-btn" type="button" data-variant="light" aria-pressed="true">Light</button>
      <button class="variant-btn" type="button" data-variant="dark" aria-pressed="false">Dark</button>
    </div>
  </header>

  <main class="stage">
    <div class="stage__inner">

      <!-- data-multi="true" lets several sections stay open at once. -->
      <aside class="docs-sidebar" id="docsSidebar" data-multi="false">
        <nav class="sidebar-nav" id="sidebarNav" aria-label="Help centre">
          <span class="acc-rail" id="accRail" aria-hidden="true"></span>

          <section class="acc-section" data-open="true">
            <h2 class="acc-heading">
              <button class="acc-trigger" type="button" aria-expanded="true" aria-controls="accPanel1">
                <span class="acc-label">Articles</span>
                <span class="acc-chevron" aria-hidden="true">
                  <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
                </span>
              </button>
            </h2>
            <div class="acc-panel" id="accPanel1">
              <ul class="acc-links">
                <li><a class="acc-link" href="#getting-started">Getting started</a></li>
                <li><a class="acc-link is-active" href="#billing" aria-current="page">Billing</a></li>
                <li><a class="acc-link" href="#api">API</a></li>
              </ul>
            </div>
          </section>

          <section class="acc-section">
            <h2 class="acc-heading">
              <button class="acc-trigger" type="button" aria-expanded="false" aria-controls="accPanel2">
                <span class="acc-label">Collections</span>
                <span class="acc-chevron" aria-hidden="true">
                  <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
                </span>
              </button>
            </h2>
            <div class="acc-panel" id="accPanel2">
              <ul class="acc-links">
                <li><a class="acc-link" href="#account-basics">Account basics</a></li>
                <li><a class="acc-link" href="#integrations">Integrations</a></li>
                <li><a class="acc-link" href="#troubleshooting">Troubleshooting</a></li>
              </ul>
            </div>
          </section>

          <section class="acc-section">
            <h2 class="acc-heading">
              <button class="acc-trigger" type="button" aria-expanded="false" aria-controls="accPanel3">
                <span class="acc-label">Settings</span>
                <span class="acc-chevron" aria-hidden="true">
                  <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
                </span>
              </button>
            </h2>
            <div class="acc-panel" id="accPanel3">
              <ul class="acc-links">
                <li><a class="acc-link" href="#profile">Profile</a></li>
                <li><a class="acc-link" href="#notifications">Notifications</a></li>
              </ul>
            </div>
          </section>
        </nav>
      </aside>

    </div>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script src="assets/script.js"></script>
  <script>
    // Demo-only: switches data-variant on <body>; also honours ?variant=name.
    (function () {
      var buttons = Array.prototype.slice.call(document.querySelectorAll('.variant-btn'));
      var names = buttons.map(function (b) { return b.dataset.variant; });
      function setVariant(name) {
        document.body.setAttribute('data-variant', name);
        buttons.forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.variant === name)); });
        // The rail is measured, and a theme can move padding: re-measure.
        requestAnimationFrame(function () { window.dispatchEvent(new Event('resize')); });
      }
      var wanted = new URLSearchParams(location.search).get('variant');
      var initial = names.indexOf(wanted) > -1 ? wanted : document.body.getAttribute('data-variant') || names[0];
      setVariant(initial);
      buttons.forEach(function (b) { b.addEventListener('click', function () { setVariant(b.dataset.variant); }); });
    })();
  </script>
</body>
</html>
/**
 * Accordion Sidebar Nav
 *
 * A docs-style sidebar with collapsible sections. Sections open on a measured
 * height tween, chevrons rotate, and the active link carries a sliding
 * indicator rail on the left edge.
 *
 * @plugins none (GSAP core)
 */

(function () {
(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }
})(function initAccordionSidebarNav() {
    if (typeof gsap === 'undefined') {
        console.warn('[accordion-sidebar-nav] GSAP is required but was not found.');
        return;
    }

    var root = document.getElementById('docsSidebar');
    var nav = document.getElementById('sidebarNav');
    var rail = document.getElementById('accRail');
    if (!root || !nav || !rail) return;

    var sections = Array.prototype.slice.call(nav.querySelectorAll('.acc-section'));
    var triggers = sections.map(function (s) { return s.querySelector('.acc-trigger'); });
    var links = Array.prototype.slice.call(nav.querySelectorAll('.acc-link'));

    var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    var T = reduce ? 0 : 1; // duration multiplier: reduced motion gets instant states
    var allowMulti = root.getAttribute('data-multi') === 'true';

    // Helpers are declared before use: build:demos un-hoists function declarations.
    var readVar = function (name) {
        return getComputedStyle(document.body).getPropertyValue(name).trim();
    };

    var partsOf = function (section) {
        return {
            trigger: section.querySelector('.acc-trigger'),
            panel: section.querySelector('.acc-panel'),
            chevron: section.querySelector('.acc-chevron'),
            label: section.querySelector('.acc-label')
        };
    };

    var isOpen = function (section) {
        return section.getAttribute('data-open') === 'true';
    };

    var activeLink = function () {
        return nav.querySelector('.acc-link.is-active');
    };

    // The open/closed label colour is tweened, so it lands as an inline
    // style. Re-read it from the custom properties whenever the theme (and
    // therefore the token value) may have changed.
    var refreshInk = function () {
        sections.forEach(function (section) {
            var p = partsOf(section);
            if (p.label) gsap.set(p.label, { color: readVar(isOpen(section) ? '--trigger-ink-open' : '--trigger-ink') });
        });
    };

    // The rail keeps left: 0 in CSS and takes its whole offset from the
    // measurement, so nothing is added twice.
    var placeRail = function (animate) {
        var link = activeLink();
        var section = link ? link.closest('.acc-section') : null;
        if (!link || !section || !isOpen(section)) {
            gsap.to(rail, { opacity: 0, duration: 0.18 * T, overwrite: 'auto' });
            return;
        }
        var navBox = nav.getBoundingClientRect();
        var linkBox = link.getBoundingClientRect();
        var target = { y: linkBox.top - navBox.top, height: linkBox.height, opacity: 1 };
        if (animate === false || T === 0) {
            gsap.set(rail, target);
        } else {
            gsap.to(rail, {
                y: target.y,
                height: target.height,
                opacity: 1,
                duration: 0.35,
                ease: 'power3.out',
                overwrite: 'auto'
            });
        }
    };

    var setSection = function (section, open, animate) {
        var p = partsOf(section);
        if (!p.panel || !p.trigger) return;
        section.setAttribute('data-open', open ? 'true' : 'false');
        p.trigger.setAttribute('aria-expanded', open ? 'true' : 'false');

        var chevronRotation = open ? 0 : -90;
        var labelInk = readVar(open ? '--trigger-ink-open' : '--trigger-ink');

        if (animate === false || T === 0) {
            gsap.set(p.panel, { height: open ? 'auto' : 0 });
            gsap.set(p.chevron, { rotation: chevronRotation });
            gsap.set(p.label, { color: labelInk });
            placeRail(false);
            return;
        }

        // Measure the real open height rather than tweening to 'auto'.
        var from = p.panel.offsetHeight;
        gsap.set(p.panel, { height: 'auto' });
        var natural = p.panel.offsetHeight;
        var to = open ? natural : 0;
        gsap.set(p.panel, { height: from });

        gsap.to(p.panel, {
            height: to,
            duration: 0.42,
            ease: open ? 'power3.out' : 'power2.inOut',
            overwrite: 'auto',
            onUpdate: function () { placeRail(false); },
            onComplete: function () {
                // Back to auto so the panel reflows if the viewport changes.
                if (open) gsap.set(p.panel, { height: 'auto' });
                placeRail();
            }
        });
        gsap.to(p.chevron, { rotation: chevronRotation, duration: 0.35, ease: 'power2.out', overwrite: 'auto' });
        gsap.to(p.label, { color: labelInk, duration: 0.3, overwrite: 'auto' });
    };

    var toggleSection = function (section) {
        var opening = !isOpen(section);
        if (opening && !allowMulti) {
            sections.forEach(function (other) {
                if (other !== section && isOpen(other)) setSection(other, false, true);
            });
        }
        setSection(section, opening, true);
    };

    // Initial state, set by GSAP so no CSS height fights the tween. Without
    // JavaScript every panel stays open, which is the readable fallback.
    sections.forEach(function (section) {
        setSection(section, isOpen(section), false);
    });
    placeRail(false);

    triggers.forEach(function (trigger, i) {
        if (!trigger) return;
        trigger.addEventListener('click', function () { toggleSection(sections[i]); });
        trigger.addEventListener('keydown', function (e) {
            if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return;
            e.preventDefault();
            var next = i;
            if (e.key === 'ArrowDown') next = (i + 1) % triggers.length;
            if (e.key === 'ArrowUp') next = (i - 1 + triggers.length) % triggers.length;
            if (e.key === 'Home') next = 0;
            if (e.key === 'End') next = triggers.length - 1;
            triggers[next].focus();
        });
    });

    links.forEach(function (link) {
        link.addEventListener('click', function () {
            links.forEach(function (other) {
                other.classList.remove('is-active');
                other.removeAttribute('aria-current');
            });
            link.classList.add('is-active');
            link.setAttribute('aria-current', 'page');
            placeRail(true);
        });
    });

    window.addEventListener('resize', function () { refreshInk(); placeRail(false); });
    if (document.fonts && document.fonts.ready) {
        document.fonts.ready.then(function () { placeRail(false); });
    }

    // The demo's theme switcher dispatches resize, which covers both of
    // these; this stays for anything that wants to force the pass by hand.
    window.refreshAccordionRail = function () { refreshInk(); placeRail(false); };
});
})();
!function(){var e;e=function(){if("undefined"!=typeof gsap){var e=document.getElementById("docsSidebar"),t=document.getElementById("sidebarNav"),o=document.getElementById("accRail");if(e&&t&&o){var n=Array.prototype.slice.call(t.querySelectorAll(".acc-section")),r=n.map(function(e){return e.querySelector(".acc-trigger")}),a=Array.prototype.slice.call(t.querySelectorAll(".acc-link")),i=window.matchMedia("(prefers-reduced-motion: reduce)").matches?0:1,c="true"===e.getAttribute("data-multi"),u=function(e){return getComputedStyle(document.body).getPropertyValue(e).trim()},l=function(e){return{trigger:e.querySelector(".acc-trigger"),panel:e.querySelector(".acc-panel"),chevron:e.querySelector(".acc-chevron"),label:e.querySelector(".acc-label")}},s=function(e){return"true"===e.getAttribute("data-open")},d=function(){n.forEach(function(e){var t=l(e);t.label&&gsap.set(t.label,{color:u(s(e)?"--trigger-ink-open":"--trigger-ink")})})},g=function(e){var n=t.querySelector(".acc-link.is-active"),r=n?n.closest(".acc-section"):null;if(n&&r&&s(r)){var a=t.getBoundingClientRect(),c=n.getBoundingClientRect(),u={y:c.top-a.top,height:c.height,opacity:1};!1===e||0===i?gsap.set(o,u):gsap.to(o,{y:u.y,height:u.height,opacity:1,duration:.35,ease:"power3.out",overwrite:"auto"})}else gsap.to(o,{opacity:0,duration:.18*i,overwrite:"auto"})},p=function(e,t,o){var n=l(e);if(n.panel&&n.trigger){e.setAttribute("data-open",t?"true":"false"),n.trigger.setAttribute("aria-expanded",t?"true":"false");var r=t?0:-90,a=u(t?"--trigger-ink-open":"--trigger-ink");if(!1===o||0===i)return gsap.set(n.panel,{height:t?"auto":0}),gsap.set(n.chevron,{rotation:r}),gsap.set(n.label,{color:a}),void g(!1);var c=n.panel.offsetHeight;gsap.set(n.panel,{height:"auto"});var s=n.panel.offsetHeight,d=t?s:0;gsap.set(n.panel,{height:c}),gsap.to(n.panel,{height:d,duration:.42,ease:t?"power3.out":"power2.inOut",overwrite:"auto",onUpdate:function(){g(!1)},onComplete:function(){t&&gsap.set(n.panel,{height:"auto"}),g()}}),gsap.to(n.chevron,{rotation:r,duration:.35,ease:"power2.out",overwrite:"auto"}),gsap.to(n.label,{color:a,duration:.3,overwrite:"auto"})}};n.forEach(function(e){p(e,s(e),!1)}),g(!1),r.forEach(function(e,t){e&&(e.addEventListener("click",function(){var e,o;e=n[t],(o=!s(e))&&!c&&n.forEach(function(t){t!==e&&s(t)&&p(t,!1,!0)}),p(e,o,!0)}),e.addEventListener("keydown",function(e){if("ArrowDown"===e.key||"ArrowUp"===e.key||"Home"===e.key||"End"===e.key){e.preventDefault();var o=t;"ArrowDown"===e.key&&(o=(t+1)%r.length),"ArrowUp"===e.key&&(o=(t-1+r.length)%r.length),"Home"===e.key&&(o=0),"End"===e.key&&(o=r.length-1),r[o].focus()}}))}),a.forEach(function(e){e.addEventListener("click",function(){a.forEach(function(e){e.classList.remove("is-active"),e.removeAttribute("aria-current")}),e.classList.add("is-active"),e.setAttribute("aria-current","page"),g(!0)})}),window.addEventListener("resize",function(){d(),g(!1)}),document.fonts&&document.fonts.ready&&document.fonts.ready.then(function(){g(!1)}),window.refreshAccordionRail=function(){d(),g(!1)}}}else console.warn("[accordion-sidebar-nav] GSAP is required but was not found.")},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}();
* { box-sizing: border-box; margin: 0; padding: 0; }

/* ---------------------------------------------------------------------
   Themes. One design at two token values. Every colour, radius and shadow
   the component (and the demo ground) uses is a custom property, set in
   full in both blocks. Set data-variant on <body> in the demo, or on the
   component's own root in your own page.
   --------------------------------------------------------------------- */
body,
body[data-variant="light"] {
  color-scheme: light;
  --ground: #f2f2f1;
  --ground-2: #e9e9e7;
  --raised: #fbfbfa;
  --hover: #e2e2df;
  --ink: #141412;
  --ink-2: #4b4b47;
  --ink-3: #676762;
  --line: rgba(20, 20, 18, 0.12);
  --line-strong: rgba(20, 20, 18, 0.22);
  --accent: #ff4d00;
  --accent-ink: #ffffff;
  --accent-wash: #fbe4da;
  --shadow: 0 1px 2px rgba(20, 20, 18, 0.06), 0 4px 12px rgba(20, 20, 18, 0.06);
  --font: 'Inter', system-ui, -apple-system, sans-serif;
  --radius: 10px;
  --radius-sm: 6px;
  --radius-xs: 4px;

  /* Component tokens. These two are read by script.js at tween time, so
     they must be literal colours, never var() references. */
  --trigger-ink: #4b4b47;
  --trigger-ink-open: #141412;
  --link-ink: #4b4b47;
  --link-ink-active: #141412;
  --rail: #ff4d00;
}

body[data-variant="dark"] {
  color-scheme: dark;
  --ground: #141414;
  --ground-2: #0f0f0f;
  --raised: #1c1c1c;
  --hover: #232323;
  --ink: #f1efea;
  --ink-2: #c9c7c1;
  --ink-3: #9a988f;
  --line: rgba(241, 239, 234, 0.12);
  --line-strong: rgba(241, 239, 234, 0.22);
  --accent: #ff4d00;
  --accent-ink: #ffffff;
  --accent-wash: #33170c;
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
  --font: 'Inter', system-ui, -apple-system, sans-serif;
  --radius: 10px;
  --radius-sm: 6px;
  --radius-xs: 4px;

  --trigger-ink: #c9c7c1;
  --trigger-ink-open: #f1efea;
  --link-ink: #c9c7c1;
  --link-ink-active: #f1efea;
  --rail: #ff4d00;
}

/* ---------------------------------------------------------------------
   Demo furniture: the ground the component stands on. Not part of the
   component; delete .stage when you paste the sidebar into your own page.
   --------------------------------------------------------------------- */
html { overflow-x: clip; }
body {
  min-height: 100svh;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font);
  font-size: 0.875rem;
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
  padding-top: 44px;                 /* the demo toolbar strip */
}
::selection { background: var(--accent); color: var(--accent-ink); }

.stage { min-height: calc(100svh - 44px); display: grid; place-items: center; padding: 32px 20px; }
.stage__inner { width: 100%; max-width: 264px; }

:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ---------------------------------------------------------------------
   The component.
   --------------------------------------------------------------------- */
.docs-sidebar {
  width: 264px;
  max-width: 100%;
  min-height: 560px;
  background: var(--raised);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  /* The extra bottom padding keeps a collapsed panel's hidden contents
     clear of the border when the column hugs its content on a phone. */
  padding: 10px 10px 18px;
  overflow-y: auto;
}

/* The rail is positioned by GSAP from a measurement, so it keeps left: 0
   and takes its whole offset from y. A CSS offset here would double it. */
.sidebar-nav { position: relative; padding-left: 10px; }
.acc-rail {
  position: absolute;
  left: 0;
  top: 0;
  width: 2px;
  height: 0;
  border-radius: 2px;
  background: var(--rail);
  opacity: 0;
  pointer-events: none;
}

.acc-section + .acc-section { margin-top: 2px; }
.acc-heading { margin: 0; font: inherit; }
.acc-trigger {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  min-height: 34px;
  padding: 0 8px;
  background: transparent;
  border: 0;
  border-radius: var(--radius-sm);
  cursor: pointer;
  color: var(--trigger-ink);
  font-family: var(--font);
  font-size: 0.8125rem;
  font-weight: 500;
  text-align: left;
}
/* No CSS transform on .acc-chevron: GSAP owns its rotation. */
.acc-chevron { display: inline-flex; color: var(--ink-3); }

.acc-panel { overflow: hidden; }
.acc-links { list-style: none; padding: 0 0 6px; }
.acc-link {
  display: flex;
  align-items: center;
  min-height: 32px;
  padding: 0 8px;
  border-radius: var(--radius-sm);
  color: var(--link-ink);
  font-size: 0.8125rem;
  font-weight: 400;
  text-decoration: none;
}
.acc-link.is-active { color: var(--link-ink-active); font-weight: 500; }

@media (hover: hover) {
  .acc-trigger:hover { background: var(--hover); color: var(--ink); }
  .acc-link:hover { background: var(--hover); color: var(--link-ink-active); }
}

@media (max-width: 480px) {
  .stage { padding: 20px 16px; }
  .stage__inner { max-width: 100%; }
  .docs-sidebar { width: 100%; }
}

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}

A sidebar navigation column with collapsible sections. Sections open on a measured height tween, chevrons rotate with the state, and a slim indicator rail slides down the left edge to sit against the current link.

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source with an onReady guard

  • Sections open on a measured pixel height, never a height: auto jump

  • Panels return to height: auto once open, so they reflow with the viewport

  • Chevrons rotate through the state with their own tween

  • Indicator rail follows the current link and hides when its section is closed

  • One section open at a time, with a data-multi opt-out for stacking

  • Real buttons with aria-expanded, plus arrow, Home and End key navigation

  • Panels stay open with JavaScript off, so nothing is hidden from a crawler

  • Light and dark themes, both complete, on one data-variant attribute

Quick Start

<link rel="stylesheet" href="assets/style.css">
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="assets/script.js"></script>

Copy the .docs-sidebar block from index.html. The script finds its two roots by id (docsSidebar and sidebarNav) plus the rail by id (accRail), so keep those or update the selectors at the top of assets/script.js. Inside them it works off classes: .acc-section, .acc-trigger, .acc-panel, .acc-chevron, .acc-label and .acc-link. Mark the current page with class="acc-link is-active"; a section starts open when it carries data-open="true".

Allow several sections open at once with one attribute:

<aside class="docs-sidebar" id="docsSidebar" data-multi="true">

Demo furniture. The fixed toolbar strip at the top of index.html (its markup, its CSS and its inline script), the .stage wrapper that centres the column on the grey ground, and the min-height that holds the column at a fixed height are the demo, not the component. Delete them and drop .docs-sidebar into your own layout; nothing in assets/script.js reads any of it.

Themes

The component ships in two themes, light (the default) and dark: one design at two sets of token values.

Pick one: set the attribute on <body> in the demo, or on the component's own root in your page.

<body data-variant="dark">

The demo's toolbar toggle and the ?variant=dark URL parameter only set this attribute; neither is part of the component.

Re-value it for your own brand: each body[data-variant="…"] block in assets/style.css sets the whole list, so a rebrand is editing values in two places and nothing else:

Token What it colours
--ground, --ground-2 the demo ground and the toolbar strip
--raised the column's own surface
--hover trigger and link hover
--ink, --ink-2, --ink-3 primary, secondary and muted text
--line the column's hairline border
--accent, --accent-ink the indicator rail, the focus ring, ::selection
--trigger-ink, --trigger-ink-open section header label, closed and open
--link-ink, --link-ink-active links, and the current one
--rail the indicator rail
--radius, --radius-sm the column, and the rows inside it
--shadow the column's elevation

--trigger-ink and --trigger-ink-open are read by assets/script.js at the moment the tween runs, so give them literal colour values rather than var() references. Nothing in the script reads the theme name.

Keyboard and ARIA

  • Each section header is a real <button> inside an <h2>, carrying aria-expanded and aria-controls pointing at its panel, so assistive technology gets the open state for free.
  • Enter / Space toggle the focused section (native button behaviour).
  • Down and Up move focus between section headers and wrap; Home and End jump to the first and last.
  • Tab moves through the headers and the links of open sections in document order; links inside a collapsed panel are still in the tab order unless you hide them, so collapse is a visual state, not a focus trap.
  • The current link carries aria-current="page"; clicking another link moves it, and the rail follows.
  • Focus is shown by a 2px accent outline on every interactive element.

How It Works

Measured height, not auto. Opening a section reads the panel's current offsetHeight, flips it to height: auto to measure the natural height, restores the starting value and then tweens between the two numbers. On completion an open panel is set back to auto so it reflows if the viewport or the content changes. Nothing ever tweens toward the string auto, which is what produces the jump most accordions ship with.

The sliding rail. The rail keeps left: 0 in CSS and takes its entire offset from a measurement: y and height come from the current link's rectangle relative to the nav, so a CSS offset can never be added on top of the measured one. It re-measures on the height tween's onUpdate, on resize, and on document.fonts.ready, and fades out when the current link's section is collapsed.

Theme-aware label colour. The header label colour is tweened, so it lands as an inline style. The values come from the --trigger-ink / --trigger-ink-open custom properties, read at the moment the tween runs, and they are re-read on resize (which the demo's toggle dispatches) so a theme change never leaves a label on a stale colour.

Customisation

  • Colours, radii and shadows live in assets/style.css as custom properties on each body[data-variant] block; the accent is #ff4d00.
  • Rail width and shape: the .acc-rail rule. Keep left: 0, or the measured offset is applied twice.
  • Column width and height: .docs-sidebar (264px wide in the demo).
  • Open and close easing: the gsap.to calls in setSection in assets/script.js.
  • Reduced motion sets the duration multiplier T to 0, so every state change lands instantly.

Requirements

  • GSAP 3.12+ (demo uses 3.15.0), core only, no plugins
  • No build step, no framework