Patterns Easy
GSAP Accordion
Three accordion variants (exclusive, multi-open, minimal) using GSAP height:'auto' animation. Demonstrates smooth open/close without CSS max-height artifacts, arrow rotation spring ease, and aria attributes.
Open in Lab
MCP
gsap accordion height-auto a11y
Targets: JS HTML
Code
/* โโ Demo 50: GSAP Accordion โโ */
/* Clean light-ish neutral palette โ distinct from all existing dark demos */
:root {
--bg: #f8f7f4;
--white: #ffffff;
--panel: #f0ede8;
--border: #d8d4cc;
--text: #1a1814;
--muted: #7a756c;
--accent: #2d5be3; /* electric blue */
--accent-warm: #e05a20;
--green: #1a7a40;
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "DM Sans", -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
line-height: 1.6;
}
.label {
display: block;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 1rem;
}
code {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 0.78rem;
color: var(--accent);
background: rgba(45, 91, 227, 0.08);
padding: 0.15em 0.4em;
border-radius: 3px;
}
/* โโ Page layout โโ */
.page {
max-width: 820px;
margin: 0 auto;
padding: 5rem 2rem 8rem;
}
.page-header {
margin-bottom: 5rem;
padding-bottom: 3rem;
border-bottom: 1px solid var(--border);
}
.page-header h1 {
font-size: clamp(3rem, 7vw, 5.5rem);
font-weight: 700;
line-height: 1.0;
letter-spacing: -0.03em;
margin-bottom: 1.5rem;
color: var(--text);
}
.page-header h1 em {
font-style: normal;
color: var(--accent);
}
.page-header p {
font-size: 1rem;
color: var(--muted);
line-height: 1.8;
max-width: 540px;
}
/* โโ Demo sections โโ */
.demo-section {
margin-bottom: 4.5rem;
}
.demo-label {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 1.25rem;
}
.variant-tag {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 0.2rem 0.6rem;
background: var(--accent);
color: white;
border-radius: 2px;
}
.demo-label h2 {
font-size: 1.05rem;
font-weight: 500;
color: var(--muted);
}
/* โโ Accordion base โโ */
.accordion {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: var(--white);
}
.acc-item {
border-bottom: 1px solid var(--border);
}
.acc-item:last-child {
border-bottom: none;
}
/* โโ Trigger โโ */
.acc-trigger {
width: 100%;
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem 1.5rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--text);
font-family: "DM Sans", sans-serif;
font-size: 0.95rem;
font-weight: 500;
transition: background 0.2s;
}
.acc-trigger:hover {
background: var(--panel);
}
.at-icon {
color: var(--accent);
font-size: 0.5rem;
flex-shrink: 0;
transition: color 0.3s;
}
.at-num {
font-size: 0.72rem;
font-weight: 700;
color: var(--muted);
letter-spacing: 0.06em;
flex-shrink: 0;
width: 24px;
}
.at-title {
flex: 1;
}
.at-arrow {
flex-shrink: 0;
color: var(--muted);
font-size: 0.9rem;
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Active state */
.acc-item[data-open="true"] .acc-trigger,
.acc-item.acc-item--open .acc-trigger {
background: var(--panel);
}
.acc-item[data-open="true"] .at-icon,
.acc-item.acc-item--open .at-icon {
color: var(--accent-warm);
}
.acc-item[data-open="true"] .at-arrow,
.acc-item.is-open .at-arrow {
transform: rotate(180deg);
}
/* โโ Body โโ */
.acc-body {
overflow: hidden;
height: 0;
}
.acc-item[data-open="true"] .acc-body,
.acc-item.acc-item--open .acc-body {
height: auto;
}
.acc-inner {
padding: 0 1.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.acc-inner p {
font-size: 0.9rem;
color: var(--muted);
line-height: 1.8;
}
/* Timeline variant */
.acc-inner--timeline {
padding-top: 0.5rem;
}
.tl-item {
display: grid;
grid-template-columns: 90px 1fr;
gap: 1rem;
padding: 0.7rem 0;
border-bottom: 1px solid var(--panel);
}
.tl-item:last-child {
border-bottom: none;
}
.tl-week {
font-size: 0.72rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.04em;
padding-top: 2px;
}
.tl-task {
font-size: 0.88rem;
color: var(--text);
}
/* โโ Minimal variant โโ */
.accordion--minimal {
border: none;
border-radius: 0;
border-top: 2px solid var(--text);
}
.accordion--minimal .acc-item {
border-bottom: 1px solid var(--border);
}
.accordion--minimal .acc-trigger {
padding: 1.25rem 0;
font-size: 1.05rem;
font-weight: 700;
}
.accordion--minimal .acc-trigger:hover {
background: transparent;
color: var(--accent);
}
.accordion--minimal .acc-trigger:hover .at-arrow {
color: var(--accent);
}
.accordion--minimal .at-arrow {
font-size: 1.2rem;
font-weight: 300;
color: var(--text);
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), color 0.2s;
}
.accordion--minimal .is-open .at-arrow {
transform: rotate(45deg);
}
.accordion--minimal .acc-inner {
padding-left: 0;
}
/* โโ Responsive โโ */
@media (max-width: 640px) {
.page {
padding: 3rem 1.25rem 5rem;
}
.demo-label {
flex-direction: column;
gap: 0.5rem;
}
}
/* โโ Reduced motion โโ */
html.reduced-motion .acc-trigger,
html.reduced-motion .at-arrow {
transition: none !important;
}if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
initDemoShell({
title: "GSAP Accordion",
category: "css-canvas",
tech: ["gsap", "height-auto", "spring-ease", "a11y"],
});
const reduced = prefersReducedMotion();
if (reduced) document.documentElement.classList.add("reduced-motion");
// โโ Core accordion logic โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// duration and ease shared across all variants
const DURATION = reduced ? 0 : 0.45;
const EASE_OPEN = "expo.out";
const EASE_CLOSE = "expo.in";
/**
* Open a panel
* @param {HTMLElement} item - .acc-item
* @param {boolean} instant - skip animation (reduced motion)
*/
function openPanel(item, instant = false) {
const body = item.querySelector(".acc-body");
const trigger = item.querySelector(".acc-trigger");
const arrow = trigger.querySelector(".at-arrow");
item.classList.add("is-open");
trigger.setAttribute("aria-expanded", "true");
if (instant) {
gsap.set(body, { height: "auto", overflow: "hidden" });
} else {
gsap.to(body, {
height: "auto",
duration: DURATION,
ease: EASE_OPEN,
overwrite: true,
onStart: () => {
body.style.overflow = "hidden";
},
});
gsap.to(arrow, {
rotation: 180,
duration: DURATION * 0.8,
ease: "back.out(2)",
overwrite: true,
});
}
}
/**
* Close a panel
* @param {HTMLElement} item - .acc-item
* @param {boolean} instant - skip animation
*/
function closePanel(item, instant = false) {
const body = item.querySelector(".acc-body");
const trigger = item.querySelector(".acc-trigger");
const arrow = trigger.querySelector(".at-arrow");
item.classList.remove("is-open");
trigger.setAttribute("aria-expanded", "false");
if (instant) {
gsap.set(body, { height: 0 });
} else {
gsap.to(body, {
height: 0,
duration: DURATION * 0.8,
ease: EASE_CLOSE,
overwrite: true,
});
gsap.to(arrow, {
rotation: 0,
duration: DURATION * 0.6,
ease: "power2.in",
overwrite: true,
});
}
}
// โโ Initialize each accordion โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
document.querySelectorAll(".accordion").forEach((accordion) => {
const mode = accordion.dataset.mode; // 'exclusive' | 'multi'
const items = accordion.querySelectorAll(".acc-item");
// Set initial state โ items marked open start open, rest start closed
items.forEach((item) => {
const body = item.querySelector(".acc-body");
const isInitiallyOpen =
item.classList.contains("acc-item--open") || item.hasAttribute("data-open");
if (isInitiallyOpen) {
gsap.set(body, { height: "auto", overflow: "hidden" });
item.classList.add("is-open");
const trigger = item.querySelector(".acc-trigger");
const arrow = trigger.querySelector(".at-arrow");
if (arrow) gsap.set(arrow, { rotation: 180 });
trigger.setAttribute("aria-expanded", "true");
} else {
gsap.set(body, { height: 0, overflow: "hidden" });
}
// Click handler
item.querySelector(".acc-trigger").addEventListener("click", () => {
const isOpen = item.classList.contains("is-open");
if (mode === "exclusive") {
// Close all other open items first
items.forEach((other) => {
if (other !== item && other.classList.contains("is-open")) {
closePanel(other, reduced);
}
});
}
if (isOpen) {
closePanel(item, reduced);
} else {
openPanel(item, reduced);
}
});
});
});
// โโ Minimal variant: arrow rotates to + / ร โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// The minimal accordion uses '+' which rotates to 'ร' on open
// This is already handled by the rotation tween in openPanel/closePanel
// โโ Entrance animations โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (!reduced) {
gsap.set(".page-header > *", { opacity: 0, y: 16 });
gsap.to(".page-header > *", {
opacity: 1,
y: 0,
duration: 0.7,
stagger: 0.1,
ease: "expo.out",
delay: 0.3,
});
document.querySelectorAll(".demo-section").forEach((sec, i) => {
gsap.set(sec, { opacity: 0, y: 24 });
gsap.to(sec, {
opacity: 1,
y: 0,
duration: 0.8,
ease: "expo.out",
scrollTrigger: {
trigger: sec,
start: "top 78%",
toggleActions: "play none none reverse",
},
});
});
}
// โโ Motion toggle โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
window.addEventListener("motion-preference", (e) => {
gsap.globalTimeline.paused(e.detail.reduced);
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GSAP Accordion</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<script type="importmap">{"imports":{"gsap":"https://esm.sh/gsap@3.13.0","gsap/ScrollTrigger":"https://esm.sh/gsap@3.13.0/ScrollTrigger","gsap/SplitText":"https://esm.sh/gsap@3.13.0/SplitText","gsap/Flip":"https://esm.sh/gsap@3.13.0/Flip","gsap/ScrambleTextPlugin":"https://esm.sh/gsap@3.13.0/ScrambleTextPlugin","gsap/TextPlugin":"https://esm.sh/gsap@3.13.0/TextPlugin","gsap/all":"https://esm.sh/gsap@3.13.0/all","gsap/":"https://esm.sh/gsap@3.13.0/","lenis":"https://esm.sh/lenis@1.1.13/dist/lenis.mjs","three":"https://esm.sh/three@0.171.0","three/addons/":"https://esm.sh/three@0.171.0/examples/jsm/"}}</script>
</head>
<body>
<div class="page">
<header class="page-header">
<span class="label">Technique Demo</span>
<h1>GSAP <em>Accordion</em></h1>
<p>Height animation from <code>0 โ auto</code> using GSAP's height tweening. Each panel collapses and expands with spring easing. Multiple variants: exclusive, multi-open, and nested.</p>
</header>
<!-- Variant 1: Exclusive (one open at a time) -->
<section class="demo-section">
<div class="demo-label">
<span class="variant-tag">Variant A</span>
<h2>Exclusive โ one open at a time</h2>
</div>
<div class="accordion" id="acc-exclusive" data-mode="exclusive">
<div class="acc-item" data-open="true">
<button class="acc-trigger" aria-expanded="true">
<span class="at-icon">โ</span>
<span class="at-title">What is GSAP accordion animation?</span>
<span class="at-arrow">โ</span>
</button>
<div class="acc-body">
<div class="acc-inner">
<p>A GSAP accordion animates panel height from <code>0</code> to its natural content height using <code>gsap.to(el, { height: 'auto' })</code>. Unlike CSS transitions which can't animate from <code>height: 0</code> to <code>height: auto</code>, GSAP handles this seamlessly by calculating the target height internally.</p>
<p>The key is pairing <code>overflow: hidden</code> on the container with a GSAP tween that toggles <code>autoAlpha</code> and <code>height</code> together for a smooth reveal.</p>
</div>
</div>
</div>
<div class="acc-item">
<button class="acc-trigger" aria-expanded="false">
<span class="at-icon">โ</span>
<span class="at-title">How do you animate height: auto?</span>
<span class="at-arrow">โ</span>
</button>
<div class="acc-body">
<div class="acc-inner">
<p>GSAP's <code>gsap.to(el, { height: 'auto', duration: 0.5 })</code> works by measuring the element's scrollHeight at tween start, then animating from current to that value. Combine with <code>ease: 'expo.out'</code> for a natural deceleration.</p>
<p>To collapse, use <code>gsap.to(el, { height: 0 })</code> โ GSAP handles the measurement automatically.</p>
</div>
</div>
</div>
<div class="acc-item">
<button class="acc-trigger" aria-expanded="false">
<span class="at-icon">โ</span>
<span class="at-title">Why not use CSS max-height trick?</span>
<span class="at-arrow">โ</span>
</button>
<div class="acc-body">
<div class="acc-inner">
<p>The CSS <code>max-height</code> trick requires setting a large arbitrary maximum (e.g., <code>max-height: 1000px</code>), which causes easing to feel wrong โ the element accelerates until it hits the actual content height, then stops abruptly.</p>
<p>GSAP measures the real target height and creates a precise, spring-eased animation with no easing artifacts.</p>
</div>
</div>
</div>
<div class="acc-item">
<button class="acc-trigger" aria-expanded="false">
<span class="at-icon">โ</span>
<span class="at-title">Accessibility considerations</span>
<span class="at-arrow">โ</span>
</button>
<div class="acc-body">
<div class="acc-inner">
<p>Always set <code>aria-expanded</code> on the trigger button and toggle it on state change. The panel body should have a matching <code>aria-hidden</code> attribute. Screen readers need these attributes to announce the expanded/collapsed state correctly.</p>
<p>For <code>prefers-reduced-motion</code>, skip the tween and immediately set the final state using <code>gsap.set()</code>.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Variant 2: Multi-open -->
<section class="demo-section">
<div class="demo-label">
<span class="variant-tag">Variant B</span>
<h2>Multi-open โ independent panels</h2>
</div>
<div class="accordion accordion--open" id="acc-multi" data-mode="multi">
<div class="acc-item acc-item--open">
<button class="acc-trigger" aria-expanded="true">
<span class="at-num">01</span>
<span class="at-title">Research & Discovery</span>
<span class="at-arrow">โ</span>
</button>
<div class="acc-body">
<div class="acc-inner acc-inner--timeline">
<div class="tl-item">
<span class="tl-week">Week 1โ2</span>
<span class="tl-task">Stakeholder interviews + competitive analysis</span>
</div>
<div class="tl-item">
<span class="tl-week">Week 3</span>
<span class="tl-task">User research sessions + affinity mapping</span>
</div>
<div class="tl-item">
<span class="tl-week">Week 4</span>
<span class="tl-task">Synthesis + problem statement</span>
</div>
</div>
</div>
</div>
<div class="acc-item">
<button class="acc-trigger" aria-expanded="false">
<span class="at-num">02</span>
<span class="at-title">Design & Prototyping</span>
<span class="at-arrow">โ</span>
</button>
<div class="acc-body">
<div class="acc-inner acc-inner--timeline">
<div class="tl-item">
<span class="tl-week">Week 5โ6</span>
<span class="tl-task">Wireframes + IA architecture</span>
</div>
<div class="tl-item">
<span class="tl-week">Week 7</span>
<span class="tl-task">High-fidelity mockups + design system</span>
</div>
<div class="tl-item">
<span class="tl-week">Week 8</span>
<span class="tl-task">Interactive prototype</span>
</div>
</div>
</div>
</div>
<div class="acc-item">
<button class="acc-trigger" aria-expanded="false">
<span class="at-num">03</span>
<span class="at-title">Development & Launch</span>
<span class="at-arrow">โ</span>
</button>
<div class="acc-body">
<div class="acc-inner acc-inner--timeline">
<div class="tl-item">
<span class="tl-week">Week 9โ12</span>
<span class="tl-task">Frontend development + integration</span>
</div>
<div class="tl-item">
<span class="tl-week">Week 13</span>
<span class="tl-task">QA + performance testing</span>
</div>
<div class="tl-item">
<span class="tl-week">Week 14</span>
<span class="tl-task">Launch + monitoring</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Variant 3: Minimal / Borderless -->
<section class="demo-section">
<div class="demo-label">
<span class="variant-tag">Variant C</span>
<h2>Minimal line style</h2>
</div>
<div class="accordion accordion--minimal" id="acc-minimal" data-mode="exclusive">
<div class="acc-item">
<button class="acc-trigger" aria-expanded="false">
<span class="at-title">Typography</span>
<span class="at-arrow">+</span>
</button>
<div class="acc-body">
<div class="acc-inner">
<p>Variable fonts, optical sizing, fluid type scales using <code>clamp()</code>, and responsive line-height that adapts to reading distance on different devices.</p>
</div>
</div>
</div>
<div class="acc-item">
<button class="acc-trigger" aria-expanded="false">
<span class="at-title">Color Systems</span>
<span class="at-arrow">+</span>
</button>
<div class="acc-body">
<div class="acc-inner">
<p>Semantic color tokens, dark/light mode with CSS custom properties, contrast checking with WCAG 2.1 AAA, and perceptual color spaces (oklch).</p>
</div>
</div>
</div>
<div class="acc-item">
<button class="acc-trigger" aria-expanded="false">
<span class="at-title">Motion Design</span>
<span class="at-arrow">+</span>
</button>
<div class="acc-body">
<div class="acc-inner">
<p>Timing functions based on physics (spring, expo, elastic), duration scaling by distance, and choreographed stagger patterns that feel natural.</p>
</div>
</div>
</div>
<div class="acc-item">
<button class="acc-trigger" aria-expanded="false">
<span class="at-title">Spacing & Layout</span>
<span class="at-arrow">+</span>
</button>
<div class="acc-body">
<div class="acc-inner">
<p>8-point grid systems, intrinsic web design with CSS Grid subgrid, aspect-ratio locking, and content-driven breakpoints rather than device-based.</p>
</div>
</div>
</div>
</div>
</section>
</div>
<script type="module" src="script.js"></script>
</body>
</html>GSAP Accordion
Three accordion variants (exclusive, multi-open, minimal) using GSAP height:โautoโ animation. Demonstrates smooth open/close without CSS max-height artifacts, arrow rotation spring ease, and aria attributes.
Source
- Repository:
libs-genclaude - Original demo id:
50-gsap-accordion
Notes
Three accordion variants (exclusive, multi-open, minimal) using GSAP height:โautoโ animation. Demonstrates smooth open/close without CSS max-height artifacts, arrow rotation spring ease, and aria attributes.