ui-052

FAQ Accordion

Free

An accessible FAQ accordion: answers open to their natural height, a plus morphs into a minus, with expand all and #deep links.

intermediate
5 more details
click-togglestate-transitionmicro-interactionkeyboard-navigationfocus-management
FAQ Accordion - UI component preview

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

About this component

An FAQ block built on the WAI-ARIA accordion pattern: each question is a button inside a heading, each answer a labelled region, and the section carries its own heading and a line of context. Opening a question tweens its panel from zero to its natural height while the answer fades in and rises six pixels a beat behind, and the plus beside it morphs into a minus as its vertical stroke rotates flat and takes the accent colour.

Read the full effect overview

In one-open mode, opening a new question closes the previous one on the same GSAP timeline with the same ease, so the page's height changes by the difference only. When the answer closing sits above the question you clicked, the page is scrolled frame by frame to hold that question still under the pointer instead of letting it slide up. Many-open mode is one attribute away, and Expand all and Collapse all sit beside the heading.

Closed answers stay in the DOM behind hidden="until-found", so the browser's find-in-page reaches text inside them and the accordion opens the match. A #faq-<id> link opens that question and scrolls to it, including links from one answer to another. Up, Down, Home and End move between questions. Two themes, light and dark, on one short list of neutral product tokens in Mona Sans, with a single blue accent for focus, the open icon and links.

What's included

12 items
  • Height-to-auto open and close with clearProps, so a panel never keeps a stale pixel height
  • Answer text fades in and rises 6px a beat after the panel starts opening
  • Plus-to-minus icon: two strokes, the vertical one rotates flat, colour moves to the accent
  • One-open mode closes the previous answer on the same timeline and holds the clicked question still on screen
  • data-accordion-mode="single|multiple", switchable at runtime; Expand all and Collapse all with aria-disabled states
  • WAI-ARIA accordion: button in a heading, aria-expanded, aria-controls, region with aria-labelledby
  • Up, Down, Home and End move focus between questions
  • #faq-<id> deep links open and scroll to an item, on load and on hashchange
  • Closed answers use hidden="until-found" where supported, so find-in-page still finds them
  • accordion:toggle event and a small API on the root: open, close, toggle, expandAll, collapseAll
  • Soft hover gated behind (hover: hover), so nothing sticks after a tap
  • Two themes as custom property blocks; reduced motion opens and closes instantly

Perfect for

5 use cases
  • FAQ sections on pricing, billing and help pages
  • Support centres that link straight to one answer with #faq-<id>
  • Product documentation with collapsible reference sections
  • Checkout and plan pages that need answers close to the decision
  • Content sites that want a disclosure pattern screen readers and find-in-page both handle

How it works

3 sections

Opening to auto height

A closed panel carries the hidden attribute. Opening removes it, tweens height from zero to auto, and clears the inline height on completion so the panel hands back to CSS. The answer inside is tweened separately, from transparent and six pixels low, starting a beat after the panel so the text arrives into space that is already there. Closing fades the answer first, collapses the panel and sets hidden again in onComplete, guarded so a question reopened mid-close is left alone.

One open, no jump

In one-open mode the closing and opening tweens are added to one timeline at the same start time with the same ease, so the total height moves by the difference rather than growing and then shrinking. If the answer closing is above the question you clicked, the timeline's onUpdate measures the question's position every frame and scrolls by any drift, which holds it under the pointer and cooperates with the browser's own scroll anchoring instead of doubling it.

Searchable, linkable, keyboard-first

Closed answers are never removed from the DOM. Where the browser supports hidden="until-found" they are hidden with that value, so find-in-page matches text inside them; the beforematch event marks the item open and updates its button and icon. A #faq-<id> hash opens that item and scrolls to it on load and on hashchange, and arrow keys, Home and End move focus between the question buttons.

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

Lighthouse, as measured

