# Expanding Feature Pills

> Compact feature pills that expand to reveal detail on interaction — great for benefits and pricing highlights.

**Definition:** Expanding Feature Pills is a components UI animation from collections.html. It is built with HTML, CSS, JavaScript, and GSAP, ships with a live preview, and is free to copy into personal and commercial projects.

## Facts
- Category: Components
- Stack: HTML, CSS, JavaScript, and GSAP
- Added: 2026-08-01
- Canonical page: https://html.bosconvertpro.com/collection/expanding-feature-pills
- Full-page preview: https://html.bosconvertpro.com/collection/expanding-feature-pills/full
- Live demo: https://html.bosconvertpro.com/demos/expanding-feature-pills/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/expanding-feature-pills/index.md
- License: https://html.bosconvertpro.com/terms

## How to use
1. Open https://html.bosconvertpro.com/collection/expanding-feature-pills/full to preview the animation in full screen.
2. Copy index.html, styles.css, and script.js from the code tabs on https://html.bosconvertpro.com/collection/expanding-feature-pills.
3. Paste the files into your project and adjust timing, easing, and content as needed.

## FAQ
### What is Expanding Feature Pills?

Expanding Feature Pills is a components UI animation from collections.html. Compact feature pills that expand to reveal detail on interaction — great for benefits and pricing highlights.

### What is Expanding Feature Pills built with?

Expanding Feature Pills is built with HTML, CSS, JavaScript, and GSAP. The source for every file is shown on the animation page and can be copied straight into your project.

### How do I use Expanding Feature Pills in my project?

