Expanding Feature Pills

A locked accordion where each pill expands with a GSAP width+height animation to reveal rich content, while the opposite panel shows a synced visual for the active item. Supports a cover placeholder visual, a close button, keyboard (Escape) dismiss, edit mode for Webflow authoring, reduced-motion fallback, and debounced resize recalculation.

gsapaccordionexpandpillsinteractivefeature list

Setup — External Scripts

CDN — GSAP (add before </body>)
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>

Code

HTML
html
<div data-feature-pills-active="false" aria-label="product features" data-feature-pills-init="" data-edit-mode="false" class="feature-pills__wrap">
  <div class="feature-pills__layout">
    <div class="feature-pills__col">
      <div data-feature-pills-collection="" class="feature-pills__info-collection">
        <ul role="list" data-feature-pills-list="" class="feature-pills__info-list">
          <li data-feature-pills-item="" data-active="false" class="feature-pills__info-item">
            <div class="feature-pills__item-bg"></div>
            <button data-feature-pills-button="" aria-expanded="false" class="feature-pills__item-button">
              <span class="feature-pills__item-label">Effortless movement</span>
              <span class="feature-pills__item-icon">
                <span class="feature-pills__item-icon-bar"></span>
                <span class="feature-pills__item-icon-bar is--horizontal"></span>
              </span>
            </button>
            <div aria-hidden="true" data-feature-pills-content="" class="feature-pills__item-content">
              <div class="feature-pills__item-mask">
                <div data-feature-pills-inner="" class="feature-pills__item-inner">
                  <p class="feature-pills__item-body">Effortless movement.<br><br>
                    <span class="feature-pills__item-body-span">Four-way stretch and a tuned cut move with you — so every stride, reach, and turn feels natural.</span>
                  </p>
                </div>
              </div>
            </div>
          </li>
          <li data-feature-pills-item="" data-active="false" class="feature-pills__info-item">
            <div class="feature-pills__item-bg"></div><button data-feature-pills-button="" aria-expanded="false" class="feature-pills__item-button"><span class="feature-pills__item-label">Breathes when you push</span><span class="feature-pills__item-icon"><span class="feature-pills__item-icon-bar"></span><span class="feature-pills__item-icon-bar is--horizontal"></span></span></button>
            <div aria-hidden="true" data-feature-pills-content="" class="feature-pills__item-content">
              <div class="feature-pills__item-mask">
                <div data-feature-pills-inner="" class="feature-pills__item-inner">
                  <p class="feature-pills__item-body">Breathes when you push.<br><br>Air-mapped fabric releases heat fast, keeping you cool through climbs, sprints, and long sessions.</p>
                </div>
              </div>
            </div>
          </li>
          <li data-feature-pills-item="" data-active="false" class="feature-pills__info-item">
            <div class="feature-pills__item-bg"></div>
            <button data-feature-pills-button="" aria-expanded="false" class="feature-pills__item-button">
              <span class="feature-pills__item-label">Storm-ready waterproofing</span>
              <span class="feature-pills__item-icon">
                <span class="feature-pills__item-icon-bar"></span>
                <span class="feature-pills__item-icon-bar is--horizontal"> </span>
              </span>
            </button>
            <div aria-hidden="true" data-feature-pills-content="" class="feature-pills__item-content">
              <div class="feature-pills__item-mask">
                <div data-feature-pills-inner="" class="feature-pills__item-inner">
                  <p class="feature-pills__item-body">Storm-ready waterproofing.<br><br>
                    <span class="feature-pills__item-body-span">A sealed outer layer sheds rain on contact, with water beading off before it ever soaks in.</span>
                  </p>
                </div>
              </div>
            </div>
          </li>
          <li data-feature-pills-item="" data-active="false" class="feature-pills__info-item">
            <div class="feature-pills__item-bg"></div>
            <button data-feature-pills-button="" aria-expanded="false" class="feature-pills__item-button">
              <span class="feature-pills__item-label">Built for high output</span><span class="feature-pills__item-icon">
                <span class="feature-pills__item-icon-bar"></span>
                <span class="feature-pills__item-icon-bar is--horizontal"></span>
              </span>
            </button>
            <div aria-hidden="true" data-feature-pills-content="" class="feature-pills__item-content">
              <div class="feature-pills__item-mask">
                <div data-feature-pills-inner="" class="feature-pills__item-inner">
                  <p class="feature-pills__item-body">Built for high output.<br><br>
                    <span class="feature-pills__item-body-span">Lightweight where it matters, durable where it counts — engineered to perform at speed, under load.</span>
                  </p>
                </div>
              </div>
            </div>
          </li>
          <li data-feature-pills-item="" data-active="false" class="feature-pills__info-item">
            <div class="feature-pills__item-bg"></div>
            <button data-feature-pills-button="" aria-expanded="false" class="feature-pills__item-button">
              <span class="feature-pills__item-label">Protection, without bulk</span>
              <span class="feature-pills__item-icon">
                <span class="feature-pills__item-icon-bar"></span>
                <span class="feature-pills__item-icon-bar is--horizontal"></span>
              </span>
            </button>
            <div aria-hidden="true" data-feature-pills-content="" class="feature-pills__item-content">
              <div class="feature-pills__item-mask">
                <div data-feature-pills-inner="" class="feature-pills__item-inner">
                  <p class="feature-pills__item-body">Protection, without bulk.<br><br>
                    <span class="feature-pills__item-body-span">Reinforced panels take the hits and abrasion, while the rest stays streamlined and flexible.</span>
                  </p>
                </div>
              </div>
            </div>
          </li>
          <li data-feature-pills-item="" data-active="false" class="feature-pills__info-item">
            <div class="feature-pills__item-bg"></div>
            <button data-feature-pills-button="" aria-expanded="false" class="feature-pills__item-button">
              <span class="feature-pills__item-label">Wind insulation</span>
              <span class="feature-pills__item-icon">
                <span class="feature-pills__item-icon-bar"></span>
                <span class="feature-pills__item-icon-bar is--horizontal"></span>
              </span>
            </button>
            <div aria-hidden="true" data-feature-pills-content="" class="feature-pills__item-content">
              <div class="feature-pills__item-mask">
                <div data-feature-pills-inner="" class="feature-pills__item-inner">
                  <p class="feature-pills__item-body">Wind insulation.<br><br>
                    <span class="feature-pills__item-body-span">A wind-blocking shell cuts chill instantly, holding warmth close without trapping sweat.</span>
                  </p>
                </div>
              </div>
            </div>
          </li>
        </ul>
      </div>
    </div>
    <div class="feature-pills__col is--visual">
      <div class="feature-pills__visual-collection">
        <div class="feature-pills__visual-list">
          <div aria-hidden="true" data-feature-pills-visual="" class="feature-pills__visual-item">
            <img src="https://cdn.prod.website-files.com/69677270cfce23df8f7f806b/69678e23bef3821ddd09d61e_Motion%20Blur%20Portrait.avif" class="feature-pills__visual-img">
          </div>
          <div aria-hidden="true" data-feature-pills-visual="" class="feature-pills__visual-item">
            <img src="https://cdn.prod.website-files.com/69677270cfce23df8f7f806b/69678e2399b8352d8164a769_Runner%20in%20Motion%20(1).avif" class="feature-pills__visual-img">
          </div>
          <div aria-hidden="true" data-feature-pills-visual="" class="feature-pills__visual-item">
            <img src="https://cdn.prod.website-files.com/69677270cfce23df8f7f806b/69678e238b18bad979ad763c_Adventurer%20in%20Motion.avif" class="feature-pills__visual-img">
          </div>
          <div aria-hidden="true" data-feature-pills-visual="" class="feature-pills__visual-item">
            <img src="https://cdn.prod.website-files.com/69677270cfce23df8f7f806b/69678e236094f2a6a51c5a6a_Dynamic%20Martial%20Arts%20Pose.avif" class="feature-pills__visual-img">
          </div>
          <div aria-hidden="true" data-feature-pills-visual="" class="feature-pills__visual-item">
            <img src="https://cdn.prod.website-files.com/69677270cfce23df8f7f806b/69678e23e6da4f53478511b5_Snowboarding%20Adventure.avif" class="feature-pills__visual-img">
          </div>
          <div aria-hidden="true" data-feature-pills-visual="" class="feature-pills__visual-item">
            <img src="https://cdn.prod.website-files.com/69677270cfce23df8f7f806b/69678e23434707a565abb0c7_Dynamic%20Skiing%20Action.avif" class="feature-pills__visual-img">
          </div>
        </div>
      </div>
      <div data-feature-pills-cover="" class="feature-pills__visual-cover">
        <img src="https://cdn.prod.website-files.com/69677270cfce23df8f7f806b/6967c0e9c014f4dab1ed8fe9_expanding-features-placeholder-v3.avif" class="feature-pills__visual-cover-img">
      </div>
    </div>
  </div>
  <div class="feature-pills__close">
    <button data-feature-pills-close="" aria-hidden="true" class="feature-pills__close-button">
      <span class="feature-pills__item-icon-bar"></span>
      <span class="feature-pills__item-icon-bar is--horizontal"></span>
    </button>
  </div>
