All components

Junni Tile Flip Board

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A full-screen 6x6 grid of 3D tiles, each showing a slice of a shared image split by background-position. Hovering a tile triggers a GSAP timeline that spins it a full turn on rotateX with a column-based rotateY tilt; a 'Flip Tiles' button flips the whole board with gsap.to and a random stagger, swapping between the front and back images. A fixed grid overlay highlights cells under the cursor on mousemove.

How it's built

Categoryinteractive
Techgsap
Complexitypage
Performance costlight
Mobile-safedesktop-first

3d-flip grid hover tiles gsap perspective cursor-grid bold editorial

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Tile Flip Board — full-screen 6×6 grid of 3D tiles, hover spin + board-wide flip reveal

Goal

Build a full-viewport 6×6 grid of 3D flip tiles. Each tile shows a slice of one shared poster image (the image is split across the whole grid via background-position), so at rest the 36 tiles reconstruct a single full-bleed poster. Hovering a tile fires a GSAP timeline that spins it a complete 360° turn on rotateX while tilting on rotateY (the tilt magnitude/direction depends on the tile's column, so tiles lean away from the board's vertical centreline). A "Flip Tiles" button flips the entire board 180° on rotateX with a random-order stagger, swapping every tile from the front poster to a second (back) poster and back again. A fixed grid overlay highlights the single 50×50px cell under the cursor on every mousemove, leaving a fading white-outline trail.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no ScrollTrigger, no smooth-scroll, no Three.js. Everything runs after DOMContentLoaded. Ship one index.html (<link rel="stylesheet" href="./styles.css">, <script type="module" src="./script.js">), one styles.css, one ES-module script.js. Must run in a fresh Vite + npm project.

Layout / HTML

<nav>
  <a href="#">Junni</a>
  <button id="flipButton">Flip Tiles</button>
</nav>

<section class="board"></section>

<div class="blocks-container">
  <div id="blocks"></div>
</div>
  • .board is empty in markup — the JS builds .row × 6, each containing .tile × 6.
  • Each .tile gets two faces built by JS:

``html <div class="tile"> <div class="tile-face tile-front"></div> <div class="tile-face tile-back"></div> </div> ``

  • #blocks is empty in markup — the JS fills it with .block divs (the cursor-grid overlay).
  • Nav label text is the neutral demo name (e.g. Junni); the button reads Flip Tiles. No real brand names.

Styling

Reset: * { margin:0; padding:0; box-sizing:border-box; }. html, body { width:100%; height:100%; }.