Google Lighthouse on this UI element's demo, 15 September 2026. 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">
  <meta name="robots" content="noindex, nofollow">
  <title>FAQ Accordion | GSAP Vault</title>
  <script data-cfasync="false">document.documentElement.classList.add('has-js')</script>
  <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=Mona+Sans: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: 6px; border: 1px solid var(--line); background: var(--raised); flex: none; }
    .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; }
    @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 question, then another: in one-open mode the first closes as the next opens.</span>
    <div class="showcase-toolbar__group" role="group" aria-label="Accordion mode">
      <button class="mode-btn" type="button" data-mode="single" aria-pressed="true">One open</button>
      <button class="mode-btn" type="button" data-mode="multiple" aria-pressed="false">Many open</button>
    </div>
    <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">

      <!-- Component starts here -->
      <section class="faq" data-accordion data-accordion-mode="single" aria-labelledby="faq-title">
        <div class="faq-head">
          <div class="faq-head__text">
            <h1 class="faq-title" id="faq-title">Frequently asked questions</h1>
            <p class="faq-sub">Billing, plans and seats. For anything else, contact our support team.</p>
          </div>
          <div class="faq-bulk">
            <button class="faq-bulk__btn" type="button" data-accordion-expand>Expand all</button>
            <button class="faq-bulk__btn" type="button" data-accordion-collapse>Collapse all</button>
          </div>
        </div>

        <div class="faq-list">

          <div class="faq-item is-open" id="faq-billing-date" data-accordion-item>
            <h2 class="faq-q">
              <button class="faq-trigger" type="button" id="faq-billing-date-trigger" aria-expanded="true" aria-controls="faq-billing-date-panel" data-accordion-trigger>
                <span class="faq-trigger__text">When will I be charged?</span>
                <span class="faq-icon" aria-hidden="true"><span class="faq-icon__bar"></span><span class="faq-icon__bar faq-icon__bar--v" data-accordion-icon-bar></span></span>
              </button>
            </h2>
            <div class="faq-panel" id="faq-billing-date-panel" role="region" aria-labelledby="faq-billing-date-trigger" data-accordion-panel>
              <div class="faq-answer" data-accordion-answer>
                <p>Your card is charged on the day you subscribe, then on the same date each month or year depending on your plan. If that date does not exist in a given month, such as the 31st, we charge on the last day of that month instead.</p>
              </div>
            </div>
          </div>

          <div class="faq-item" id="faq-switch-plan" data-accordion-item>
            <h2 class="faq-q">
              <button class="faq-trigger" type="button" id="faq-switch-plan-trigger" aria-expanded="false" aria-controls="faq-switch-plan-panel" data-accordion-trigger>
                <span class="faq-trigger__text">Can I switch between monthly and annual billing?</span>
                <span class="faq-icon" aria-hidden="true"><span class="faq-icon__bar"></span><span class="faq-icon__bar faq-icon__bar--v" data-accordion-icon-bar></span></span>
              </button>
            </h2>
            <div class="faq-panel" id="faq-switch-plan-panel" role="region" aria-labelledby="faq-switch-plan-trigger" data-accordion-panel>
              <div class="faq-answer" data-accordion-answer>
                <p>Yes, from Billing in your account settings. What happens next depends on the direction:</p>
                <ul>
                  <li><strong>Monthly to annual</strong> takes effect straight away, and the unused part of your current month is credited against the annual price.</li>
                  <li><strong>Annual to monthly</strong> keeps your annual price until the paid year ends, then moves you to monthly billing on the renewal date.</li>
                  <li>Either way, every invoice stays in one history under Billing.</li>
                </ul>
              </div>
            </div>
          </div>

          <div class="faq-item" id="faq-seats" data-accordion-item>
            <h2 class="faq-q">
              <button class="faq-trigger" type="button" id="faq-seats-trigger" aria-expanded="false" aria-controls="faq-seats-panel" data-accordion-trigger>
                <span class="faq-trigger__text">How do I add or remove seats?</span>
                <span class="faq-icon" aria-hidden="true"><span class="faq-icon__bar"></span><span class="faq-icon__bar faq-icon__bar--v" data-accordion-icon-bar></span></span>
              </button>
            </h2>
            <div class="faq-panel" id="faq-seats-panel" role="region" aria-labelledby="faq-seats-trigger" data-accordion-panel>
              <div class="faq-answer" data-accordion-answer>
                <p>Admins can change the seat count at any time under Members. Added seats are billed straight away at a prorated rate for the rest of the period. Removed seats stay usable until the period ends and come off your next invoice.</p>
              </div>
            </div>
          </div>

          <div class="faq-item" id="faq-failed-payment" data-accordion-item>
            <h2 class="faq-q">
              <button class="faq-trigger" type="button" id="faq-failed-payment-trigger" aria-expanded="false" aria-controls="faq-failed-payment-panel" data-accordion-trigger>
                <span class="faq-trigger__text">What happens if a payment fails?</span>
                <span class="faq-icon" aria-hidden="true"><span class="faq-icon__bar"></span><span class="faq-icon__bar faq-icon__bar--v" data-accordion-icon-bar></span></span>
              </button>
            </h2>
            <div class="faq-panel" id="faq-failed-payment-panel" role="region" aria-labelledby="faq-failed-payment-trigger" data-accordion-panel>
              <div class="faq-answer" data-accordion-answer>
                <p>We retry the card three times over seven days and email your billing contact after each attempt. Your workspace keeps working normally in the meantime.</p>
                <p>If the last retry fails, the account becomes read-only: everyone can still sign in and export their data, but nothing new can be created until the balance is paid. Updating the card under Billing clears it immediately.</p>
              </div>
            </div>
          </div>

          <div class="faq-item" id="faq-cancel" data-accordion-item>
            <h2 class="faq-q">
              <button class="faq-trigger" type="button" id="faq-cancel-trigger" aria-expanded="false" aria-controls="faq-cancel-panel" data-accordion-trigger>
                <span class="faq-trigger__text">How do I cancel my subscription?</span>
                <span class="faq-icon" aria-hidden="true"><span class="faq-icon__bar"></span><span class="faq-icon__bar faq-icon__bar--v" data-accordion-icon-bar></span></span>
              </button>
            </h2>
            <div class="faq-panel" id="faq-cancel-panel" role="region" aria-labelledby="faq-cancel-trigger" data-accordion-panel>
              <div class="faq-answer" data-accordion-answer>
                <p>Cancel from Billing whenever you like; there is no notice period. You keep access until the end of the period you have paid for and are not charged again. If you cancelled an annual plan recently, check <a href="#faq-refunds">whether you can get a refund</a>.</p>
              </div>
            </div>
          </div>

          <div class="faq-item" id="faq-refunds" data-accordion-item>
            <h2 class="faq-q">
              <button class="faq-trigger" type="button" id="faq-refunds-trigger" aria-expanded="false" aria-controls="faq-refunds-panel" data-accordion-trigger>
                <span class="faq-trigger__text">Can I get a refund?</span>
                <span class="faq-icon" aria-hidden="true"><span class="faq-icon__bar"></span><span class="faq-icon__bar faq-icon__bar--v" data-accordion-icon-bar></span></span>
              </button>
            </h2>
            <div class="faq-panel" id="faq-refunds-panel" role="region" aria-labelledby="faq-refunds-trigger" data-accordion-panel>
              <div class="faq-answer" data-accordion-answer>
                <p>Annual plans cancelled within 14 days of purchase or renewal are refunded in full to the original payment method. Monthly plans are not refunded.</p>
              </div>
            </div>
          </div>

        </div>
      </section>
      <!-- Component ends here -->

    </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)); });
        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); }); });
    })();

    // Demo-only: the mode control sets data-accordion-mode on the component,
    // the one attribute the script reads for it. Also honours ?mode=multiple.
    (function () {
      var root = document.querySelector('[data-accordion]');
      var buttons = Array.prototype.slice.call(document.querySelectorAll('.mode-btn'));
      function setMode(mode) {
        root.setAttribute('data-accordion-mode', mode);
        buttons.forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.mode === mode)); });
      }
      var wanted = new URLSearchParams(location.search).get('mode');
      if (wanted === 'single' || wanted === 'multiple') setMode(wanted);
      buttons.forEach(function (b) { b.addEventListener('click', function () { setMode(b.dataset.mode); }); });
    })();
  </script>
</body>
</html>
/**
 * FAQ Disclosure Accordion
 *
 * A WAI-ARIA accordion for FAQ blocks. Panels open to their natural height
 * with the answer fading and rising a beat behind; a plus morphs into a minus.
 * In one-open mode the previous answer closes on the same timeline as the new
 * one opens, and the clicked question is held still on screen while the
 * content above it shrinks. Expand all / Collapse all, Up/Down/Home/End
 * between questions, #faq-<id> deep links, and closed answers kept in the DOM
 * behind hidden="until-found" so the browser's find-in-page still reaches them.
 *
 * @plugins none (GSAP core only)
 * @techniques click-toggle, state-transition, micro-interaction, keyboard-navigation, focus-management
 */