</div>
CSS
css
.feature-pills__wrap {
  color: #f2f2f2;
  background-color: #272a2a;
  border: 2px solid #ffffff26;
  border-radius: 1.25em;
  width: 100%;
  max-width: 75em;
  height: 45em;
  position: relative;
  overflow: clip;
}

.feature-pills__layout {
  justify-content: flex-start;
  align-items: stretch;
  width: 100%;
  height: 100%;
  display: flex;
  position: relative;
}

.feature-pills__col {
  width: 50%;
  position: relative;
}

.feature-pills__visual-collection {
  z-index: 0;
  width: 100%;
  height: 100%;
  position: relative;
}

.feature-pills__visual-item {
  opacity: 0;
  width: 100%;
  height: 100%;
  position: absolute;
  inset: 0%;
}

.feature-pills__visual-list {
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
}

.feature-pills__visual-img {
  object-fit: cover;
  width: 100%;
  height: 100%;
}

.feature-pills__info-collection {
  flex-flow: column;
  justify-content: center;
  align-items: flex-start;
  width: 100%;
  height: 100%;
  padding-left: 1.25em;
  padding-right: 1.25em;
  display: flex;
}

.feature-pills__info-list {
  grid-column-gap: 1em;
  grid-row-gap: 1em;
  max-width: var(--content-item-expanded);
  flex-flow: column;
  flex: none;
  justify-content: center;
  align-items: flex-start;
  width: 100%;
  margin-bottom: 0;
  margin-left: auto;
  margin-right: auto;
  padding: 0;
  list-style: none;
  display: flex;
}

