Accordion & FAQ
Every site has an FAQ, and this is the interaction people most often get subtly wrong. You cannot CSS-transition to height: auto, so the usual workarounds are a hard-coded max-height that clips long answers, or a display: none toggle that just snaps. GSAP solves it properly: height: "auto" measures the natural height, animates to that number, then sets auto again so the panel stays fluid afterwards. Two consequences follow, and they are the real lesson. The measured height is a stale number the moment the text reflows, so the timeline must be invalidated on resize. And opening a panel changes the height of the whole page, which means every ScrollTrigger below it is now measured against a layout that no longer exists.
How to build
Interactions panel
Free plan gets you part of the way
The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.
How the two routes work →Plugins
Timeline · ScrollTrigger
Build time
35 min, including the accessibility.
No-code?
Yes, mostly — Webflow's Dropdown component and a Click interaction will open and close a panel. What the panel cannot do is call ScrollTrigger.refresh() afterwards, so on a page with scroll animations below the FAQ you need this anyway.
Webflow structure
element · class · attribute<b>Div</b> <i>faq_list</i> <b>Div</b> <i>faq_item</i> <b>Button</b> <i>faq_button</i> <u>aria-expanded="false" id="faq-0"</u> <b>Text</b> <i>faq_question</i> <b>Div</b> <i>faq_icon</i> <u>aria-hidden="true"</u> <b>Div</b> <i>faq_icon-bar</i> ← horizontal, 15 × 1.5px <b>Div</b> <i>faq_icon-bar is-v</i> ← vertical, 1.5 × 15px <b>Div</b> <i>faq_panel</i> <u>role="region" aria-labelledby="faq-0"</u> <b>Rich Text</b> <i>faq_body</i> <b>Div</b> <i>faq_item</i> … (or one Collection Item, CMS-driven)
Styles to set in the Designer
faq_panel | <code>height: 0; overflow: hidden;</code> — set in the Designer, so the panel is closed even before JavaScript runs. faq_button | A real <code><button></code>, full width, <code>text-align: left</code>. Using a div costs you Enter and Space for free, and you will have to re-add them by hand. faq_button:focus-visible | A visible focus ring with <code>outline-offset</code>. This is the single most commonly failed item in an accessibility audit of an FAQ. faq_body | Put the bottom padding <strong>here</strong>, not on <code>faq_panel</code>. Padding on the animated element is included in the height and makes the close feel like it snaps at the end. faq_icon | <code>position: relative</code>, 15×15. Two absolutely-centred bars — the vertical one scales to 0 while the whole icon rotates 180°. Rotate 90° instead and the surviving horizontal bar stands on end — you get a pipe, not a minus.
Building it with the Interactions panel
no code, free plan- Use Webflow's Dropdown component for each faq_item instead of a plain Div — it opens and closes to its natural height on its own, with no property tween to build.
- Wire the icon by hand: select faq_icon → Interactions → trigger Click on faq_button → Toggle → Rotate 0° → 180°, duration 0.35s, Ease Power2 InOut.
- Give faq_body the same Click (toggle) trigger: From Opacity 0 / Move Y 10px, To Opacity 100 / Move Y 0, duration 0.3–0.4s, so the answer fades up rather than just appearing.
- Turn on conditional playback → No animation for reduced motion on every interaction here.
What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.
- The Dropdown component never calls ScrollTrigger.refresh() when it finishes opening. On a page with any scroll animation below the FAQ, opening a panel changes the page height and every trigger under it is measured against a layout that no longer exists — that one line only exists in the script.
See “The script” below for the exact code.
Custom attributes
Element settings → Custom attributesfaq_button | aria-expanded | false | The script flips it. Screen readers announce “collapsed / expanded” from this and nothing else. faq_panel | role / aria-labelledby | region / faq-0 | Ties the answer back to its question, so a screen reader user knows which panel they landed in.
CSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; const items = gsap.utils.toArray(".faq_item"); const tls = []; let open = -1; // single-open. Drop this for multi-open. items.forEach((item, i) => { const btn = item.querySelector(".faq_button"); const panel = item.querySelector(".faq_panel"); const body = item.querySelector(".faq_body"); const icon = item.querySelector(".faq_icon"); const bar = item.querySelector(".is-v"); const tl = gsap.timeline({ paused: true, defaults: { duration: reduced ? 0 : 0.45, ease: "power3.inOut" }, // The page is now taller or shorter than when every trigger below // this FAQ was measured. Miss this and they all fire at the wrong // scroll position for the rest of the session. onComplete: () => ScrollTrigger.refresh(), onReverseComplete: () => ScrollTrigger.refresh() }) .to(panel, { height: "auto" }, 0) // GSAP measures it; CSS cannot .to(bar, { scaleY: 0 }, 0) // plus becomes minus .to(icon, { rotate: 180 }, 0) .from(body, { opacity: 0, y: 10, duration: reduced ? 0 : 0.4 }, 0.08); tls.push(tl); btn.addEventListener("click", () => { const isOpen = open === i; if (open > -1 && open !== i) { // close the other one tls[open].timeScale(1.35).reverse(); items[open].querySelector(".faq_button").setAttribute("aria-expanded", "false"); } isOpen ? tl.timeScale(1.35).reverse() : tl.timeScale(1).play(); open = isOpen ? -1 : i; btn.setAttribute("aria-expanded", String(!isOpen)); }); }); // A recorded "auto" height is just a number, and it is wrong as soon as the // text reflows. invalidate() forces a re-measure on the next play. addEventListener("resize", () => tls.forEach(tl => !tl.isActive() && tl.invalidate())); }); </script>
Gotchas that will cost you an hour
- Call ScrollTrigger.refresh() when a panel finishes moving. Opening an answer adds 200px to the page, so every reveal, pin and parallax below the FAQ is now measured against a layout that no longer exists. This is the bug people report as “the animations lower down stopped working”.
- invalidate() on resize. GSAP stores the measured auto-height as a number. Narrow the window so a three-line answer becomes five, and the panel opens to the old height and clips the last two lines.
- Padding on the body, not on the panel. Padding on the animated element counts toward its height, so the close animation appears to stall and then jump at the very end.
- Use a real <button>. A div gives you no keyboard, no focus ring and no role. Webflow will let you build it either way; only one of them passes an audit.
- Close faster than you open — timeScale(1.35). The user has already decided; matching the open duration feels like the page is arguing with them.
- Do not animate display. Toggling display forces a full layout pass and cancels the tween mid-flight, so you get a flash instead of a slide.
Push it further
- Deep-link an answer: read location.hash on load, open that item and scroll to it. Support pages live and die on this.
- Swap height for grid-template-rows: 0fr → 1fr on a grid wrapper — cheaper, no measurement, no invalidate. Check your browser support floor first.
- Add arrow-key navigation between the buttons. It is fifteen lines and it turns a passable FAQ into one that a keyboard user actually enjoys.
Webflow Motion Lab — GSAP 3.15, all plugins free.
4
/ 30