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 →Preview
Source
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);
}
}
Dependencies
gsap