Open the animation page (https://html.bosconvertpro.com/collection/expanding-feature-pills), preview it live, then copy index.html, styles.css, and script.js from the code tabs. Paste the files into your project and adjust timing, easing, and content as needed. A standalone demo runs at https://html.bosconvertpro.com/demos/expanding-feature-pills/index.html.

### Is Expanding Feature Pills free to use?

Yes. The Expanding Feature Pills demo code may be used in personal and commercial projects. You may not resell or redistribute the collection itself as a competing template pack. Full terms: https://html.bosconvertpro.com/terms.

## Source — index.html
````html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Expanding Feature Pills — Aether Audio</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link rel="preconnect" href="https://images.unsplash.com" />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <main class="page">
      <!--
        Expanding Feature Pills
        Accordion feature list with a synced media crossfade.
        Configure timing on the root via data-duration / data-ease / data-content-ease.
      -->
      <section
        class="feature-pills"
        data-feature-pills
        data-duration="0.55"
        data-ease="power3.inOut"
        data-content-ease="power2.out"
        aria-label="Aether Audio product features"
      >
        <div class="feature-pills__layout">
          <!-- Content column -->
          <div class="feature-pills__content">
            <header class="feature-pills__header">
              <p class="feature-pills__eyebrow">Aether Audio</p>
              <h1 class="feature-pills__title">Engineered for the way you listen</h1>
            </header>

            <div class="feature-pills__list" data-feature-list role="list">
              <article class="feature-pills__item" data-feature-item role="listitem">
                <button type="button" class="feature-pills__trigger" data-feature-trigger aria-expanded="false">
                  <span class="feature-pills__label">Spatial immersion</span>
                  <span class="feature-pills__icon" aria-hidden="true">
                    <span class="feature-pills__icon-line feature-pills__icon-line--horizontal"></span>
                    <span class="feature-pills__icon-line feature-pills__icon-line--vertical" data-feature-icon-bar></span>
                  </span>
                </button>
                <div class="feature-pills__panel" data-feature-panel role="region" aria-hidden="true" inert>
                  <div class="feature-pills__panel-inner" data-feature-panel-inner>
                    <p class="feature-pills__description">
                      3D audio maps instruments around you so every mix feels
                      wider, deeper, and unmistakably present.
                    </p>
                  </div>
                </div>
              </article>

              <article class="feature-pills__item" data-feature-item role="listitem">
                <button type="button" class="feature-pills__trigger" data-feature-trigger aria-expanded="false">
                  <span class="feature-pills__label">Adaptive silence</span>
                  <span class="feature-pills__icon" aria-hidden="true">
                    <span class="feature-pills__icon-line feature-pills__icon-line--horizontal"></span>
                    <span class="feature-pills__icon-line feature-pills__icon-line--vertical" data-feature-icon-bar></span>
                  </span>
                </button>
                <div class="feature-pills__panel" data-feature-panel role="region" aria-hidden="true" inert>
                  <div class="feature-pills__panel-inner" data-feature-panel-inner>
                    <p class="feature-pills__description">
                      Real-time noise shaping quiets the world without the
                      hollow vacuum of traditional cancellation.
                    </p>
                  </div>
                </div>
              </article>

              <article
                class="feature-pills__item is-active"
                data-feature-item
                data-active
                role="listitem"
              >
                <button type="button" class="feature-pills__trigger" data-feature-trigger aria-expanded="true">
                  <span class="feature-pills__label">Cloud-light comfort</span>
                  <span class="feature-pills__icon" aria-hidden="true">
                    <span class="feature-pills__icon-line feature-pills__icon-line--horizontal"></span>
                    <span class="feature-pills__icon-line feature-pills__icon-line--vertical" data-feature-icon-bar></span>
                  </span>
                </button>
                <div class="feature-pills__panel" data-feature-panel role="region" aria-hidden="false">
                  <div class="feature-pills__panel-inner" data-feature-panel-inner>
                    <p class="feature-pills__description">
                      Memory-foam cushions and a balanced clamp keep pressure
                      off your temples — built for marathon sessions.
                    </p>
                  </div>
                </div>
              </article>

              <article class="feature-pills__item" data-feature-item role="listitem">
                <button type="button" class="feature-pills__trigger" data-feature-trigger aria-expanded="false">
                  <span class="feature-pills__label">Studio-grade fidelity</span>
                  <span class="feature-pills__icon" aria-hidden="true">
                    <span class="feature-pills__icon-line feature-pills__icon-line--horizontal"></span>
                    <span class="feature-pills__icon-line feature-pills__icon-line--vertical" data-feature-icon-bar></span>
                  </span>
                </button>
                <div class="feature-pills__panel" data-feature-panel role="region" aria-hidden="true" inert>
                  <div class="feature-pills__panel-inner" data-feature-panel-inner>
                    <p class="feature-pills__description">
                      Custom 40mm drivers and a tuned acoustic chamber reveal
                      detail from sub-bass to crystalline highs.
                    </p>
                  </div>
                </div>
              </article>

              <article class="feature-pills__item" data-feature-item role="listitem">
                <button type="button" class="feature-pills__trigger" data-feature-trigger aria-expanded="false">
                  <span class="feature-pills__label">Seamless handoff</span>
                  <span class="feature-pills__icon" aria-hidden="true">
                    <span class="feature-pills__icon-line feature-pills__icon-line--horizontal"></span>
                    <span class="feature-pills__icon-line feature-pills__icon-line--vertical" data-feature-icon-bar></span>
                  </span>
                </button>
                <div class="feature-pills__panel" data-feature-panel role="region" aria-hidden="true" inert>
                  <div class="feature-pills__panel-inner" data-feature-panel-inner>
                    <p class="feature-pills__description">
                      Multipoint Bluetooth jumps between laptop and phone
                      without a beat missed or a menu opened.
                    </p>
                  </div>
                </div>
              </article>

              <article class="feature-pills__item" data-feature-item role="listitem">
                <button type="button" class="feature-pills__trigger" data-feature-trigger aria-expanded="false">
                  <span class="feature-pills__label">Weather-ready build</span>
                  <span class="feature-pills__icon" aria-hidden="true">
                    <span class="feature-pills__icon-line feature-pills__icon-line--horizontal"></span>
                    <span class="feature-pills__icon-line feature-pills__icon-line--vertical" data-feature-icon-bar></span>
                  </span>
                </button>
                <div class="feature-pills__panel" data-feature-panel role="region" aria-hidden="true" inert>
                  <div class="feature-pills__panel-inner" data-feature-panel-inner>
                    <p class="feature-pills__description">
                      IPX4 sealing and corrosion-resistant hardware shrug off
                      drizzle, sweat, and commute chaos.
                    </p>
                  </div>
                </div>
              </article>
            </div>
          </div>

          <!-- Media column -->
          <div class="feature-pills__media" data-feature-media aria-hidden="true">
            <div class="feature-pills__media-stack" data-feature-media-stack>
              <figure class="feature-pills__media-item" data-feature-media-item>
                <img
                  class="feature-pills__media-image"
                  src="https://images.unsplash.com/photo-1618366712010-f4ae9c647dcb?auto=format&fit=crop&w=1600&q=85"
                  alt=""
                  width="1600"
                  height="1200"
                  draggable="false"
                  loading="eager"
                />
              </figure>
              <figure class="feature-pills__media-item" data-feature-media-item>
                <img
                  class="feature-pills__media-image"
                  src="https://images.unsplash.com/photo-1484704849700-f032a568e944?auto=format&fit=crop&w=1600&q=85"
                  alt=""
                  width="1600"
                  height="1200"
                  draggable="false"
                  loading="lazy"
                />
              </figure>
              <figure
                class="feature-pills__media-item is-active"
                data-feature-media-item
                data-active
              >
                <img
                  class="feature-pills__media-image"
                  src="https://images.unsplash.com/photo-1546435770-a3e426bf472b?auto=format&fit=crop&w=1600&q=85"
                  alt=""
                  width="1600"
                  height="1200"
                  draggable="false"
                  loading="eager"
                />
              </figure>
              <figure class="feature-pills__media-item" data-feature-media-item>
                <img
                  class="feature-pills__media-image"
                  src="https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?auto=format&fit=crop&w=1600&q=85"
                  alt=""
                  width="1600"
                  height="1200"
                  draggable="false"
                  loading="lazy"
                />
              </figure>
              <figure class="feature-pills__media-item" data-feature-media-item>
                <img
                  class="feature-pills__media-image"
                  src="https://images.unsplash.com/photo-1511379938547-c1f69419868d?auto=format&fit=crop&w=1600&q=85"
                  alt=""
                  width="1600"
                  height="1200"
                  draggable="false"
                  loading="lazy"
                />
              </figure>
              <figure class="feature-pills__media-item" data-feature-media-item>
                <img
                  class="feature-pills__media-image"
                  src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?auto=format&fit=crop&w=1600&q=85"
                  alt=""
                  width="1600"
                  height="1200"
                  draggable="false"
                  loading="lazy"
                />
              </figure>
            </div>
            <div class="feature-pills__media-overlay"></div>
          </div>
        </div>
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
/* ==========================================================================
   Feature Pills
   Full-bleed accordion features + synced media crossfade
   ========================================================================== */

:root {
  --color-bg: #000000;
  --color-media-bg: #0a0a0c;
  --color-item: rgba(255, 255, 255, 0.055);
  --color-item-hover: rgba(255, 255, 255, 0.09);
  --color-item-active: rgba(255, 255, 255, 0.08);
  --color-item-border: rgba(255, 255, 255, 0.08);
  --color-item-border-active: rgba(255, 255, 255, 0.12);
  --color-text: #f2f2f4;
  --color-text-soft: rgba(242, 242, 244, 0.72);
  --color-text-muted: rgba(242, 242, 244, 0.42);
  --color-accent: #c8c4ff;
  --color-accent-soft: rgba(200, 196, 255, 0.14);
  --space-item-gap: 10px;
  --content-max-width: 480px;
  --font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
}

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html,
body {
  width: 100%;
  min-height: 100%;
}

body {
  font-family: var(--font-family);
  color: var(--color-text);
  background: var(--color-bg);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
  cursor: pointer;
}

img {
  display: block;
  max-width: 100%;
  user-select: none;
  -webkit-user-drag: none;
}

/* ---------- Page ---------- */

.page {
  position: relative;
  width: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  align-items: stretch;
  justify-content: stretch;
  padding: 0;
  overflow: hidden;
  background: var(--color-bg);
}

/* ---------- Block ---------- */

.feature-pills {
  width: 100%;
  flex: 1;
  display: flex;
  opacity: 0;
  will-change: opacity, transform;
}

.feature-pills__layout {
  display: grid;
  grid-template-columns: minmax(320px, 42%) 1fr;
  width: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  background: var(--color-bg);
  overflow: hidden;
}

/* ---------- Content column ---------- */

.feature-pills__content {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1.75rem;
  padding: clamp(2rem, 5vh, 4rem) clamp(1.75rem, 5vw, 4.5rem);
  background: var(--color-bg);
}

.feature-pills__header {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  padding-inline: 0.15rem;
  max-width: var(--content-max-width);
  width: 100%;
}

.feature-pills__eyebrow {
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--color-text-muted);
}