Fonts: nav link and button use a bold uppercase display font (the original uses a custom face named "MOSKO MAPPA"; since there's no @font-face, just declare it with a heavy display/grotesque fallback stack, e.g. "MOSKO MAPPA", "Anton", "Archivo Black", sans-serif). Everything is text-transform: uppercase.

Navposition:absolute; top:0; left:0; width:100vw; display:flex; justify-content:space-between; align-items:center; padding:2em; z-index:10; pointer-events:none;. Children re-enable pointer events (pointer-events:all).

  • nav a: color:#fff; text-decoration:none; text-transform:uppercase; font-size:28px;.
  • nav button: border:none; outline:none; color:#fff; background-color:#000; border-radius:0.25em; padding:0.65em 1em 0.25em 1em; text-transform:uppercase; font-size:24px;.

Board (the 3D stage).board { width:100vw; height:100vh; padding:0.25em; display:flex; flex-direction:column; gap:0.25em; perspective:1000px; background-color:#000; position:relative; z-index:1; }. The perspective:1000px is load-bearing — it gives the tile spins their 3D depth.

Row.row { flex:1; display:flex; gap:0.25em; } (6 equal rows stacked; each row's 6 tiles are equal flex columns). The 0.25em gaps + black board background read as thin black gridlines between tiles.

Tile.tile { flex:1; position:relative; transform-style:preserve-3d; }. transform-style:preserve-3d is essential so the two faces occupy real 3D space and the back hides when facing away.

Faces.tile-face { position:absolute; width:100%; height:100%; backface-visibility:hidden; border-radius:0.5em; overflow:hidden; }. backface-visibility:hidden is what makes each face vanish when rotated past 90°.

  • .tile-front { background-color:darkslategrey; }
  • .tile-back { background-color:darkslategrey; transform:rotateX(180deg); } — the back face is pre-rotated 180° on X so it reads correctly once the tile flips.

Image slicing (the reconstruction trick) — each face paints its image through a ::before:

.tile-front::before,
.tile-back::before {
  content:"";
  position:absolute;
  top:0; left:0; right:0; bottom:0;
  background-size:600% 600%;          /* 6× the tile → one image spans the whole 6×6 board */
  background-position:inherit;         /* inherits the per-tile position set inline by JS */
  clip-path:inset(0 round 0.25em);
}
.tile-front::before { background-image:url("<front poster image>"); }
.tile-back::before  { background-image:url("<back poster image>"); }

Both faces share background-size:600% 600%; the JS writes each face's background-position inline so the 36 tiles crop a 6×6 mosaic that reconstructs the whole poster.

Cursor-grid overlay.blocks-container { position:fixed; top:0; left:0; width:100vw; height:100vh; overflow:hidden; pointer-events:none; z-index:2; }. #blocks { width:105vw; height:100vh; display:flex; flex-wrap:wrap; justify-content:flex-start; align-content:flex-start; overflow:hidden; }. .block { width:50px; height:50px; border:0.5px solid transparent; transition:border-color 0.3s ease; }. .highlight { border-color:#fff; } — the highlighted cell shows a thin white outline that fades in/out over the border-color transition.

GSAP effect (be exhaustive)

Imports & constants
import gsap from "gsap";

const ROWS = 6;
const COLS = 6;
const BLOCK_SIZE = 50;
const COOLDOWN = 1000;   // ms hover throttle per tile
let isFlipped = false;   // module-level board flip state (front vs back poster)
Building the board
  • createBoard() appends 6 .row divs, each with 6 tiles from createTile(row, col).
  • createTile(row, col) builds the two-face markup, then computes bgPosition = \${col * 20}% ${row * 20}%\` and writes it as inline style.backgroundPosition on both .tile-front and .tile-back. Columns 0–5 map to 0% 20% 40% 60% 80% 100% horizontally; rows 0–5 the same vertically. Combined with the CSS background-size:600% 600%`, each tile crops its unique 1/6 × 1/6 slice.
1. Hover spin (per tile, mouseenter)

Attach to every .tile (query after building). Keep a per-tile lastEnterTime = 0.

  • On mouseenter, throttle: only proceed if Date.now() - lastEnterTime > COOLDOWN (1000 ms). Update lastEnterTime. Re-entering the same tile within 1s does nothing (an animating tile can't be re-triggered mid-spin).
  • Compute the tilt from the tile's index in the flat .tile NodeList (index % 6 = its column):

| index % 6 (column) | 0 | 1 | 2 | 3 | 4 | 5 | |---|---|---|---|---|---|---| | tiltY (deg) | −40 | −20 | −10 | +10 | +20 | +40 |

Left half tilts negative, right half positive, magnitude grows toward the outer columns — the board "fans" away from its vertical centre.

  • Call animateTile(tile, tiltY), a gsap.timeline() with three steps:

``js gsap.timeline() .set(tile, { rotateX: isFlipped ? 180 : 0, rotateY: 0 }) .to(tile, { rotateX: isFlipped ? 450 : 270, rotateY: tiltY, duration: 0.5, ease: "power2.out" }) .to(tile, { rotateX: isFlipped ? 540 : 360, rotateY: 0, duration: 0.5, ease: "power2.out" }, "-=0.25"); ``

  • .set snaps the tile to its current resting face (rotateX = 180 if the board is flipped, else 0) and zeroes rotateY.
  • Tween 1 spins ¾ of a turn (+270° on rotateX) and tilts out to tiltY, duration:0.5, ease:"power2.out".
  • Tween 2 completes the full turn (+360° total from the start) and returns rotateY to 0, duration:0.5, ease:"power2.out", positioned "-=0.25" so it overlaps the first tween by 0.25s (the two half-second tweens run ~0.75s total).
  • Net motion: a complete 360° rotateX flip that lands back on the same face (a flourish, not a reveal), with rotateY going 0 → tiltY → 0. Which poster is visible is governed only by isFlipped.
2. Board flip (#flipButton click)
flipButton.addEventListener("click", () => flipAllTiles(tiles));

function flipAllTiles(tiles) {
  isFlipped = !isFlipped;
  gsap.to(tiles, {
    rotateX: isFlipped ? 180 : 0,
    duration: 1,
    stagger: { amount: 0.5, from: "random" },
    ease: "power2.inOut",
  });
}
  • Toggles isFlipped, then tweens all 36 tiles' rotateX to an absolute 180° (or back to 0°), duration:1, ease:"power2.inOut".
  • stagger:{ amount:0.5, from:"random" } — the 36 flips are spread across a total of 0.5s in random order, so the board flips as a scattered wave. This is the real front↔back poster reveal (the hover spin never changes the visible face).
3. Cursor grid overlay (mousemove)
  • createBlocks(): numCols = Math.ceil(window.innerWidth / 50), numRows = Math.ceil(window.innerHeight / 50), append numCols * numRows .block divs (each data-index=i) into #blocks. Store { numCols, numBlocks } on window.blockInfo.
  • document.addEventListener("mousemove", highlightBlock): measure #blocks getBoundingClientRect(), col = Math.floor((e.clientX - rect.left)/50), row = Math.floor((e.clientY - rect.top)/50), index = row * numCols + col. Add .highlight to blocks.children[index], then setTimeout(() => block.classList.remove("highlight"), 250). The CSS transition:border-color 0.3s ease fades the white outline in and out, so the cursor drags a trail of briefly-lit grid cells.
Timing / easing summary

| Action | property | from → to | duration | ease | stagger / position | |---|---|---|---|---|---| | hover — set | rotateX / rotateY | → 0 or 180 / 0 (snap) | — | — | timeline start | | hover — tween 1 | rotateX / rotateY | +270° / 0 → tiltY | 0.5 | power2.out | — | | hover — tween 2 | rotateX / rotateY | +360° total / tiltY → 0 | 0.5 | power2.out | "-=0.25" overlap | | board flip | rotateX | 0 ↔ 180 (absolute) | 1 | power2.inOut | { amount:0.5, from:"random" } | | cell highlight | border-color (CSS) | transparent → #fff → transparent | 0.3s CSS | ease | 250ms hold |

Init order

init() runs on DOMContentLoaded: createBoard() → attach tile mouseenter handlers + button click → window.blockInfo = createBlocks() → add the mousemove listener.

Assets / images

2 full-bleed landscape posters (roughly viewport aspect, ~16:9 / 3:2), each displayed once but sliced across the entire 6×6 grid:

  • Front poster — a dark charcoal/near-black background with a large bold white display wordmark centred, and a small handwritten-script tagline beneath it. This is the board's initial (unflipped) state. Use neutral invented text, no real brands.
  • Back poster — an electric-blue background with a bold white all-caps headline, two lines of smaller uppercase subtext, and a small monogram/mark. This is revealed after pressing Flip Tiles. Neutral invented text, no real brands.

Because background-size:600% 600% stretches each image to 6× the tile box, any image reconstructs seamlessly across the board; pick landscape art whose composition reads well when tiled into 36 rounded cells.

Behavior notes

  • Desktop / mouse-driven — the whole thing is hover- and mousemove-based; there's no touch fallback (leave as-is).
  • Hover throttle — each tile's 1000ms cooldown means a spin always finishes before it can be re-triggered; sweeping the cursor across the board fires a lively cascade of independent 360° spins, one per fresh tile.
  • Hover vs flip are independent — the hover timeline reads isFlipped to spin around whichever face is currently showing but always returns to it; only the button changes which poster is face-up.
  • Cursor grid never blocks clicks.blocks-container is pointer-events:none (over the board, z-index:2), while the nav/button sit at z-index:10 with pointer events re-enabled.
  • No console errors; nothing hijacks scroll (the board is a fixed full-screen stage).