Layout Grid Flip

Toggle between large and small grid layouts with a smooth GSAP Flip animation. Cards reposition from their old layout to the new one in a single coordinated move, with the container height tweening in sync.

gsapflipgridlayouttoggleanimationecommerce

Setup — External Scripts

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

Code

HTML
html
<div data-layout-status="large" data-layout-group class="layout-group">

  <!-- Toggle buttons -->
  <div class="layout-buttons">
    <button data-layout-button="large" class="layout-btn is--active">
      <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 12 12" fill="none" class="layout-btn__icon">
        <rect x="1" y="1" width="3.33333" height="3.33333" fill="currentColor"></rect>
        <rect x="1" y="7.60791" width="3.33333" height="3.33333" fill="currentColor"></rect>
        <rect x="7.66797" y="1" width="3.33333" height="3.33333" fill="currentColor"></rect>
        <rect x="7.66797" y="7.60791" width="3.33333" height="3.33333" fill="currentColor"></rect>
      </svg>
      <span class="layout-btn__label">Large</span>
    </button>
    <button data-layout-button="small" class="layout-btn">
      <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 12 12" fill="none" class="layout-btn__icon">
        <rect x="1" y="1" width="2.5" height="2.5" fill="currentColor"></rect>
        <rect x="4.75" y="1" width="2.5" height="2.5" fill="currentColor"></rect>
        <rect x="8.5" y="1" width="2.5" height="2.5" fill="currentColor"></rect>
        <rect x="1" y="4.75" width="2.5" height="2.5" fill="currentColor"></rect>
        <rect x="4.75" y="4.75" width="2.5" height="2.5" fill="currentColor"></rect>
        <rect x="8.5" y="4.75" width="2.5" height="2.5" fill="currentColor"></rect>
        <rect x="1" y="8.5" width="2.5" height="2.5" fill="currentColor"></rect>
        <rect x="4.75" y="8.5" width="2.5" height="2.5" fill="currentColor"></rect>
        <rect x="8.5" y="8.5" width="2.5" height="2.5" fill="currentColor"></rect>
      </svg>
      <span class="layout-btn__label">Small</span>
    </button>
  </div>

  <!-- Grid -->
  <div data-layout-grid class="layout-grid">
    <div data-layout-grid-collection class="layout-grid__collection">
      <div data-layout-grid-list class="layout-grid__list">

        <div data-layout-grid-item class="layout-grid__item">
          <div class="layout-grid__card">
            <div class="layout-grid__card-visual">
              <img src="https://cdn.prod.website-files.com/68e622e47687fdcc53e30b62/68e6389f51914d7b695bf687_Minimalist%20Dining%20Setup.avif" class="layout-grid__card-img"></div>
            <div class="layout-grid__card-details">
              <h2 data-layout-grid-item-title class="layout-grid__card-title">Dining Chair</h2>
              <h2 class="layout-grid__card-sub">€279</h2>
            </div>
          </div>
        </div>
        <div data-layout-grid-item class="layout-grid__item">
          <div class="layout-grid__card">
            <div class="layout-grid__card-visual">
              <img src="https://cdn.prod.website-files.com/68e622e47687fdcc53e30b62/68e6389f4d800086249e8fbb_Minimalist%20Living%20Room.avif" class="layout-grid__card-img"></div>
            <div class="layout-grid__card-details">
              <h2 data-layout-grid-item-title class="layout-grid__card-title">2-Seat Sofa</h2>
              <h2 class="layout-grid__card-sub">€999</h2>
            </div>
          </div>
        </div>
        <div data-layout-grid-item class="layout-grid__item">
          <div class="layout-grid__card">
            <div class="layout-grid__card-visual">
              <img src="https://cdn.prod.website-files.com/68e622e47687fdcc53e30b62/68e6389faac59c04a82a3391_Minimalist%20Interior%20Setup.avif" class="layout-grid__card-img"></div>
            <div class="layout-grid__card-details">
              <h2 data-layout-grid-item-title class="layout-grid__card-title">Lounge Chair</h2>
              <h2 class="layout-grid__card-sub">€449</h2>
            </div>
          </div>
        </div>
        <div data-layout-grid-item class="layout-grid__item">
          <div class="layout-grid__card">
            <div class="layout-grid__card-visual">
              <img src="https://cdn.prod.website-files.com/68e622e47687fdcc53e30b62/68e6389fc3b76bead9557598_Rustic%20Wooden%20Shelf.avif" class="layout-grid__card-img"></div>
            <div class="layout-grid__card-details">
              <h2 data-layout-grid-item-title class="layout-grid__card-title">Bookshelf</h2>
              <h2 class="layout-grid__card-sub">€129</h2>
            </div>
          </div>
        </div>
        <div data-layout-grid-item class="layout-grid__item">
          <div class="layout-grid__card">
            <div class="layout-grid__card-visual">
              <img src="https://cdn.prod.website-files.com/68e622e47687fdcc53e30b62/68e6389f9e0c6d1d3b33158c_Cozy%20Nightstand%20Setup.avif" class="layout-grid__card-img"></div>
            <div class="layout-grid__card-details">
              <h2 data-layout-grid-item-title class="layout-grid__card-title">Nightstand</h2>
              <h2 class="layout-grid__card-sub">€249</h2>
            </div>
          </div>
        </div>
        <div data-layout-grid-item class="layout-grid__item">
          <div class="layout-grid__card">
            <div class="layout-grid__card-visual">
              <img src="https://cdn.prod.website-files.com/68e622e47687fdcc53e30b62/68e6389f548fb9a8c6826e1a_Minimalist%20Wooden%20Desk.avif" loading="lazy" alt="" class="layout-grid__card-img"></div>
            <div class="layout-grid__card-details">
              <h2 data-layout-grid-item-title class="layout-grid__card-title">Wooden Desk</h2>
              <h2 class="layout-grid__card-sub">€549</h2>
            </div>
          </div>
        </div>
        <div data-layout-grid-item class="layout-grid__item">
          <div class="layout-grid__card">
            <div class="layout-grid__card-visual">
              <img src="https://cdn.prod.website-files.com/68e622e47687fdcc53e30b62/68e6389f587ac1f7bf546f80_Minimalist%20Console%20Table.avif" class="layout-grid__card-img"></div>
            <div class="layout-grid__card-details">
              <h2 data-layout-grid-item-title class="layout-grid__card-title">Low Cabinet</h2>
              <h2 class="layout-grid__card-sub">€679</h2>
            </div>
          </div>
        </div>
        <div data-layout-grid-item class="layout-grid__item">
          <div class="layout-grid__card">
            <div class="layout-grid__card-visual">
              <img src="https://cdn.prod.website-files.com/68e622e47687fdcc53e30b62/68e6389fcaa49c34b1ce959a_Modern%20Wooden%20Sofa.avif" class="layout-grid__card-img"></div>
            <div class="layout-grid__card-details">
              <h2 data-layout-grid-item-title class="layout-grid__card-title">Beige Blanket</h2>
              <h2 class="layout-grid__card-sub">€49</h2>
            </div>
          </div>
        </div>
        <div data-layout-grid-item class="layout-grid__item">
          <div class="layout-grid__card">
            <div class="layout-grid__card-visual">
              <img src="https://cdn.prod.website-files.com/68e622e47687fdcc53e30b62/68e6389fcd9a5a5b917c3e8d_Minimalist%20Wooden%20Shelf.avif" class="layout-grid__card-img">
            </div>
            <div class="layout-grid__card-details">
              <h2 data-layout-grid-item-title class="layout-grid__card-title">Display Cupboard</h2>
              <h2 class="layout-grid__card-sub">€899</h2>
            </div>
          </div>
        </div>

      </div>
    </div>
  </div>
</div>
CSS
css
.layout-buttons {
  grid-column-gap: .5em;
  grid-row-gap: .5em;
  justify-content: flex-start;
  align-items: center;
  padding: 1em 1em 3em;
  display: flex;
}

.layout-btn {
  grid-column-gap: .5em;
  grid-row-gap: .5em;
  background-color: #fff;
  border-radius: 100em;
  justify-content: flex-start;
  align-items: center;
  padding: .75em 1.25em;
  transition: color .2s, background-color .2s;
  display: flex;
}

.layout-btn.is--active {
  color: #fff;
  background-color: #201d1d;
}

.layout-btn__label {
  font-size: 1.5em;
  line-height: 1;
}

.layout-btn__icon {
  width: .75em;
}

.layout-grid {
  padding-bottom: 10em;
  padding-left: 1em;
  padding-right: 1em;
}

.layout-grid__list {
  grid-row-gap: 4em;
  column-gap: var(--column-gap);
  flex-flow: wrap;
  display: flex;
  position: relative;
}

.layout-grid__item {
  will-change: transform;
  position: relative;
}

.layout-grid__card {
  flex-flow: column;
  width: 100%;
  display: flex;
  position: relative;
}

.layout-grid__card-visual {
  aspect-ratio: 1 / 1.25;
  border-radius: .25em;
  overflow: hidden;
}