.feature-pills__info-item {
  padding: 0;
  position: relative;
}

.feature-pills__item-bg {
  z-index: 0;
  background-color: #ffffff14;
  border-radius: 2em;
  width: 100%;
  height: 100%;
  position: absolute;
  inset: 0%;
}

.feature-pills__item-button {
  z-index: 1;
  grid-column-gap: .625em;
  grid-row-gap: .625em;
  background-color: #0000;
  border: 1px #000;
  flex-flow: row;
  justify-content: flex-start;
  align-items: center;
  padding: .75em 1.25em;
  display: flex;
  position: relative;
}

.feature-pills__item-label {
  letter-spacing: -.015em;
  white-space: nowrap;
  flex: none;
  font-size: 1.25em;
  font-weight: 500;
}

.feature-pills__item-icon {
  aspect-ratio: 1;
  background-color: #fff3;
  border-radius: 100em;
  flex: none;
  justify-content: center;
  align-items: center;
  width: 1.25em;
  padding: 0;
  display: flex;
  position: relative;
}

.feature-pills__item-icon-bar {
  background-color: #fff;
  flex: none;
  width: 1px;
  height: 50%;
  padding: 0;
  position: absolute;
}

.feature-pills__item-icon-bar.is--horizontal {
  width: 50%;
  height: 1px;
}

.feature-pills__visual-cover {
  z-index: 1;
  width: 100%;
  height: 100%;
  position: absolute;
  inset: 0%;
}

.feature-pills__visual-cover-img {
  object-fit: cover;
  width: 100%;
  height: 100%;
}

.feature-pills__item-content {
  z-index: 2;
  pointer-events: none;
  position: absolute;
  inset: 0%;
}