.feature-pills__title {
  font-size: clamp(1.2rem, 2.2vw, 1.45rem);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.3;
  color: var(--color-text);
  max-width: 16ch;
}

.feature-pills__list {
  display: flex;
  flex-direction: column;
  gap: var(--space-item-gap);
  list-style: none;
  max-width: var(--content-max-width);
  width: 100%;
}

/* ---------- Feature item ---------- */

.feature-pills__item {
  position: relative;
  border-radius: 0;
  background: var(--color-item);
  border: 1px solid var(--color-item-border);
  overflow: hidden;
  transform-origin: 50% 50%;
  will-change: transform, background-color, border-color;
  transition:
    background-color 0.4s var(--ease-out),
    border-color 0.4s var(--ease-out);
}

.feature-pills__item:hover:not(.is-active) {
  background: var(--color-item-hover);
}

.feature-pills__item.is-active {
  background: var(--color-item-active);
  border-color: var(--color-item-border-active);
}

.feature-pills__trigger {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  width: 100%;
  min-height: 48px;
  padding: 0.85rem 1.15rem 0.85rem 1.25rem;
  text-align: left;
  outline: none;
}

.feature-pills__trigger:focus-visible {
  box-shadow:
    inset 0 0 0 2px var(--color-accent-soft),
    inset 0 0 0 1px var(--color-accent);
}