(function () {
  (function onReady(init) {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
      init();
    }
  })(function initFaqAccordion() {
    if (typeof gsap === 'undefined') {
      // Without GSAP the CSS pre-hide is lifted, so every answer stays readable.
      document.documentElement.classList.remove('has-js');
      return;
    }
    document.documentElement.classList.add('has-js');

    /* Timing. Product motion: short, eased out, the answer a beat behind the
       panel. Change these rather than the tweens below. */
    var OPEN_DURATION = 0.34;
    var CLOSE_DURATION = 0.28;
    var ANSWER_DELAY = 0.08;
    var ANSWER_RISE = 6;

    /* hidden="until-found" keeps a closed answer searchable: Ctrl/Cmd+F finds
       text inside it, the browser removes the attribute and fires
       `beforematch`. Browsers without it treat the value as plain `hidden`. */
    var supportsUntilFound = 'onbeforematch' in document.body;

    var slice = function (nodes) { return Array.prototype.slice.call(nodes); };

    var hidePanel = function (panel) {
      if (supportsUntilFound) panel.setAttribute('hidden', 'until-found');
      else panel.hidden = true;
    };

    var ctx = gsap.context(function () {
      var mm = gsap.matchMedia();

      /* A complementary pair: gsap.matchMedia only runs the callback while at
         least one condition matches, so every device lands in exactly one. */
      mm.add({
        isMotion: '(prefers-reduced-motion: no-preference)',
        isReduced: '(prefers-reduced-motion: reduce)'
      }, function (context) {
        var isReduced = context.conditions.isReduced;
        var dur = function (seconds) { return isReduced ? 0 : seconds; };
        var teardown = [];

        slice(document.querySelectorAll('[data-accordion]')).forEach(function (root) {
          teardown.push(setupAccordion(root, dur));
        });

        return function cleanup() {
          teardown.forEach(function (fn) { fn(); });
        };
      });
    });

    window.addEventListener('beforeunload', function () { ctx.revert(); });

    /* ------------------------------------------------------------------ */

    function setupAccordion(root, dur) {
      var handlers = [];
      var on = function (el, type, fn, opts) {
        el.addEventListener(type, fn, opts);
        handlers.push(function () { el.removeEventListener(type, fn, opts); });
      };

      var expandAllBtn = root.querySelector('[data-accordion-expand]');
      var collapseAllBtn = root.querySelector('[data-accordion-collapse]');
      var swap = null;         // the running one-open timeline, if any
      var lastOpened = null;

      var isSingle = function () {
        return root.getAttribute('data-accordion-mode') !== 'multiple';
      };

      /* Read every item from the markup. Missing ids and ARIA wiring are
         filled in, so the minimum markup is the item, its button and its
         panel. The open state comes from aria-expanded, so a re-init (for
         example after a reduced-motion change) keeps what the reader opened. */
      var items = slice(root.querySelectorAll('[data-accordion-item]')).map(function (el, i) {
        var trigger = el.querySelector('[data-accordion-trigger]');
        var panel = el.querySelector('[data-accordion-panel]');
        var answer = el.querySelector('[data-accordion-answer]') || panel;
        var iconBar = el.querySelector('[data-accordion-icon-bar]');
        var id = el.id || (el.id = 'faq-item-' + (i + 1));
        if (!trigger.id) trigger.id = id + '-trigger';
        if (!panel.id) panel.id = id + '-panel';
        trigger.setAttribute('aria-controls', panel.id);
        panel.setAttribute('role', 'region');
        panel.setAttribute('aria-labelledby', trigger.id);
        return {
          el: el, trigger: trigger, panel: panel, answer: answer, iconBar: iconBar,
          open: trigger.getAttribute('aria-expanded') === 'true'
        };
      });
      if (!items.length) return function () {};

      var itemFor = function (node) {
        var el = node && node.closest ? node.closest('[data-accordion-item]') : null;
        for (var i = 0; i < items.length; i++) if (items[i].el === el) return items[i];
        return null;
      };

      var emit = function (item) {
        root.dispatchEvent(new CustomEvent('accordion:toggle', {
          bubbles: true, detail: { id: item.el.id, open: item.open }
        }));
      };

      var syncBulkButtons = function () {
        var openCount = items.filter(function (it) { return it.open; }).length;
        // aria-disabled, not disabled: a button that disables itself under
        // the keyboard would drop focus to <body>.
        if (expandAllBtn) expandAllBtn.setAttribute('aria-disabled', String(openCount === items.length));
        if (collapseAllBtn) collapseAllBtn.setAttribute('aria-disabled', String(openCount === 0));
      };

      var setState = function (item, open) {
        item.open = open;
        item.trigger.setAttribute('aria-expanded', String(open));
        item.el.classList.toggle('is-open', open);
        if (open) lastOpened = item;
      };

      /* Opening: remove `hidden`, tween height from where it is to `auto`,
         then clear it so the panel hands back to CSS (a theme or font change
         later can never leave it at a stale pixel height). The answer rises
         6px and fades in a beat after the panel starts moving. */
      var openTween = function (item, ease) {
        var panel = item.panel;
        gsap.killTweensOf([panel, item.answer, item.iconBar]);
        var fromHidden = panel.hasAttribute('hidden');
        var startHeight = fromHidden ? 0 : panel.offsetHeight;
        panel.removeAttribute('hidden');

        var tl = gsap.timeline();
        tl.fromTo(panel, { height: startHeight }, {
          height: 'auto', duration: dur(OPEN_DURATION), ease: ease || 'power2.out', clearProps: 'height'
        }, 0);
        tl.fromTo(item.answer,
          { opacity: fromHidden ? 0 : gsap.getProperty(item.answer, 'opacity'), y: fromHidden ? ANSWER_RISE : gsap.getProperty(item.answer, 'y') },
          { opacity: 1, y: 0, duration: dur(0.26), ease: 'power2.out', clearProps: 'opacity,transform' },
          dur(ANSWER_DELAY));
        if (item.iconBar) {
          tl.to(item.iconBar, { rotation: 90, duration: dur(0.3), ease: 'power2.inOut' }, 0);
        }
        return tl;
      };

      /* Closing: the answer fades first, the panel follows, and `hidden` is
         set in onComplete so nothing invisible is left in the tab order or the
         accessibility tree. The guard skips it if the item was reopened
         mid-close. */
      var closeTween = function (item, ease) {
        var panel = item.panel;
        gsap.killTweensOf([panel, item.answer, item.iconBar]);
        var tl = gsap.timeline();
        if (panel.hasAttribute('hidden')) return tl;
        tl.to(item.answer, { opacity: 0, duration: dur(0.14), ease: 'power1.out' }, 0);
        tl.fromTo(panel, { height: panel.offsetHeight }, {
          height: 0, duration: dur(CLOSE_DURATION), ease: ease || 'power2.inOut',
          onComplete: function () {
            if (item.open) return;
            hidePanel(panel);
            gsap.set(panel, { clearProps: 'height' });
            gsap.set(item.answer, { clearProps: 'opacity,transform' });
          }
        }, 0);
        if (item.iconBar) {
          tl.to(item.iconBar, { rotation: 0, duration: dur(0.3), ease: 'power2.inOut' }, 0);
        }
        return tl;
      };

      /* Keeps `anchor` at the same place in the viewport while the timeline
         runs. Closing an answer ABOVE the clicked question would otherwise
         pull the question up under the pointer by that answer's height. The
         correction is measured every frame, so it cooperates with the
         browser's own scroll anchoring instead of doubling it. */
      var holdInPlace = function (tl, anchor) {
        var startTop = anchor.getBoundingClientRect().top;
        var hold = function () {
          var drift = anchor.getBoundingClientRect().top - startTop;
          if (Math.abs(drift) >= 0.5) window.scrollBy(0, drift);
        };
        tl.eventCallback('onUpdate', hold);
        tl.eventCallback('onComplete', hold);
      };

      var open = function (item, opts) {
        opts = opts || {};
        if (item.open) return;
        if (swap) { swap.progress(1); swap = null; }

        var others = isSingle() && !opts.keepOthers
          ? items.filter(function (it) { return it !== item && it.open; })
          : [];

        setState(item, true);
        emit(item);

        if (!others.length) {
          openTween(item);
        } else {
          /* One timeline, one ease for both heights: the page's total height
             moves by the difference only, never grows then shrinks. */
          var tl = gsap.timeline({ onComplete: function () { swap = null; } });
          others.forEach(function (other) {
            setState(other, false);
            emit(other);
            tl.add(closeTween(other, 'power2.inOut'), 0);
          });
          tl.add(openTween(item, 'power2.inOut'), 0);
          var above = others.some(function (other) {
            return other.el.compareDocumentPosition(item.el) & Node.DOCUMENT_POSITION_FOLLOWING;
          });
          if (above && opts.hold !== false) holdInPlace(tl, item.trigger);
          swap = tl;
        }
        syncBulkButtons();
      };

      var close = function (item) {
        if (!item.open) return;
        setState(item, false);
        emit(item);
        closeTween(item);
        syncBulkButtons();
      };

      var toggle = function (item) { item.open ? close(item) : open(item); };

      var expandAll = function () {
        items.forEach(function (it) { if (!it.open) open(it, { keepOthers: true }); });
      };
      var collapseAll = function () { items.forEach(close); };

      /* ---- initial state: set without animation ---- */
      items.forEach(function (item) {
        setState(item, item.open);
        if (item.open) item.panel.removeAttribute('hidden');
        else hidePanel(item.panel);
        if (item.iconBar) gsap.set(item.iconBar, { rotation: item.open ? 90 : 0 });
      });
      // One-open mode starting with several open keeps the first.
      if (isSingle()) {
        var openNow = items.filter(function (it) { return it.open; });
        openNow.slice(1).forEach(function (it) { setState(it, false); hidePanel(it.panel); if (it.iconBar) gsap.set(it.iconBar, { rotation: 0 }); });
        lastOpened = openNow[0] || null;
      }
      syncBulkButtons();
      root.setAttribute('data-accordion-ready', '');

      /* ---- pointer and keyboard ---- */
      items.forEach(function (item) {
        on(item.trigger, 'click', function () { toggle(item); });

        /* Find-in-page revealed a closed answer: the browser has already
           removed `hidden` and is scrolling to the match. Record it as open
           without animating, and leave the others alone so that scroll is not
           undone by a panel collapsing above it. */
        on(item.panel, 'beforematch', function () {
          gsap.killTweensOf([item.panel, item.answer, item.iconBar]);
          gsap.set(item.panel, { clearProps: 'height' });
          gsap.set(item.answer, { clearProps: 'opacity,transform' });
          if (item.iconBar) gsap.set(item.iconBar, { rotation: 90 });
          setState(item, true);
          emit(item);
          syncBulkButtons();
        });
      });

      // Up/Down move between questions (wrapping), Home/End jump to the ends.
      on(root, 'keydown', function (e) {
        var current = itemFor(document.activeElement);
        if (!current || document.activeElement !== current.trigger) return;
        var index = items.indexOf(current);
        var next = null;
        if (e.key === 'ArrowDown') next = items[(index + 1) % items.length];
        else if (e.key === 'ArrowUp') next = items[(index - 1 + items.length) % items.length];
        else if (e.key === 'Home') next = items[0];
        else if (e.key === 'End') next = items[items.length - 1];
        if (!next) return;
        e.preventDefault();
        next.trigger.focus();
      });

      if (expandAllBtn) on(expandAllBtn, 'click', function () {
        if (expandAllBtn.getAttribute('aria-disabled') !== 'true') expandAll();
      });
      if (collapseAllBtn) on(collapseAllBtn, 'click', function () {
        if (collapseAllBtn.getAttribute('aria-disabled') !== 'true') collapseAll();
      });

      /* Switching to one-open mode with several answers open keeps the most
         recently opened one and closes the rest. */
      var modeObserver = new MutationObserver(function () {
        if (!isSingle()) return;
        var keep = lastOpened && lastOpened.open ? lastOpened : items.filter(function (it) { return it.open; })[0];
        items.forEach(function (it) { if (it !== keep) close(it); });
      });
      modeObserver.observe(root, { attributes: true, attributeFilter: ['data-accordion-mode'] });

      /* ---- deep links: #faq-<id> opens that item and scrolls to it ---- */
      var fromHash = function (focus) {
        var id = decodeURIComponent(location.hash.slice(1));
        if (!id) return;
        var target = document.getElementById(id);
        var item = target && root.contains(target) ? itemFor(target) : null;
        if (!item) return;
        // No hold here: the reader asked to go to this item, so let it move.
        open(item, { hold: false });
        var land = function () {
          item.el.scrollIntoView({ block: 'start', behavior: dur(1) ? 'smooth' : 'auto' });
          if (focus) item.trigger.focus({ preventScroll: true });
        };
        // Scroll once the panels have settled, so the target does not move
        // out from under the scroll as the answer above it closes.
        gsap.delayedCall(dur(OPEN_DURATION), land);
      };
      fromHash(false);
      on(window, 'hashchange', function () { fromHash(true); });

      /* A small public API on the root element. */
      root.accordion = {
        open: function (id) { var it = itemFor(document.getElementById(id)); if (it) open(it); },
        close: function (id) { var it = itemFor(document.getElementById(id)); if (it) close(it); },
        toggle: function (id) { var it = itemFor(document.getElementById(id)); if (it) toggle(it); },
        expandAll: expandAll,
        collapseAll: collapseAll
      };

      return function teardownAccordion() {
        handlers.forEach(function (off) { off(); });
        modeObserver.disconnect();
        if (swap) swap.progress(1);
        // Land every item in its recorded state with no inline leftovers.
        items.forEach(function (item) {
          gsap.killTweensOf([item.panel, item.answer, item.iconBar]);
          gsap.set([item.panel, item.answer], { clearProps: 'height,opacity,transform' });
          if (item.open) item.panel.removeAttribute('hidden');
          else hidePanel(item.panel);
        });
        delete root.accordion;
      };
    }
  });
})();
!function(){var e;e=function(){if("undefined"!=typeof gsap){document.documentElement.classList.add("has-js");var e=.34,t="onbeforematch"in document.body,n=function(e){return Array.prototype.slice.call(e)},o=function(e){t?e.setAttribute("hidden","until-found"):e.hidden=!0},r=gsap.context(function(){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(t){var r=t.conditions.isReduced,a=function(e){return r?0:e},i=[];return n(document.querySelectorAll("[data-accordion]")).forEach(function(t){i.push(function(t,r){var a=[],i=function(e,t,n,o){e.addEventListener(t,n,o),a.push(function(){e.removeEventListener(t,n,o)})},c=t.querySelector("[data-accordion-expand]"),u=t.querySelector("[data-accordion-collapse]"),l=null,d=null,s=function(){return"multiple"!==t.getAttribute("data-accordion-mode")},f=n(t.querySelectorAll("[data-accordion-item]")).map(function(e,t){var n=e.querySelector("[data-accordion-trigger]"),o=e.querySelector("[data-accordion-panel]"),r=e.querySelector("[data-accordion-answer]")||o,a=e.querySelector("[data-accordion-icon-bar]"),i=e.id||(e.id="faq-item-"+(t+1));return n.id||(n.id=i+"-trigger"),o.id||(o.id=i+"-panel"),n.setAttribute("aria-controls",o.id),o.setAttribute("role","region"),o.setAttribute("aria-labelledby",n.id),{el:e,trigger:n,panel:o,answer:r,iconBar:a,open:"true"===n.getAttribute("aria-expanded")}});if(!f.length)return function(){};var p=function(e){for(var t=e&&e.closest?e.closest("[data-accordion-item]"):null,n=0;n<f.length;n++)if(f[n].el===t)return f[n];return null},g=function(e){t.dispatchEvent(new CustomEvent("accordion:toggle",{bubbles:!0,detail:{id:e.el.id,open:e.open}}))},h=function(){var e=f.filter(function(e){return e.open}).length;c&&c.setAttribute("aria-disabled",String(e===f.length)),u&&u.setAttribute("aria-disabled",String(0===e))},m=function(e,t){e.open=t,e.trigger.setAttribute("aria-expanded",String(t)),e.el.classList.toggle("is-open",t),t&&(d=e)},v=function(t,n){var o=t.panel;gsap.killTweensOf([o,t.answer,t.iconBar]);var a=o.hasAttribute("hidden"),i=a?0:o.offsetHeight;o.removeAttribute("hidden");var c=gsap.timeline();return c.fromTo(o,{height:i},{height:"auto",duration:r(e),ease:n||"power2.out",clearProps:"height"},0),c.fromTo(t.answer,{opacity:a?0:gsap.getProperty(t.answer,"opacity"),y:a?6:gsap.getProperty(t.answer,"y")},{opacity:1,y:0,duration:r(.26),ease:"power2.out",clearProps:"opacity,transform"},r(.08)),t.iconBar&&c.to(t.iconBar,{rotation:90,duration:r(.3),ease:"power2.inOut"},0),c},b=function(e,t){var n=e.panel;gsap.killTweensOf([n,e.answer,e.iconBar]);var a=gsap.timeline();return n.hasAttribute("hidden")||(a.to(e.answer,{opacity:0,duration:r(.14),ease:"power1.out"},0),a.fromTo(n,{height:n.offsetHeight},{height:0,duration:r(.28),ease:t||"power2.inOut",onComplete:function(){e.open||(o(n),gsap.set(n,{clearProps:"height"}),gsap.set(e.answer,{clearProps:"opacity,transform"}))}},0),e.iconBar&&a.to(e.iconBar,{rotation:0,duration:r(.3),ease:"power2.inOut"},0)),a},y=function(e,t){var n=t.getBoundingClientRect().top,o=function(){var e=t.getBoundingClientRect().top-n;Math.abs(e)>=.5&&window.scrollBy(0,e)};e.eventCallback("onUpdate",o),e.eventCallback("onComplete",o)},w=function(e,t){if(t=t||{},!e.open){l&&(l.progress(1),l=null);var n=s()&&!t.keepOthers?f.filter(function(t){return t!==e&&t.open}):[];if(m(e,!0),g(e),n.length){var o=gsap.timeline({onComplete:function(){l=null}});n.forEach(function(e){m(e,!1),g(e),o.add(b(e,"power2.inOut"),0)}),o.add(v(e,"power2.inOut"),0),n.some(function(t){return t.el.compareDocumentPosition(e.el)&Node.DOCUMENT_POSITION_FOLLOWING})&&!1!==t.hold&&y(o,e.trigger),l=o}else v(e);h()}},E=function(e){e.open&&(m(e,!1),g(e),b(e),h())},A=function(e){e.open?E(e):w(e)},B=function(){f.forEach(function(e){e.open||w(e,{keepOthers:!0})})},O=function(){f.forEach(E)};if(f.forEach(function(e){m(e,e.open),e.open?e.panel.removeAttribute("hidden"):o(e.panel),e.iconBar&&gsap.set(e.iconBar,{rotation:e.open?90:0})}),s()){var k=f.filter(function(e){return e.open});k.slice(1).forEach(function(e){m(e,!1),o(e.panel),e.iconBar&&gsap.set(e.iconBar,{rotation:0})}),d=k[0]||null}h(),t.setAttribute("data-accordion-ready",""),f.forEach(function(e){i(e.trigger,"click",function(){A(e)}),i(e.panel,"beforematch",function(){gsap.killTweensOf([e.panel,e.answer,e.iconBar]),gsap.set(e.panel,{clearProps:"height"}),gsap.set(e.answer,{clearProps:"opacity,transform"}),e.iconBar&&gsap.set(e.iconBar,{rotation:90}),m(e,!0),g(e),h()})}),i(t,"keydown",function(e){var t=p(document.activeElement);if(t&&document.activeElement===t.trigger){var n=f.indexOf(t),o=null;"ArrowDown"===e.key?o=f[(n+1)%f.length]:"ArrowUp"===e.key?o=f[(n-1+f.length)%f.length]:"Home"===e.key?o=f[0]:"End"===e.key&&(o=f[f.length-1]),o&&(e.preventDefault(),o.trigger.focus())}}),c&&i(c,"click",function(){"true"!==c.getAttribute("aria-disabled")&&B()}),u&&i(u,"click",function(){"true"!==u.getAttribute("aria-disabled")&&O()});var S=new MutationObserver(function(){if(s()){var e=d&&d.open?d:f.filter(function(e){return e.open})[0];f.forEach(function(t){t!==e&&E(t)})}});S.observe(t,{attributes:!0,attributeFilter:["data-accordion-mode"]});var C=function(n){var o=decodeURIComponent(location.hash.slice(1));if(o){var a=document.getElementById(o),i=a&&t.contains(a)?p(a):null;if(i){w(i,{hold:!1});var c=function(){i.el.scrollIntoView({block:"start",behavior:r(1)?"smooth":"auto"}),n&&i.trigger.focus({preventScroll:!0})};gsap.delayedCall(r(e),c)}}};return C(!1),i(window,"hashchange",function(){C(!0)}),t.accordion={open:function(e){var t=p(document.getElementById(e));t&&w(t)},close:function(e){var t=p(document.getElementById(e));t&&E(t)},toggle:function(e){var t=p(document.getElementById(e));t&&A(t)},expandAll:B,collapseAll:O},function(){a.forEach(function(e){e()}),S.disconnect(),l&&l.progress(1),f.forEach(function(e){gsap.killTweensOf([e.panel,e.answer,e.iconBar]),gsap.set([e.panel,e.answer],{clearProps:"height,opacity,transform"}),e.open?e.panel.removeAttribute("hidden"):o(e.panel)}),delete t.accordion}}(t,a))}),function(){i.forEach(function(e){e()})}})});window.addEventListener("beforeunload",function(){r.revert()})}else document.documentElement.classList.remove("has-js")},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}();
* { box-sizing: border-box; margin: 0; padding: 0; }

/* ---------- Theme tokens ----------
   Two themes, one design: `light` and `dark`, selected with data-variant on
   <body> in this demo, or on the [data-accordion] root itself in your page.
   Every colour, radius and shadow the component and the demo ground use is a
   property here, so one attribute flips both. Text colours are solid on
   purpose: a translucent text colour cannot be contrast-checked. */
body,
body[data-variant="light"],
[data-accordion][data-variant="light"] {
  color-scheme: light;

  /* the ground */
  --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);
  --shadow: 0 1px 2px rgba(20, 20, 18, 0.06), 0 4px 12px rgba(20, 20, 18, 0.06);
  --font: 'Mona Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
  --radius: 10px;
  --radius-sm: 6px;

  /* the accordion */
  --accent: #2563eb;          /* focus ring, the open item's icon */
  --accent-ink: #ffffff;      /* text on the accent */
  --accent-text: #1d4fd7;     /* the accent as text: links (5.9:1 on --raised) */
  --item-hover: #f3f3f1;      /* soft row hover, one step off --raised */
  --icon: #676762;            /* a closed item's plus */
  --faq-scroll-offset: 64px;  /* space kept above an item a deep link scrolls to */
}

body[data-variant="dark"],
[data-accordion][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);
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);

  --accent: #2563eb;
  --accent-ink: #ffffff;
  --accent-text: #7ea6ff;     /* 7.0:1 on the dark --raised */
  --item-hover: #212121;
  --icon: #9a988f;
}

