LayoutIntermediateApril 9, 2026

Layout Grid Flip

A card grid that animates between large (3-col) and small (5-col) layouts using GSAP Flip. Cards reflow with a smooth positional tween and the container height animates in sync. Subtitle text fades out in small view. Respects prefers-reduced-motion.

View Full Demo →
demo.jsx
import LayoutGridFlip, { LayoutGridCard } from "./index.jsx";
import { layoutGridFlip } from "@/content/layout/demo-data.js";
import styles from "./demo.module.css";

export default function LayoutGridFlipDemo() {
  return (
    <div className={styles.demo}>
      <h2 className={styles.heading}>Selected Work</h2>
      <LayoutGridFlip defaultLayout={layoutGridFlip.defaultLayout}>
        {layoutGridFlip.cards.map((card, i) => (
          <LayoutGridCard key={i} {...card} />
        ))}
      </LayoutGridFlip>
    </div>
  );
}
index.jsx
"use client";

import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
import styles from "./styles.module.css";

gsap.registerPlugin(Flip);

// — Card component —
export function LayoutGridCard({ title, subtitle, imageSrc, imageAlt = "", className = "" }) {
  return (
    <div data-layout-grid-item="" className={`${styles.card} ${className}`}>
      <div className={styles.cardImage}>
        <img
          src={imageSrc}
          alt={imageAlt}
          className={styles.cardImg}
        />
      </div>
      <div className={styles.cardBody}>
        <p data-layout-grid-item-title="" className={styles.cardTitle}>
          {title}
        </p>
        <p className={styles.cardSub}>{subtitle}</p>
      </div>
    </div>
  );
}

// — Grid flip wrapper —
export default function LayoutGridFlip({
  children,
  defaultLayout = "large",
  className = "",
}) {
  const groupRef = useRef(null);

  useEffect(() => {
    const group = groupRef.current;
    if (!group) return;

    const ACTIVE_CLASS = styles.active;
    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) return;

    buttons.forEach((b) =>
      b.setAttribute(
        "aria-pressed",
        String(b.getAttribute("data-layout-button") === defaultLayout)
      )
    );

    let activeTween = null;

    function handleClick(btn) {
      const targetLayout = btn.getAttribute("data-layout-button");
      const currentLayout = group.getAttribute("data-layout-status");
      if (currentLayout === targetLayout) return;

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

      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));
        });
        return;
      }

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

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

      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;

      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" });
          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;
    }

    buttons.forEach((btn) => {
      btn.addEventListener("click", () => handleClick(btn));
    });

    return () => {
      if (activeTween) activeTween.kill();
    };
  }, [defaultLayout]);

  return (
    <div
      ref={groupRef}
      data-layout-group=""
      data-layout-status={defaultLayout}
      className={`${styles.group} ${className}`}
    >
      {/* Toggle buttons */}
      <div className={styles.controls}>
        <button
          data-layout-button="large"
          aria-label="Large grid view"
          className={`${styles.toggleBtn} ${
            defaultLayout === "large" ? styles.active : ""
          }`}
        >
          <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
            <rect x="0" y="0" width="7" height="7" fill="currentColor" />
            <rect x="9" y="0" width="7" height="7" fill="currentColor" />
            <rect x="0" y="9" width="7" height="7" fill="currentColor" />
            <rect x="9" y="9" width="7" height="7" fill="currentColor" />
          </svg>
        </button>
        <button
          data-layout-button="small"
          aria-label="Small grid view"
          className={`${styles.toggleBtn} ${
            defaultLayout === "small" ? styles.active : ""
          }`}
        >
          <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
            <rect x="0" y="0" width="4" height="4" fill="currentColor" />
            <rect x="6" y="0" width="4" height="4" fill="currentColor" />
            <rect x="12" y="0" width="4" height="4" fill="currentColor" />
            <rect x="0" y="6" width="4" height="4" fill="currentColor" />
            <rect x="6" y="6" width="4" height="4" fill="currentColor" />
            <rect x="12" y="6" width="4" height="4" fill="currentColor" />
            <rect x="0" y="12" width="4" height="4" fill="currentColor" />
            <rect x="6" y="12" width="4" height="4" fill="currentColor" />
            <rect x="12" y="12" width="4" height="4" fill="currentColor" />
          </svg>
        </button>
      </div>

      {/* Grid */}
      <div data-layout-grid="" className={styles.grid}>
        <div data-layout-grid-collection="" className={styles.collection}>
          <div className={styles.list}>
            {children}
          </div>
        </div>
      </div>
    </div>
  );
}
demo.module.css
.demo {
  min-height: 100vh;
  background: #f5f4f0;
  padding: 5rem 5vw;
}