.feature-pills__label {
  font-size: 0.94rem;
  font-weight: 500;
  letter-spacing: -0.01em;
  line-height: 1.3;
  color: var(--color-text-soft);
  transition: color 0.3s var(--ease-out);
}

.feature-pills__item.is-active .feature-pills__label,
.feature-pills__item:hover:not(.is-active) .feature-pills__label {
  color: var(--color-text);
}

/* Plus icon — hidden while active */
.feature-pills__icon {
  position: relative;
  flex-shrink: 0;
  width: 14px;
  height: 14px;
  opacity: 0.55;
  transform: scale(1);
  transition:
    opacity 0.35s var(--ease-out),
    transform 0.35s var(--ease-out);
}

.feature-pills__item:hover:not(.is-active) .feature-pills__icon {
  opacity: 0.9;
}

.feature-pills__item.is-active .feature-pills__icon {
  opacity: 0;
  transform: scale(0.55);
  pointer-events: none;
}

.feature-pills__icon-line {
  position: absolute;
  top: 50%;
  left: 50%;
  background: currentColor;
  border-radius: 1px;
  transform: translate(-50%, -50%);
}

.feature-pills__icon-line--horizontal {
  width: 12px;
  height: 1.5px;
}

.feature-pills__icon-line--vertical {
  width: 1.5px;
  height: 12px;
  transform-origin: center center;
}

/* ---------- Expandable panel ---------- */

.feature-pills__panel {
  height: 0;
  overflow: hidden;
  will-change: height;
}

.feature-pills__panel-inner {
  padding: 0 1.25rem 1.15rem;
  opacity: 0;
  transform: translateY(6px);
  will-change: opacity, transform;
}

.feature-pills__description {
  font-size: 0.875rem;
  font-weight: 400;
  line-height: 1.55;
  letter-spacing: -0.005em;
  color: var(--color-text-muted);
  max-width: 34ch;
}

.feature-pills__item.is-active .feature-pills__description {
  color: var(--color-text-soft);
}

/* ---------- Media column ---------- */

.feature-pills__media {
  position: relative;
  min-height: 100%;
  height: 100%;
  background: var(--color-media-bg);
  overflow: hidden;
}