::selection { background: var(--accent); color: var(--accent-ink); }

/* ---------- The component ---------- */

.faq {
  width: 100%;
  font-family: var(--font);
  color: var(--ink);
}

/* The block owns its heading: an FAQ section is a heading, a line of context
   and the questions. Set the heading level to fit your page (h2 here with
   h3 questions is the usual choice inside an article). */
.faq-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 12px 24px;
  flex-wrap: wrap;
  margin-bottom: 16px;
}
.faq-head__text { flex: 1 1 320px; min-width: 0; }
.faq-title {
  font-size: 1.25rem;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -0.012em;
  color: var(--ink);
}
.faq-sub {
  margin-top: 4px;
  font-size: 0.875rem;
  line-height: 1.5;
  color: var(--ink-3);
}

.faq-bulk { display: flex; gap: 6px; flex: none; }
.faq-bulk__btn {
  appearance: none;
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background: var(--raised);
  color: var(--ink);
  font: inherit;
  font-size: 0.8125rem;
  font-weight: 500;
  cursor: pointer;
  transition: background-color 0.15s ease-out, color 0.15s ease-out;
}
.faq-bulk__btn[aria-disabled="true"] { color: var(--ink-3); cursor: default; }
.faq-bulk__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (hover: hover) {
  .faq-bulk__btn:not([aria-disabled="true"]):hover { background: var(--hover); }
}
/* Without the script the bulk buttons have nothing to drive. */
html:not(.has-js) .faq-bulk { display: none; }