.layout-grid__card-img {
  object-fit: cover;
  width: 100%;
  height: 100%;
}

.layout-grid__card-title {
  margin-top: 0;
  margin-bottom: 0;
  font-size: 1.5em;
  line-height: 1.25;
}

.layout-grid__card-sub {
  opacity: .5;
  margin-top: 0;
  margin-bottom: 0;
  font-size: 1.5em;
  line-height: 1.25;
  transition: opacity .2s;
}

.layout-grid__card-details {
  height: 3.75em;
  margin-top: .75em;
}

/* ── Layout status: column counts ── */
[data-layout-status="large"] {
  --columns: 3;
  --column-gap: 1.5em;
}

[data-layout-status="small"] {
  --columns: 5;
  --column-gap: 1em;
}

[data-layout-grid-item] {
  width: calc((100% - (var(--columns) - 1) * var(--column-gap)) / var(--columns));
}

/* Title micro-motion between modes */
[data-layout-grid-item-title] {
  transition: all 0.8s cubic-bezier(0.65, 0, 0.1, 1);
}

/* Price fades in delayed on large mode */
[data-layout-status="large"] [data-layout-grid-item] .layout-grid__card-sub {
  transition-delay: 0.6s;
}

/* Small mode overrides */
[data-layout-status="small"] [data-layout-grid-item] .layout-grid__card-title {
  font-size: 1em;
}

[data-layout-status="small"] [data-layout-grid-item] .layout-grid__card-sub {
  opacity: 0;
  pointer-events: none;
}

/* ── Responsive ── */
@media screen and (max-width: 767px) {
  [data-layout-status="large"] {
    --columns: 1;
    --column-gap: 0em;
  }

  [data-layout-status="small"] {
    --columns: 2;
    --column-gap: 1em;
  }
}
JavaScript
javascript
function initGridLayoutFlip() {
  const groups = document.querySelectorAll("[data-layout-group]");
  const ACTIVE_CLASS = "is--active";

  groups.forEach((group) => {
    let activeTween = null;

    const buttons = group.querySelectorAll("[data-layout-button]");
    const grid = group.querySelector("[data-layout-grid]");
    const collection = group.querySelector("[data-layout-grid-collection]");

    if (!buttons.length || !grid || !collection) {
      console.warn("Layout Grid Flip: missing required elements.");
      return;
    }

    // Accessibility init
    buttons.forEach((b) =>
      b.setAttribute("aria-pressed", String(b.classList.contains(ACTIVE_CLASS)))
    );

    buttons.forEach((btn) => {
      btn.addEventListener("click", () => {
        const targetLayout = btn.getAttribute("data-layout-button");
        const currentLayout = group.getAttribute("data-layout-status");
        if (currentLayout === targetLayout) return;

        if (activeTween) { activeTween.kill(); activeTween = null; }

        // Reduced-motion: instant swap
        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
          group.setAttribute("data-layout-status", targetLayout);
          buttons.forEach((b) => {
            const isActive = b === btn;
            b.classList.toggle(ACTIVE_CLASS, isActive);
            b.setAttribute("aria-pressed", String(isActive));
          });
          window.ScrollTrigger?.refresh?.();
          if (window.lenis?.resize) window.lenis.resize();
          return;
        }

        // Capture positions before the layout change
        const items = grid.querySelectorAll("[data-layout-grid-item]");
        const state = Flip.getState(items, { simple: true });

        collection.getBoundingClientRect();
        const prevH = collection.offsetHeight;

        // Apply the new layout
        group.setAttribute("data-layout-status", targetLayout);
        buttons.forEach((b) => {
          const isActive = b === btn;
          b.classList.toggle(ACTIVE_CLASS, isActive);
          b.setAttribute("aria-pressed", String(isActive));
        });

        collection.getBoundingClientRect();
        const nextH = collection.offsetHeight;

        // Lock height so items can go absolute without collapsing the container
        gsap.set(collection, { height: prevH });

        const tl = gsap.timeline({
          onStart: () => group.setAttribute("data-transitioning", "true"),
          onInterrupt: () => {
            group.removeAttribute("data-transitioning");
            gsap.set(collection, { clearProps: "height" });
          },
          onComplete: () => {
            group.removeAttribute("data-transitioning");
            gsap.set(collection, { clearProps: "height" });
            window.ScrollTrigger?.refresh?.();
            if (window.lenis?.resize) window.lenis.resize();
            activeTween = null;
          },
        });

        tl
          .add(Flip.from(state, {
            duration: 0.65,
            ease: "power4.inOut",
            absolute: true,
            nested: true,
            prune: true,
            stagger: targetLayout === "large"
              ? { each: 0.03, from: "end" }
              : { each: 0.03, from: "start" },
          }), 0)
          .to(collection, {
            height: nextH,
            duration: 0.65,
            ease: "power4.inOut",
          }, 0);

        activeTween = tl;
      });
    });
  });
}

document.addEventListener('DOMContentLoaded', () => {
  initGridLayoutFlip();
});

Attributes

NameTypeDefaultDescription
[data-layout-group]attributeThe outermost wrapper. The script scopes all queries here, so multiple independent instances on the same page work without interference.
[data-layout-status]attribute"large" | "small"Declares the current layout mode on the group wrapper. The script reads this to check whether a layout change is needed and sets it to the target value before animating. CSS also reads this to apply --columns and --column-gap.
[data-layout-button]attribute"large" | "small"Add to each toggle button. The attribute value must match the corresponding [data-layout-status] value exactly. Clicking sets the group to that layout.
[data-layout-grid]attributeScopes the Flip item query to this grid element only.
[data-layout-grid-collection]attributeThe visual wrapper around the list. The script locks its height before Flip runs and tweens it to the new height in sync, preventing container collapse during animation.
[data-layout-grid-list]attributeDirect wrapper of the card items. Manages wrapping and gaps via CSS custom properties.
[data-layout-grid-item]attributeEach card element. Recorded by Flip.getState() before the layout change and animated from its old to its new position.
[data-layout-grid-item-title]attributeOptional. Add to the card heading to enable CSS-driven micro-motion (e.g. font-size change) between modes. Uses a CSS transition — do not use it for changes that affect card height, as this can interfere with Flip measurements.
[data-transitioning]attributeSet to "true" by the script during the animation and removed on complete/interrupt. Use it in CSS to disable pointer events or add transitioning styles.

Notes

  • Requires GSAP and the Flip plugin loaded via CDN before the script runs.
  • The attribute value on [data-layout-button] must exactly match the value used on [data-layout-status]. Mismatches will silently prevent the animation from running.
  • Avoid CSS changes that affect card or container height inside [data-layout-grid-item-title] transitions — Flip captures heights before and after the switch; unexpected height changes will cause visual glitches.
  • If ScrollTrigger or Lenis are present on the page, they are automatically refreshed after each transition completes.
  • Multiple [data-layout-group] instances on the same page are fully supported.

Guide

Column count

Define --columns and --column-gap per layout status. The card width is calculated automatically from these two variables. Add @media overrides to make the grid responsive.

[data-layout-status="large"] {
  --columns: 3;
  --column-gap: 1.5em;
}

[data-layout-status="small"] {
  --columns: 5;
  --column-gap: 1em;
}

@media screen and (max-width: 767px) {
  [data-layout-status="large"] { --columns: 1; --column-gap: 0; }
  [data-layout-status="small"] { --columns: 2; --column-gap: 1em; }
}

Card micro-motion

Use [data-layout-grid-item-title] on the heading to add a CSS transition between modes (e.g. font-size). Avoid CSS changes that affect card height — Flip measures heights before and after the layout switch, and unexpected height changes will break the animation.

Reduced motion

When prefers-reduced-motion is active, the script skips the Flip animation and instantly applies the new layout. ScrollTrigger and Lenis are refreshed afterward in both cases.