.heading {
  font-size: clamp(1.75rem, 4vw, 3rem);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.1;
  margin: 0 0 3rem;
  color: #111;
}
styles.module.css
/* Local tokens */
.group {
  --color-stroke-light: rgba(0, 0, 0, 0.1);
  --color-stroke: rgba(0, 0, 0, 0.25);
  --color-black: #111111;
  --color-gray: rgba(0, 0, 0, 0.45);
  --color-bg-secondary: rgba(0, 0, 0, 0.05);
  --font-body: 1rem;
  --font-body-sm: 0.8125rem;
  --weight-medium: 500;
  --space-2xs: 0.25rem;
  --space-xs: 0.5rem;
  --space-s: 0.75rem;
  --space-m: 1rem;
  --space-xl: 1.5rem;
  --space-2xl: 2rem;

  width: 100%;
}

/* Toggle controls */
.controls {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  margin-bottom: var(--space-2xl);
}

.toggleBtn {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 2.5em;
  height: 2.5em;
  padding: 0;
  background-color: transparent;
  border: 1px solid var(--color-stroke-light);
  border-radius: 0.25em;
  cursor: pointer;
  color: var(--color-gray);
  transition: color 200ms ease, border-color 200ms ease;
}

.toggleBtn.active {
  color: var(--color-black);
  border-color: var(--color-stroke);
}

.toggleBtn:hover {
  color: var(--color-black);
}

/* Grid wrapper */
.grid {
  width: 100%;
}

.collection {
  width: 100%;
  overflow: hidden;
}

.list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--column-gap, var(--space-xl));
}

/* Column system driven by data-layout-status */
:global([data-layout-status="large"]) {
  --columns: 3;
  --column-gap: var(--space-xl);
}

:global([data-layout-status="small"]) {
  --columns: 5;
  --column-gap: var(--space-m);
}

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

/* Card */
.card {
  display: flex;
  flex-direction: column;
  gap: var(--space-s);
  overflow: hidden;
}

.cardImage {
  position: relative;
  width: 100%;
  aspect-ratio: 4 / 5;
  border-radius: 0.5em;
  overflow: hidden;
  background-color: var(--color-bg-secondary);
}

.cardImg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.cardBody {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
}

.cardTitle {
  font-size: var(--font-body);
  font-weight: var(--weight-medium);
  line-height: 1.2;
  transition: font-size 0.8s cubic-bezier(0.65, 0, 0.1, 1);
}

.cardSub {
  font-size: var(--font-body-sm);
  color: var(--color-gray);
  transition: opacity 0.8s cubic-bezier(0.65, 0, 0.1, 1);
}

/* Small layout overrides */
:global([data-layout-status="small"]) .cardTitle {
  font-size: var(--font-body-sm);
}

:global([data-layout-status="small"]) .cardSub {
  opacity: 0;
  pointer-events: none;
}

:global([data-layout-status="large"]) .cardSub {
  transition-delay: 0.6s;
}

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

  :global([data-layout-status="small"]) {
    --columns: 2;
    --column-gap: var(--space-m);
  }
}
  • gsap

Apr 10, 2026

LAYOUT

Card Slider

A draggable horizontal card slider built with GSAP Draggable and InertiaPlugin. Supports snap-to-slide, momentum scrolling, mousewheel, keyboard navigation, and prev/next buttons. Responsive slide width via CSS container queries.

Apr 8, 2026

LAYOUT

Infinite Ticker

An infinitely scrolling horizontal text ticker that loops seamlessly. Items are duplicated and a CSS animation shifts the track by exactly 50%, creating a gapless loop without JavaScript.