Sixteen numbered tiles transition between four layout modes -- grid, row, stack, and scatter -- using GSAP Flip. On each switch Flip.getState snapshots every .flip-item, React re-renders the new flex/size styles, then Flip.from animates the delta with power2.inOut easing and a 0.02s stagger. Entering elements scale from 0, leaving ones scale to 0. The scatter layout assigns each tile a diameter of 50 + (i%5)*20 px with border-radius 50%.
Source
"use client";
import { useRef, useState, useCallback } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { Flip } from "gsap/Flip";
import ScrambleLink from "../ScrambleLink";
gsap.registerPlugin(useGSAP, Flip);
const ITEMS = Array.from({ length: 16 }, (_, i) => {
const hue = (i * 22.5) % 360;
return {
id: `item-${i}`,
label: String(i + 1).padStart(2, "0"),
bg: `hsl(${hue}, 50%, ${i % 2 === 0 ? 20 : 85}%)`,
color: i % 2 === 0 ? "#fff" : "#1a1a2e",
};
});
type Layout = "grid" | "row" | "stack" | "scatter";
const LAYOUTS: { label: string; value: Layout }[] = [
{ label: "Grid", value: "grid" },
{ label: "Row", value: "row" },
{ label: "Stack", value: "stack" },
{ label: "Scatter", value: "scatter" },
];
export default function FlipGrid() {
const containerRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const [layout, setLayout] = useState<Layout>("grid");
const [dark, setDark] = useState(true);
const switchLayout = useCallback(
(next: Layout) => {
if (!wrapRef.current || next === layout) return;
const state = Flip.getState(".flip-item");
setLayout(next);
requestAnimationFrame(() => {
Flip.from(state, {
duration: 0.7,
ease: "power2.inOut",
stagger: 0.02,
absolute: true,
onEnter: (elements) =>
gsap.fromTo(elements, { opacity: 0, scale: 0 }, { opacity: 1, scale: 1, duration: 0.5 }),
onLeave: (elements) =>
gsap.to(elements, { opacity: 0, scale: 0, duration: 0.3 }),
});
});
},
[layout]
);
const layoutStyle = (): React.CSSProperties => {
const base: React.CSSProperties = {
position: "relative",
display: "flex",
transition: "none",
};
switch (layout) {
case "grid":
return {
...base,
flexWrap: "wrap",
gap: 12,
width: 4 * 100 + 3 * 12,
justifyContent: "center",
};
case "row":
return {
...base,
flexWrap: "nowrap",
gap: 6,
width: "auto",
justifyContent: "center",
alignItems: "center",
};
case "stack":
return {
...base,
flexWrap: "wrap",
gap: 0,
width: 120,
justifyContent: "center",
};
case "scatter":
return {
...base,
flexWrap: "wrap",
gap: 16,
width: 600,
justifyContent: "center",
};
}
};
const itemStyle = (i: number): React.CSSProperties => {
switch (layout) {
case "grid":
return { width: 100, height: 100, borderRadius: 12 };
case "row":
return { width: 40, height: 120, borderRadius: 8 };
case "stack":
return {
width: 120,
height: 30,
borderRadius: 4,
marginTop: i > 0 ? -8 : 0,
};
case "scatter": {
const size = 50 + (i % 5) * 20;
return { width: size, height: size, borderRadius: "50%" };
}
}
};
return (
<div
ref={containerRef}
className="fixed inset-0 flex items-center justify-center select-none overflow-hidden"
style={{
background: dark ? "#0a0a0a" : "#f8f7f4",
transition: "background 0.4s ease",
}}
>
{/* Items */}
<div ref={wrapRef} style={layoutStyle()}>
{ITEMS.map((item, i) => (
<div
key={item.id}
className="flip-item"
data-flip-id={item.id}
style={{
...itemStyle(i),
backgroundColor: item.bg,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 4px 16px rgba(0,0,0,0.15)",
willChange: "transform",
}}
>
<span
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 500, 'SRIF' 0",
fontSize: layout === "stack" ? 10 : layout === "row" ? 11 : 14,
color: item.color,
opacity: 0.8,
}}
>
{item.label}
</span>
</div>
))}
</div>
{/* Controls */}
<div
style={{
position: "fixed",
right: 24,
top: "50%",
transform: "translateY(-50%)",
zIndex: 20,
width: 200,
background: dark ? "rgba(20,20,20,0.92)" : "rgba(255,255,255,0.92)",
backdropFilter: "blur(12px)",
borderRadius: 16,
padding: "20px 22px",
boxShadow: dark
? "0 2px 20px rgba(0,0,0,0.3)"
: "0 2px 20px rgba(0,0,0,0.06)",
border: dark
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(0,0,0,0.06)",
transition: "all 0.4s ease",
}}
>
<span
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 500, 'SRIF' 100",
fontSize: 12,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: dark ? "#fff" : "#1a1a2e",
}}
>
Flip Grid
</span>
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 6 }}>
{LAYOUTS.map((l) => (
<button
key={l.value}
onClick={() => switchLayout(l.value)}
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 300, 'SRIF' 100",
fontSize: 11,
letterSpacing: "0.06em",
textAlign: "left",
padding: "6px 10px",
borderRadius: 8,
border:
layout === l.value
? dark
? "1px solid rgba(255,255,255,0.3)"
: "1px solid rgba(26,26,46,0.2)"
: dark
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(0,0,0,0.06)",
background:
layout === l.value
? dark
? "rgba(255,255,255,0.1)"
: "rgba(26,26,46,0.06)"
: "transparent",
color: dark ? "#fff" : "#1a1a2e",
cursor: "pointer",
transition: "all 0.2s ease",
}}
>
{l.label}
</button>
))}
</div>
<button
onClick={() => setDark((v) => !v)}
style={{
width: "100%",
padding: "8px 0",
marginTop: 16,
borderRadius: 8,
border: dark
? "1px solid rgba(255,255,255,0.2)"
: "1px solid rgba(26,26,46,0.1)",
background: dark
? "rgba(255,255,255,0.1)"
: "rgba(26,26,46,0.04)",
color: dark ? "#fff" : "#1a1a2e",
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 500, 'SRIF' 100",
fontSize: 11,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
cursor: "pointer",
transition: "all 0.3s ease",
}}
>
{dark ? "Dark" : "Light"}
</button>
</div>
{/* Nav */}
<div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
<ScrambleLink
from="FLIP GRID"
to="← LAB"
href="/lab"
className="text-[11px] tracking-[0.1em] text-foreground/30 uppercase hover:text-foreground/60 transition-colors"
style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }}
/>
</div>
</div>
);
}