/* The list: one raised surface, hairlines between questions. */
.faq-list {
  background: var(--raised);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  overflow: hidden;
}

.faq-item {
  border-top: 1px solid var(--line);
  scroll-margin-top: var(--faq-scroll-offset);
  transition: background-color 0.18s ease-out;
}
.faq-item:first-child { border-top: 0; }
@media (hover: hover) {
  .faq-item:hover { background: var(--item-hover); }
}

/* The heading only carries the level; the button inside it is the control. */
.faq-q { font: inherit; margin: 0; }

.faq-trigger {
  appearance: none;
  display: flex;
  align-items: center;
  gap: 16px;
  width: 100%;
  min-height: 52px;
  padding: 14px 18px;
  border: 0;
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 0.9375rem;
  font-weight: 500;
  line-height: 1.4;
  text-align: left;
  cursor: pointer;
}
.faq-trigger:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
  border-radius: var(--radius-sm);
}
.faq-trigger__text { flex: 1 1 auto; min-width: 0; }

/* Plus to minus: two strokes, the vertical one rotates flat onto the other.
   No CSS transform on either bar: GSAP owns the vertical bar's rotation, and
   a transform here would be read back as its starting angle. */
.faq-icon {
  position: relative;
  flex: none;
  width: 16px;
  height: 16px;
  color: var(--icon);
  transition: color 0.2s ease-out;
}
.faq-item.is-open .faq-icon { color: var(--accent); }
.faq-icon__bar {
  position: absolute;
  top: 7.25px;
  left: 2px;
  width: 12px;
  height: 1.5px;
  border-radius: 1px;
  background: currentColor;
}
.faq-icon__bar--v {
  top: 2px;
  left: 7.25px;
  width: 1.5px;
  height: 12px;
}
/* Without the script every answer is shown, so every icon reads as a minus. */
html:not(.has-js) .faq-icon__bar--v { opacity: 0; }

