/* ── 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":"/vendor/gsap/index.js","gsap/ScrollTrigger":"/vendor/gsap/ScrollTrigger.js","gsap/SplitText":"/vendor/gsap/SplitText.js","gsap/Flip":"/vendor/gsap/Flip.js","gsap/ScrambleTextPlugin":"/vendor/gsap/ScrambleTextPlugin.js","gsap/TextPlugin":"/vendor/gsap/TextPlugin.js","gsap/all":"/vendor/gsap/all.js","gsap/":"/vendor/gsap/","lenis":"/vendor/lenis/dist/lenis.mjs","three":"/vendor/three/build/three.module.js","three/addons/":"/vendor/three/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>