.feature-pills__media-stack {
  position: absolute;
  inset: 0;
}

.feature-pills__media-item {
  position: absolute;
  inset: 0;
  margin: 0;
  opacity: 0;
  visibility: hidden;
  will-change: opacity, transform;
  pointer-events: none;
}

.feature-pills__media-item.is-active {
  opacity: 1;
  visibility: visible;
  z-index: 1;
}

.feature-pills__media-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  transform: scale(1.04);
  will-change: transform;
}

.feature-pills__media-item.is-active .feature-pills__media-image {
  transform: scale(1);
}

.feature-pills__media-overlay {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background:
    linear-gradient(90deg, rgba(0, 0, 0, 0.45) 0%, transparent 22%),
    linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, transparent 28%);
}

/* ---------- Responsive ---------- */

@media (max-width: 860px) {
  .page {
    overflow: auto;
  }

  .feature-pills__layout {
    grid-template-columns: 1fr;
    grid-template-rows: minmax(42vh, 48vh) auto;
    min-height: 100dvh;
  }

  .feature-pills__content {
    order: 1;
    justify-content: flex-start;
    gap: 1.35rem;
    padding: 1.75rem 1.35rem 2.25rem;
  }

  .feature-pills__media {
    order: 0;
    min-height: 42vh;
    height: auto;
  }

  .feature-pills__list,
  .feature-pills__title,
  .feature-pills__description {
    max-width: none;
  }
}

@media (max-width: 480px) {
  .feature-pills__layout {
    grid-template-rows: minmax(38vh, 42vh) auto;
  }

  .feature-pills__content {
    padding: 1.5rem 1.15rem 2rem;
  }

  .feature-pills__trigger {
    min-height: 46px;
    padding: 0.8rem 1rem 0.8rem 1.1rem;
  }

  .feature-pills__label {
    font-size: 0.9rem;
  }

  .feature-pills__panel-inner {
    padding: 0 1.1rem 1.05rem;
  }

  .feature-pills__description {
    font-size: 0.84rem;
  }
}

/* ---------- Reduced motion ---------- */

@media (prefers-reduced-motion: reduce) {
  .feature-pills,
  .feature-pills__item,
  .feature-pills__label,
  .feature-pills__icon,
  .feature-pills__panel,
  .feature-pills__panel-inner,
  .feature-pills__media-item,
  .feature-pills__media-image {
    transition: none !important;
  }
}
````

## Source — script.js
````javascript
/**
 * Feature Pills
 * -------------
 * Accordion feature items with height-animated panels and a synced
 * media crossfade. Only one item is active at a time.
 *
 * Root config (data attributes on [data-feature-pills]):
 *   data-duration      — open/close duration in seconds (default 0.55)
 *   data-ease          — GSAP ease for height (default power3.inOut)
 *   data-content-ease  — GSAP ease for panel content (default power2.out)
 */

