# FAQ Accordion

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

Canonical: https://gsapvault.com/ui-elements/faq-disclosure-accordion
Live demo: https://gsapvault.com/demos/faq-disclosure-accordion/index.html

| Property | Value |
|----------|-------|
| Type | ui-element |
| Tier | free |
| Price | Free |
| Difficulty | intermediate |
| Plugins | Core GSAP only |
| Techniques | click-toggle, state-transition, micro-interaction, keyboard-navigation, focus-management |
| Uses Lenis | No |

## Lighthouse, as measured

Google Lighthouse on the demo, 15 September 2026. A measurement of the demo as shipped, not a promise for your page.

| Category | Score |
|----------|-------|
| Accessibility | 100 |
| Best practices | 100 |

No performance score is published: it depends on the deployment (server compression, caching, CDN, connection and device) rather than on the code, so measure it where it will live.

## Overview

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.

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.

## Features

- 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

## 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

## Vibe-Code Ready Setup

This UI element includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

### 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.

## Documentation

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>`:**

```html
<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:**

```html
<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:

```html
<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 `scrollBy`s 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:

```html
<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`

## Source Code

This UI element is free. The complete source is included below.

### index.html

```html
<!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>
```

### assets/style.css

```css
* { 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; }
}
```

### assets/script.js

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

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