/* The panel is only a height clip: no padding or border of its own, so a
   closed panel under hidden="until-found" (which keeps a box) is zero tall.
   The spacing lives on the answer inside it. */
.faq-panel { overflow: hidden; }

/* Before the script has read the markup, closed answers are held shut so
   they never paint open for a frame. Without JavaScript (no .has-js) every
   answer stays readable. */
.has-js [data-accordion]:not([data-accordion-ready]) .faq-item:not(.is-open) .faq-panel { display: none; }

.faq-answer {
  padding: 0 52px 18px 18px;
  font-size: 0.875rem;
  line-height: 1.6;
  color: var(--ink-2);
}
.faq-answer > * + * { margin-top: 10px; }
.faq-answer p { max-width: 62ch; }
.faq-answer ul { padding-left: 18px; max-width: 62ch; }
.faq-answer li + li { margin-top: 6px; }
.faq-answer li::marker { color: var(--ink-3); }
.faq-answer strong { color: var(--ink); font-weight: 500; }
.faq-answer a {
  color: var(--accent-text);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
}
.faq-answer a:hover { text-decoration-thickness: 2px; }
.faq-answer a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }

@media (max-width: 480px) {
  .faq-trigger { padding: 14px 16px; gap: 12px; }
  .faq-answer { padding: 0 16px 16px; }
}

/* ---- demo furniture: the ground ------------------------------------------
   The page below is the demo, not part of the component. It is `.stage-*`
   only, the script never reads a stage class, and it lives here so
   thumbnail.html can reuse it. Delete it in your own project. The stage is
   top-aligned rather than centred on purpose: a vertically centred block
   moves its own top every time an answer changes height. */
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 */
}
.stage { display: grid; justify-items: center; padding: 48px 20px 72px; }
.stage__inner { width: 100%; max-width: 720px; }

@media (max-width: 480px) {
  .stage { padding: 24px 14px 48px; }
}

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

An accessible FAQ accordion: answers open to their natural height with the text rising a beat behind, a plus morphs into a minus, and in one-open mode the previous answer closes on the same timeline while the question you clicked stays still.

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

  • Height-to-auto open and close, with the inline height cleared afterwards so a panel never keeps a stale pixel size

  • The answer fades in and rises 6px a beat after its panel starts opening

  • A plus built from two strokes: the vertical one rotates flat to make a minus, and the icon takes the accent colour

  • One-open or many-open from one attribute, switchable at runtime; Expand all and Collapse all

  • In one-open mode the clicked question is held still on screen while an answer above it closes

  • Up, Down, Home and End move between questions; #faq-<id> links open and scroll to an item

  • Closed answers stay in the DOM behind hidden="until-found", so find-in-page still finds them

  • WAI-ARIA accordion roles: a button inside a heading, aria-expanded, aria-controls, and a region labelled by its button

Quick Start

1. Add to your <head>:

<link rel="stylesheet" href="assets/style.css">
<script>document.documentElement.classList.add('has-js')</script>

The one-line script adds .has-js before the page paints, so closed answers are held shut from the first frame. Without it they flash open until the script runs.

2. Add before the closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="assets/script.js"></script>

3. Copy the <section class="faq" data-accordion> block from index.html into your page.

One item is:

<div class="faq-item" id="faq-refunds" data-accordion-item>
  <h3 class="faq-q">
    <button class="faq-trigger" type="button" aria-expanded="false" data-accordion-trigger>
      <span class="faq-trigger__text">Can I get a refund?</span>
      <span class="faq-icon" aria-hidden="true">
        <span class="faq-icon__bar"></span>
        <span class="faq-icon__bar faq-icon__bar--v" data-accordion-icon-bar></span>
      </span>
    </button>
  </h3>
  <div class="faq-panel" data-accordion-panel>
    <div class="faq-answer" data-accordion-answer>
      <p>Annual plans cancelled within 14 days are refunded in full.</p>
    </div>
  </div>
</div>

The script looks for data- hooks only: [data-accordion] on the root, [data-accordion-item], [data-accordion-trigger], [data-accordion-panel], and the optional [data-accordion-answer] (what fades and rises; the panel itself if absent), [data-accordion-icon-bar], [data-accordion-expand] and [data-accordion-collapse]. Button and panel ids, aria-controls, role="region" and aria-labelledby are filled in from the item id if you leave them out, but writing them in the markup keeps it correct before the script runs.

Mark an item open by starting its button at aria-expanded="true" and giving the item is-open. Leave hidden off every panel in your markup: the script adds it to the closed ones, so a visitor without JavaScript still sees every answer.

Heading levels. The demo uses an h1 for the block and h2 for each question because the FAQ is the whole page. Inside an article, use the level that fits your outline, usually an h2 for "Frequently asked questions" and h3 for the questions. The script does not care which.