(() => {
  "use strict";

  const SELECTORS = {
    root: "[data-feature-pills]",
    list: "[data-feature-list]",
    item: "[data-feature-item]",
    trigger: "[data-feature-trigger]",
    panel: "[data-feature-panel]",
    panelInner: "[data-feature-panel-inner]",
    iconBar: "[data-feature-icon-bar]",
    mediaItem: "[data-feature-media-item]",
    mediaImage: ".feature-pills__media-image",
  };

  const STATE = {
    active: "is-active",
  };

  let instanceId = 0;

  waitForGsap(initAll);

  function waitForGsap(callback, attempt = 0) {
    if (typeof gsap !== "undefined") {
      callback();
      return;
    }

    if (attempt < 80) {
      setTimeout(() => waitForGsap(callback, attempt + 1), 40);
      return;
    }

    console.error("[Feature Pills] GSAP failed to load.");
  }

  function initAll() {
    gsap.config({ force3D: true, nullTargetWarn: false });

    document.querySelectorAll(SELECTORS.root).forEach((root) => {
      createFeaturePills(root);
    });
  }

  /**
   * @param {HTMLElement} root
   */
  function createFeaturePills(root) {
    const list = root.querySelector(SELECTORS.list);
    const itemEls = Array.from(root.querySelectorAll(SELECTORS.item));
    const mediaItems = Array.from(root.querySelectorAll(SELECTORS.mediaItem));

    if (!list || itemEls.length === 0) return;

    const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
    const duration = clamp(parseFloat(root.dataset.duration || "0.55"), 0.2, 1.4);
    const ease = root.dataset.ease || "power3.inOut";
    const contentEase = root.dataset.contentEase || "power2.out";

    const uid = ++instanceId;

    /** @type {number} */
    let activeIndex = itemEls.findIndex((el) => el.hasAttribute("data-active"));
    if (activeIndex < 0) activeIndex = 0;

    /** @type {Map<HTMLElement, gsap.core.Timeline>} */
    const timelines = new Map();

    let isAnimating = false;
    /** @type {number | null} */
    let pendingIndex = null;

    const items = itemEls.map((el, index) => {
      const trigger = el.querySelector(SELECTORS.trigger);
      const panel = el.querySelector(SELECTORS.panel);
      const panelInner = el.querySelector(SELECTORS.panelInner);
      const iconBar = el.querySelector(SELECTORS.iconBar);

      // Wire ARIA relationships once
      if (trigger && panel) {
        const triggerId = `feature-pills-${uid}-trigger-${index}`;
        const panelId = `feature-pills-${uid}-panel-${index}`;

        trigger.id = triggerId;
        panel.id = panelId;
        trigger.setAttribute("aria-controls", panelId);
        panel.setAttribute("aria-labelledby", triggerId);
      }

      return { el, trigger, panel, panelInner, iconBar, index };
    });

    // Instant initial state
    items.forEach((item) => {
      const isOpen = item.index === activeIndex;
      setItemState(item, isOpen, true);

      if (item.iconBar) {
        gsap.set(item.iconBar, {
          rotation: isOpen ? 90 : 0,
          transformOrigin: "50% 50%",
        });
      }
    });

    mediaItems.forEach((mediaItem, index) => {
      const isActive = index === activeIndex;
      const image = mediaItem.querySelector(SELECTORS.mediaImage);

      gsap.set(mediaItem, {
        autoAlpha: isActive ? 1 : 0,
        zIndex: isActive ? 2 : 1,
      });

      if (image) {
        gsap.set(image, { scale: isActive ? 1 : 1.06 });
      }

      mediaItem.classList.toggle(STATE.active, isActive);
      mediaItem.toggleAttribute("data-active", isActive);
    });

    playIntro();
    bindEvents();
    prefetchMedia(mediaItems);

    function bindEvents() {
      items.forEach((item) => {
        item.trigger?.addEventListener("click", () => activate(item.index));
      });

      list.addEventListener("keydown", onListKeydown);

      let resizeRaf = 0;
      window.addEventListener("resize", () => {
        cancelAnimationFrame(resizeRaf);
        resizeRaf = requestAnimationFrame(syncOpenPanelHeight);
      });
    }

    /**
     * @param {KeyboardEvent} event
     */
    function onListKeydown(event) {
      const focusedIndex = items.findIndex((item) => item.trigger === document.activeElement);
      if (focusedIndex < 0) return;

      let nextIndex = null;

      switch (event.key) {
        case "ArrowDown":
        case "ArrowRight":
          nextIndex = (focusedIndex + 1) % items.length;
          break;
        case "ArrowUp":
        case "ArrowLeft":
          nextIndex = (focusedIndex - 1 + items.length) % items.length;
          break;
        case "Home":
          nextIndex = 0;
          break;
        case "End":
          nextIndex = items.length - 1;
          break;
        default:
          return;
      }

      event.preventDefault();
      items[nextIndex].trigger?.focus();
      activate(nextIndex);
    }

    function syncOpenPanelHeight() {
      const activeItem = items[activeIndex];
      if (!activeItem?.panel || !activeItem.panelInner) return;
      if (!activeItem.el.classList.contains(STATE.active)) return;

      gsap.set(activeItem.panel, { height: activeItem.panelInner.scrollHeight });
    }

    /**
     * @param {number} index
     */
    function activate(index) {
      if (index === activeIndex || index < 0 || index >= items.length) return;

      if (isAnimating) {
        pendingIndex = index;
        return;
      }

      const previousItem = items[activeIndex];
      const nextItem = items[index];
      const previousIndex = activeIndex;

      activeIndex = index;
      isAnimating = true;

      const animDuration = reduceMotion.matches ? 0.01 : duration;

      killTimeline(previousItem.el);
      killTimeline(nextItem.el);

      const master = gsap.timeline({
        defaults: { ease },
        onComplete: () => {
          isAnimating = false;

          if (pendingIndex !== null && pendingIndex !== activeIndex) {
            const queued = pendingIndex;
            pendingIndex = null;
            activate(queued);
            return;
          }

          pendingIndex = null;
        },
      });

      if (previousItem) {
        master.add(closeItem(previousItem, animDuration), 0);
      }

      master.add(openItem(nextItem, animDuration), reduceMotion.matches ? 0 : 0.04);
      master.add(crossfadeMedia(previousIndex, index, animDuration * 1.05), 0);

      timelines.set(nextItem.el, master);
    }

    /**
     * @param {ReturnType<typeof items[0]>} item
     * @param {number} animDuration
     */
    function openItem(item, animDuration) {
      const timeline = gsap.timeline({ defaults: { overwrite: "auto" } });
      if (!item.panel || !item.panelInner) return timeline;

      setItemState(item, true, false);

      gsap.set(item.panel, { height: "auto" });
      const targetHeight = item.panelInner.scrollHeight;
      gsap.set(item.panel, { height: 0 });

      timeline.fromTo(
        item.el,
        { scale: 1 },
        {
          scale: 1.012,
          duration: animDuration * 0.32,
          ease: "power2.out",
          yoyo: true,
          repeat: 1,
          transformOrigin: "50% 50%",
        },
        0
      );

      timeline.to(
        item.panel,
        {
          height: targetHeight,
          duration: animDuration,
          ease,
        },
        0
      );

      timeline.to(
        item.panelInner,
        {
          autoAlpha: 1,
          y: 0,
          duration: animDuration * 0.72,
          ease: contentEase,
        },
        animDuration * 0.22
      );

      if (item.iconBar) {
        timeline.to(
          item.iconBar,
          {
            rotation: 90,
            duration: animDuration * 0.45,
            ease: "power2.inOut",
          },
          0
        );
      }

      timeline.set(item.panel, { height: "auto" });

      return timeline;
    }

    /**
     * @param {ReturnType<typeof items[0]>} item
     * @param {number} animDuration
     */
    function closeItem(item, animDuration) {
      const timeline = gsap.timeline({ defaults: { overwrite: "auto" } });
      if (!item.panel || !item.panelInner) return timeline;

      const currentHeight = item.panel.offsetHeight || item.panelInner.scrollHeight;
      gsap.set(item.panel, { height: currentHeight });

      timeline.to(
        item.panelInner,
        {
          autoAlpha: 0,
          y: 4,
          duration: animDuration * 0.32,
          ease: "power2.in",
        },
        0
      );

      timeline.to(
        item.panel,
        {
          height: 0,
          duration: animDuration * 0.9,
          ease,
        },
        animDuration * 0.06
      );

      if (item.iconBar) {
        timeline.to(
          item.iconBar,
          {
            rotation: 0,
            duration: animDuration * 0.45,
            ease: "power2.inOut",
          },
          animDuration * 0.2
        );
      }

      timeline.add(() => setItemState(item, false, false), animDuration * 0.4);

      return timeline;
    }

    /**
     * @param {number} fromIndex
     * @param {number} toIndex
     * @param {number} animDuration
     */
    function crossfadeMedia(fromIndex, toIndex, animDuration) {
      const timeline = gsap.timeline();
      const fromItem = mediaItems[fromIndex];
      const toItem = mediaItems[toIndex];

      if (!toItem) return timeline;

      const fromImage = fromItem?.querySelector(SELECTORS.mediaImage);
      const toImage = toItem.querySelector(SELECTORS.mediaImage);

      mediaItems.forEach((mediaItem, index) => {
        const isActive = index === toIndex;
        mediaItem.classList.toggle(STATE.active, isActive);
        mediaItem.toggleAttribute("data-active", isActive);
      });

      gsap.set(toItem, { zIndex: 3, autoAlpha: 0 });
      if (fromItem) gsap.set(fromItem, { zIndex: 2 });
      if (toImage) gsap.set(toImage, { scale: 1.07 });

      timeline.to(
        toItem,
        {
          autoAlpha: 1,
          duration: animDuration * 0.85,
          ease: "power2.out",
        },
        0
      );

      if (toImage) {
        timeline.to(
          toImage,
          {
            scale: 1,
            duration: animDuration * 1.15,
            ease: "power3.out",
          },
          0
        );
      }

      if (fromItem) {
        timeline.to(
          fromItem,
          {
            autoAlpha: 0,
            duration: animDuration * 0.7,
            ease: "power2.inOut",
          },
          animDuration * 0.08
        );

        if (fromImage) {
          timeline.to(
            fromImage,
            {
              scale: 1.04,
              duration: animDuration * 0.75,
              ease: "power2.inOut",
            },
            0
          );
        }

        timeline.set(fromItem, { zIndex: 1 });
      }

      timeline.set(toItem, { zIndex: 2 });

      return timeline;
    }

    /**
     * @param {ReturnType<typeof items[0]>} item
     * @param {boolean} isOpen
     * @param {boolean} instant
     */
    function setItemState(item, isOpen, instant) {
      item.el.classList.toggle(STATE.active, isOpen);
      item.el.toggleAttribute("data-active", isOpen);

      item.trigger?.setAttribute("aria-expanded", isOpen ? "true" : "false");

      if (item.panel) {
        item.panel.setAttribute("aria-hidden", isOpen ? "false" : "true");

        if (isOpen) {
          item.panel.removeAttribute("inert");
        } else {
          item.panel.setAttribute("inert", "");
        }
      }

      if (!instant || !item.panel || !item.panelInner) return;

      if (isOpen) {
        gsap.set(item.panel, { height: "auto" });
        gsap.set(item.panelInner, { autoAlpha: 1, y: 0 });
      } else {
        gsap.set(item.panel, { height: 0 });
        gsap.set(item.panelInner, { autoAlpha: 0, y: 6 });
      }
    }

    /**
     * @param {HTMLElement} el
     */
    function killTimeline(el) {
      const existing = timelines.get(el);
      if (!existing) return;

      existing.kill();
      timelines.delete(el);
    }

    function playIntro() {
      const motionScale = reduceMotion.matches ? 0.01 : 1;

      gsap.set(root, { autoAlpha: 0, y: 18, scale: 0.985 });

      const intro = gsap.timeline({ defaults: { ease: "power3.out" } });

      intro.to(root, {
        autoAlpha: 1,
        y: 0,
        scale: 1,
        duration: 0.75 * motionScale,
      });

      intro.from(
        items.map((item) => item.el),
        {
          autoAlpha: 0,
          y: 14,
          scale: 0.96,
          duration: 0.55 * motionScale,
          stagger: 0.045 * motionScale,
          clearProps: "transform",
        },
        0.12 * motionScale
      );

      const activeMedia = mediaItems[activeIndex];
      const activeImage = activeMedia?.querySelector(SELECTORS.mediaImage);

      if (activeImage) {
        gsap.fromTo(
          activeImage,
          { scale: 1.1 },
          {
            scale: 1,
            duration: 1.2 * motionScale,
            ease: "power2.out",
            delay: 0.1 * motionScale,
          }
        );
      }
    }
  }

  /**
   * @param {HTMLElement[]} mediaItems
   */
  function prefetchMedia(mediaItems) {
    const run = () => {
      mediaItems.forEach((mediaItem) => {
        const image = mediaItem.querySelector("img");
        if (!image?.src) return;

        const preload = new Image();
        preload.src = image.src;
      });
    };

    if ("requestIdleCallback" in window) {
      requestIdleCallback(run, { timeout: 2000 });
    } else {
      setTimeout(run, 600);
    }
  }

  /**
   * @param {number} value
   * @param {number} min
   * @param {number} max
   */
  function clamp(value, min, max) {
    return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min));
  }
})();
````