.feature-pills__item-mask {
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.feature-pills__item-inner {
  max-width: var(--content-item-expanded);
  flex-flow: column;
  justify-content: flex-start;
  align-items: flex-start;
  width: max-content;
  padding: 1.5em 1.5em 2em;
  display: flex;
}

.feature-pills__item-body {
  margin-bottom: 0;
  font-size: 1.25em;
  font-weight: 500;
}

.feature-pills__item-body-span {
  opacity: .5;
}

.feature-pills__close {
  z-index: 2;
  position: absolute;
  top: 1em;
  right: 1em;
}

.feature-pills__close-button {
  aspect-ratio: 1;
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  background-color: #ffffff14;
  border: 0 #000;
  border-radius: 10em;
  justify-content: center;
  align-items: center;
  width: 2em;
  padding: 8px;
  display: flex;
  position: relative;
}

@media screen and (max-width: 991px) {
  .section-resource {
    justify-content: center;
    align-items: flex-start;
    padding-top: 5em;
  }

  .feature-pills__wrap {
    background-color: #0000;
    border-style: none;
    border-radius: 0;
    height: auto;
  }

  .feature-pills__layout {
    flex-flow: column;
  }

  .feature-pills__col {
    width: 100%;
  }

  .feature-pills__col.is--visual {
    aspect-ratio: 1;
    border-radius: 1.25em;
    order: -9999;
    overflow: hidden;
  }

  .feature-pills__info-collection {
    padding: 2.5em 0 4em;
  }

  .feature-pills__info-list {
    flex-flow: wrap;
    justify-content: flex-start;
    align-items: flex-start;
    max-width: none;
  }

  .feature-pills__info-item {
    width: var(--content-item-expanded);
  }

  .feature-pills__item-button {
    justify-content: space-between;
    align-items: center;
    width: 100%;
  }

  .feature-pills__item-inner {
    max-width: 100%;
  }
}

/* Max width of expanded pill/content */
[data-feature-pills-init] {
  --content-item-expanded: 25em;
}

@media screen and (max-width: 991px){
  [data-feature-pills-init] {
    --content-item-expanded: calc(50% - 0.5em);
  }
}

@media screen and (max-width: 767px){
  [data-feature-pills-init] {
    --content-item-expanded: 100%;
  }
}

/* Default state + transition */
[data-feature-pills-button] {
  opacity: 1;
  transition: opacity 400ms ease-in-out 300ms;
}

[data-feature-pills-inner] {
  opacity: 0;
  transition: opacity 300ms ease-in-out 0ms;
}

[data-feature-pills-visual] {
  opacity: 0;
  transition: opacity 350ms ease-in-out;
}

[data-feature-pills-cover] {
  opacity: 1;
  transition: opacity 350ms ease-in-out;
}

/* Active Pill */
[data-feature-pills-item][data-active="true"] [data-feature-pills-button] {
  opacity: 0;
  transition: opacity 50ms ease-in-out 0ms;
}

[data-feature-pills-item][data-active="true"] [data-feature-pills-inner] {
  opacity: 1;
}

/* Active Visual */
[data-feature-pills-visual][data-active="true"] {
  opacity: 1;
}

[data-feature-pills-cover][data-active="false"] {
  opacity: 0;
}

/* Close button */
[data-feature-pills-close] {
  transform: scale(0) rotate(135deg);
  opacity: 0;
  pointer-events: none;
  transition: all 500ms cubic-bezier(.7, 0, .3, 1);
}

[data-feature-pills-active="true"] [data-feature-pills-close] {
  transform: scale(1) rotate(45deg);
  opacity: 1;
  pointer-events: auto;
}

/* 'edit' mode where buttons are hidden and inner content is shown */
[data-feature-pills-init][data-edit-mode="true"] [data-feature-pills-collection] {
  overflow: auto;
  justify-content: start;
}

[data-feature-pills-init][data-edit-mode="true"] [data-feature-pills-button] {
  display: none;
}

[data-feature-pills-init][data-edit-mode="true"] [data-feature-pills-content] {
  position: relative;
  pointer-events: auto;
}

[data-feature-pills-init][data-edit-mode="true"] [data-feature-pills-inner] {
  opacity: 1;
  transform: translate(0px, 0em);
}
JavaScript
javascript
function initExpandingFeaturePills() {
  const wraps = document.querySelectorAll("[data-feature-pills-init]");
  if (!wraps.length) return;

  const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  wraps.forEach((wrap, wrapIdx) => {
    const items = Array.from(wrap.querySelectorAll("[data-feature-pills-item]"));
    const visuals = Array.from(wrap.querySelectorAll("[data-feature-pills-visual]"));
    const cover = wrap.querySelector("[data-feature-pills-cover]");
    const closeBtn = wrap.querySelector("[data-feature-pills-close]");
    if (!items.length) return;

    if (visuals.length && visuals.length !== items.length) {
      console.warn(
        `[ExpandingFeaturePills] items (${items.length}) and visuals (${visuals.length}) mismatch in module #${wrapIdx}. Visual syncing uses index order.`
      );
    }

    const uidBase = `feature-pills-${wrapIdx}`;
    const ease = "back.out(2)";
    const animationDuration = 0.5;

    const getExpandedWidth = () =>
      getComputedStyle(wrap).getPropertyValue("--content-item-expanded").trim() || "";

    const getActiveIndex = () => {
      const active = items.find((it) => it.getAttribute("data-active") === "true");
      return active ? Number(active.getAttribute("data-feature-pills-index")) : null;
    };

    const setWrapActive = (isActive) => {
      wrap.setAttribute("data-feature-pills-active", isActive ? "true" : "false");
      if (closeBtn) closeBtn.setAttribute("aria-hidden", isActive ? "false" : "true");
      if (cover) {
        cover.setAttribute("data-active", isActive ? "false" : "true");
        cover.setAttribute("aria-hidden", isActive ? "true" : "false");
      }
    };

    const setVisualActive = (indexOrNull) => {
      if (!visuals.length) return;
      visuals.forEach((v) => {
        const idx = Number(v.getAttribute("data-feature-pills-index"));
        const isActive = indexOrNull !== null && idx === indexOrNull;
        v.setAttribute("data-active", isActive ? "true" : "false");
        v.setAttribute("aria-hidden", isActive ? "false" : "true");
      });
    };

    const setItemA11y = (item, isOpen) => {
      const btn = item.querySelector("[data-feature-pills-button]");
      const content = item.querySelector("[data-feature-pills-content]");
      if (!btn || !content) return;
      btn.setAttribute("aria-expanded", isOpen ? "true" : "false");
      content.setAttribute("aria-hidden", isOpen ? "false" : "true");
    };

    const measureButtonH = (item) => {
      const btn = item.querySelector("[data-feature-pills-button]");
      return btn ? Math.ceil(btn.getBoundingClientRect().height) : 0;
    };

    const measureInnerH = (item, expandedW) => {
      const inner = item.querySelector("[data-feature-pills-inner]");
      if (!inner) return 0;

      const mask = item.querySelector(".feature-pills__item-mask");

      const prevMaskOverflow = mask ? mask.style.overflow : null;
      if (mask) mask.style.overflow = "visible";

      const prevMaxW = inner.style.maxWidth;
      if (expandedW) inner.style.maxWidth = expandedW;

      const h = Math.ceil(inner.getBoundingClientRect().height);

      if (expandedW) inner.style.maxWidth = prevMaxW || "";
      if (mask) mask.style.overflow = prevMaskOverflow || "";

      return h;
    };

    const getHeights = (item, expandedW) => {
      const buttonH = measureButtonH(item);
      const innerH = measureInnerH(item, expandedW);
      const openH = Math.max(buttonH, innerH);
      return { buttonH, openH };
    };

    const collapsedWidthPx = new Map();

    const captureCollapsedWidths = () => {
      items.forEach((item) => {
        const prev = item.style.width;
        item.style.width = "";
        collapsedWidthPx.set(item, Math.ceil(item.getBoundingClientRect().width));
        item.style.width = prev;
      });
    };

    const animateBox = (el, vars) => {
      gsap.killTweensOf(el);
      if (prefersReducedMotion) {
        if (vars.height != null) el.style.height = `${vars.height}px`;
        if (vars.width != null) el.style.width = typeof vars.width === "number" ? `${vars.width}px` : vars.width;
        return;
      }
      gsap.to(el, { ...vars, duration: animationDuration, ease, overwrite: true });
    };

    const openItem = (item) => {
      const expandedW = getExpandedWidth();
      const { openH } = getHeights(item, expandedW);

      item.setAttribute("data-active", "true");
      setItemA11y(item, true);
      setWrapActive(true);

      const targetW = expandedW || `${collapsedWidthPx.get(item) || Math.ceil(item.getBoundingClientRect().width)}px`;
      animateBox(item, { height: openH, width: targetW });
    };

    const closeItem = (item) => {
      const expandedW = getExpandedWidth();
      const { buttonH } = getHeights(item, expandedW);

      item.setAttribute("data-active", "false");
      setItemA11y(item, false);

      const targetW = collapsedWidthPx.get(item) || Math.ceil(item.getBoundingClientRect().width);
      animateBox(item, { height: buttonH, width: targetW });
    };

    const switchTo = (nextIndex) => {
      const current = getActiveIndex();
      if (current === nextIndex) return;

      const nextItem = items[nextIndex];
      if (!nextItem) return;

      if (current !== null) closeItem(items[current]);
      openItem(nextItem);

      setVisualActive(nextIndex);
    };

    const closeAll = () => {
      const current = getActiveIndex();
      if (current === null) return;
      closeItem(items[current]);
      setVisualActive(null);
      setWrapActive(false);
    };

    items.forEach((item, i) => {
      item.setAttribute("data-feature-pills-index", String(i));
      if (!item.hasAttribute("data-active")) item.setAttribute("data-active", "false");

      const btn = item.querySelector("[data-feature-pills-button]");
      const content = item.querySelector("[data-feature-pills-content]");
      if (btn) {
        btn.setAttribute("data-feature-pills-index", String(i));
        btn.type = "button";
        const triggerId = `${uidBase}-trigger-${i}`;
        if (!btn.id) btn.id = triggerId;
      }
      if (content && btn) {
        content.setAttribute("data-feature-pills-index", String(i));
        const panelId = `${uidBase}-panel-${i}`;
        if (!content.id) content.id = panelId;
        btn.setAttribute("aria-controls", content.id);
        content.setAttribute("role", "region");
        content.setAttribute("aria-labelledby", btn.id);
        if (!content.hasAttribute("aria-hidden")) content.setAttribute("aria-hidden", "true");
        if (!btn.hasAttribute("aria-expanded")) btn.setAttribute("aria-expanded", "false");
      }
    });

    visuals.forEach((v, i) => v.setAttribute("data-feature-pills-index", String(i)));

    if (closeBtn) {
      closeBtn.type = "button";
      if (!closeBtn.hasAttribute("aria-hidden")) closeBtn.setAttribute("aria-hidden", "true");
      closeBtn.addEventListener("click", closeAll);
    }

    items.forEach((item) => {
      const h = measureButtonH(item);
      item.style.height = `${h}px`;
    });

    setWrapActive(false);
    setVisualActive(null);

    items.forEach((item, i) => {
      const btn = item.querySelector("[data-feature-pills-button]");
      if (!btn) return;
      btn.addEventListener("click", () => switchTo(i));
    });

    wrap.addEventListener("keydown", (e) => {
      if (e.key === "Escape") closeAll();
    });

    const debounce = (fn, wait = 150) => {
      let t;
      return (...args) => {
        clearTimeout(t);
        t = setTimeout(() => fn(...args), wait);
      };
    };

    const handleResize = () => {
      const current = getActiveIndex();

      items.forEach((item) => {
        if (item.getAttribute("data-active") !== "true") item.style.width = "";
      });

      captureCollapsedWidths();

      if (current !== null) {
        const item = items[current];
        const expandedW = getExpandedWidth();
        const { openH } = getHeights(item, expandedW);
        const targetW = expandedW || "";

        if (prefersReducedMotion) {
          item.style.height = `${openH}px`;
          if (targetW) item.style.width = targetW;
        } else {
          const fallbackW = `${Math.ceil(item.getBoundingClientRect().width)}px`;
          const widthForActive = targetW || fallbackW;

          gsap.set(item, { height: openH, width: widthForActive });
          if (targetW) item.style.width = targetW;
        }
      } else {
        items.forEach((item) => {
          const h = measureButtonH(item);
          item.style.height = `${h}px`;
        });
      }
    };

    const debouncedResize = debounce(handleResize, 200);

    captureCollapsedWidths();
    window.addEventListener("resize", debouncedResize, { passive: true });
  });
}

document.addEventListener("DOMContentLoaded", () => {
  initExpandingFeaturePills();
});

Attributes

NameTypeDefaultDescription
[data-feature-pills-init]attributeModule root. The script scopes all selectors to this element. Add one per independent instance. Also hosts the --content-item-expanded CSS custom property.
[data-feature-pills-active]attribute ("true" | "false")Set on the module root. Automatically toggled by the script — "true" when any pill is open, "false" when all are closed. Used in CSS to control close-button visibility.
[data-edit-mode]attribute ("true" | "false")Set to "true" in Webflow Designer to show expanded content inline for easier authoring. Has no effect at runtime — keep it "false" on the published site.
[data-feature-pills-collection]attributeParent wrapper of the pill list. Used for overflow and flex layout overrides in edit mode.
[data-feature-pills-list]attributeDirect parent of all pill items. Ideally a <ul> element.
[data-feature-pills-item]attributeEach individual pill / accordion item. The script measures its button height and inner content height to drive the GSAP width+height animation.
[data-active]attribute ("true" | "false")State attribute on each pill item and visual. Set to "false" in HTML; toggled by the script. Used in CSS to hide/reveal the button label vs. expanded content.
[data-feature-pills-button]attributeThe clickable trigger inside each pill. Clicking it calls switchTo(index), opening that pill and closing any currently open one.
[data-feature-pills-content]attributeContent wrapper revealed when a pill is active. Positioned absolute over the button so the pill height is controlled entirely by the GSAP animation.
[data-feature-pills-inner]attributeInner element whose rendered height is measured by the script to determine the open height target for the GSAP animation.
[data-feature-pills-visual]attributeEach visual slide in the right-hand panel. Matched to pills by DOM index (1st pill → 1st visual). Receives data-active="true" when its pill opens.
[data-feature-pills-cover]attributeDefault placeholder visual shown when no pill is open. Hidden when any pill is active, restored when all close.
[data-feature-pills-close]attributeClose button. Closes the active pill, restores the cover visual, and resets [data-feature-pills-active] to "false". Only visible when a pill is open.
--content-item-expandedCSS custom propertyControls how wide the active pill grows. Set on [data-feature-pills-init]. Defaults to 25em on desktop, 50% on tablet, 100% on mobile via the provided media queries.

Notes

  • Requires GSAP loaded via CDN before the script runs.
  • Reduced motion: when prefers-reduced-motion is set, all GSAP animations are skipped and height/width are set instantly.
  • Resize is debounced at 200 ms. On resize the script recaptures collapsed widths and recalculates the active pill's open height to keep it correct at the new viewport.
  • The pill list can be a Webflow CMS List — add [data-feature-pills-item] to each list item and build the button + content structure inside.
  • Visual count should match pill count. A console.warn fires if they differ, but the component still works — mismatched indices simply won't activate a visual.

Guide

Locked accordion behavior

Only one pill can be open at a time. Clicking an already-open pill does nothing — this is intentional so links, buttons, or videos inside the expanded content remain interactive. The only ways to close a pill are the X close button or the Escape key.

Expanded width

The CSS custom property --content-item-expanded on [data-feature-pills-init] controls how wide the pill grows when open. You can override this per breakpoint in your own CSS.

[data-feature-pills-init] {
  --content-item-expanded: 25em;
}

Easing and duration

The animation feel is controlled by two variables at the top of each wrap's init block. Change them to adjust the bounce and speed globally for that instance.

const ease = "back.out(2)";
const animationDuration = 0.5;

Edit mode (Webflow Designer)

Add data-edit-mode="true" to the module root while building in Webflow. This hides the trigger buttons and makes the inner content divs visible and scrollable, so you can author the copy without triggering the JS animation.

Visual syncing

Visuals are matched to pills by DOM position — the 1st pill activates the 1st [data-feature-pills-visual], and so on. If the counts differ, a console warning is logged. The cover image is shown when no pill is open and hidden as soon as any pill opens.

Item structure reference

<li data-feature-pills-item data-active="false">
  <button data-feature-pills-button>
    Feature title
  </button>

  <div data-feature-pills-content>
    <div class="feature-pills__item-mask">
      <div data-feature-pills-inner>
        Feature description / copy goes here
      </div>
    </div>
  </div>
</li>