The 44px toolbar strip across the top of index.html (mode and theme buttons) and the .stage wrapper are demo furniture, not part of the component; leave them behind.

Options

Attribute / API Where What it does
data-accordion-mode="single" root One answer open at a time (the default when the attribute is missing)
data-accordion-mode="multiple" root Any number open. Changing the attribute at runtime back to single keeps the most recently opened answer and closes the rest
id="faq-<id>" item The deep-link target: #faq-<id> in the URL opens it on load and on hashchange, and scrolls to it
--faq-scroll-offset CSS Space left above an item a deep link scrolls to (a fixed header's height). Default 64px
accordion:toggle event on root Fires on every open and close with detail: { id, open }
root.accordion JS open(id), close(id), toggle(id), expandAll(), collapseAll()

Expand all opens every item in either mode; in one-open mode the next single question you open then closes the others. Each bulk button is marked aria-disabled="true" when it has nothing to do (all open, or none open).

Keyboard & Accessibility

Key What it does
Enter / Space Opens or closes the focused question
ArrowDown / ArrowUp Moves focus to the next / previous question, wrapping at the ends
Home / End Moves focus to the first / last question
Tab / Shift+Tab Moves through the bulk buttons, the questions and any links in open answers
  • Roles: this is the WAI-ARIA accordion pattern. Each question is a native <button> inside a heading, carrying aria-expanded and aria-controls; each answer is a role="region" with aria-labelledby pointing at its button, so a screen reader announces "Can I get a refund?, collapsed, button" and names the region when you enter it.
  • Closed answers carry hidden, so they are out of the tab order and the accessibility tree; a link inside a closed answer is never reached by Tab.
  • The bulk buttons use aria-disabled rather than disabled, so a keyboard user who presses Collapse all keeps focus on the button instead of being dropped to the top of the page.
  • A deep link that arrives by hashchange (a link in another answer, say) moves focus to that question's button, so keyboard and screen reader users land where they were sent.
  • Every interactive part has a :focus-visible ring in the accent. Hover styles sit behind @media (hover: hover), so nothing stays highlighted after a tap.
  • prefers-reduced-motion: reduce: every duration drops to zero, so answers open and close instantly with the same end states. Nothing is disabled.
  • Without JavaScript, every answer is shown and every icon reads as a minus.

Why answers stay in the DOM

Closed answers are hidden, never removed or emptied. Where the browser supports it (Chromium today), they are hidden with hidden="until-found" rather than plain hidden. The difference matters for an FAQ: a visitor who presses Ctrl+F or Cmd+F and types "refund" finds the text inside a closed answer, the browser opens it and scrolls to the match, and the accordion hears the beforematch event and updates the button, the icon and its own state to agree. Search engines read the answers too, since they are in the markup. Browsers without until-found treat the value as ordinary hidden, so nothing breaks; find-in-page simply does not see closed answers there. A find-in-page reveal leaves other open answers alone, even in one-open mode, so the browser's scroll to the match is never undone by a panel collapsing above it.

How It Works

Opening to auto height. Opening removes hidden, then gsap.fromTo tweens the panel's height from zero to 'auto' with clearProps: 'height', so the panel hands back to CSS the moment it lands and a later font or theme change can never leave it at a stale height. The answer inside is a separate tween from opacity: 0, y: 6 starting a beat later, so the text arrives into space that is already opening. Closing fades the answer, collapses the panel, and sets hidden in onComplete, guarded so an item reopened mid-close is not hidden under the reader. The panel has no padding or border of its own for the same reason: under until-found a hidden panel keeps a box, and only an unpadded one collapses to zero.

One open, no jump. In one-open mode the closing and opening tweens are added to one gsap.timeline at time zero with the same power2.inOut ease, so the page's height moves by the difference only, never growing and then shrinking. If the answer closing sits above the question you clicked, the timeline's onUpdate measures that question's position every frame and scrollBys the drift, which holds it under the pointer. Because it measures rather than predicts, it cooperates with the browser's own scroll anchoring instead of doubling it. A deep link opts out of the hold: there the reader asked to be taken somewhere.

The icon. Two 1.5px bars sit centred in a 16px box. GSAP rotates the vertical one to 90 degrees on open, flat onto the horizontal bar, and back to 0 on close; the colour change is a CSS transition keyed off .is-open. Neither bar has a CSS transform, because GSAP would read it back as the starting angle.

Interruption. Every open and close first calls gsap.killTweensOf on that item's panel, answer and icon bar and starts from the current height and opacity, so clicking a question again mid-tween reverses it smoothly. A pending one-open swap is completed before the next one starts.

Customisation

  • Timings are the constants at the top of assets/script.js: OPEN_DURATION, CLOSE_DURATION, ANSWER_DELAY and ANSWER_RISE.
  • The swap ease is 'power2.inOut' in open(); a lone open uses 'power2.out' in openTween().
  • Row height and padding are on .faq-trigger; the answer's measure is max-width: 62ch on .faq-answer p.
  • The soft hover is --item-hover; set it equal to --raised to turn it off.
  • For a separated-cards look instead of one list, move the border, radius and background from .faq-list to .faq-item and add a gap.

Themes

Two themes, light (the default) and dark: one design at two token values. Pick one with the attribute, on <body> or on the accordion root itself:

<section class="faq" data-accordion data-variant="dark">

Nothing else changes: same markup, same script, and nothing in assets/script.js reads the theme name. The demo's toolbar toggle and the ?variant=dark URL parameter only set that attribute on <body>.

Every colour, radius and shadow is a custom property in the two data-variant blocks at the top of assets/style.css:

Group Properties
Ground and ink --ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3
Hairlines --line, --line-strong
Accent --accent (focus ring, open icon), --accent-ink (text on it), --accent-text (links)
The accordion --item-hover, --icon, --faq-scroll-offset
Shape and type --font, --radius, --radius-sm, --shadow

To wear your own brand, re-value --accent and --accent-text (the accent as text, which needs 4.5:1 on --raised); to sit on your own surfaces, re-value --raised, --item-hover and --line. Keep text colours solid rather than translucent, or their contrast cannot be measured. The demo uses Mona Sans; --font takes any face.

Requirements

  • GSAP 3.12+ (core only, no plugins)
  • No build step, no framework
  • hidden="until-found" is a progressive enhancement: supported browsers get searchable closed answers, others get plain hidden

Your cart

Your cart is empty

The Vault £99

The Vault library, plus future additions to the library.