NEMAWASHI LAB

Flip Grid

GSAP

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%.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
Flip Grid

Source

FlipGrid.tsx276 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe