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>
.boardis empty in markup — the JS builds.row× 6, each containing.tile× 6.- Each
.tilegets two faces built by JS:
``html <div class="tile"> <div class="tile-face tile-front"></div> <div class="tile-face tile-back"></div> </div> ``
#blocksis empty in markup — the JS fills it with.blockdivs (the cursor-grid overlay).- Nav label text is the neutral demo name (e.g.
Junni); the button readsFlip 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.
Nav — position: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.rowdivs, each with 6 tiles fromcreateTile(row, col).createTile(row, col)builds the two-face markup, then computesbgPosition = \${col * 20}% ${row * 20}%\`and writes it as inlinestyle.backgroundPositionon both.tile-frontand.tile-back. Columns 0–5 map to0% 20% 40% 60% 80% 100%horizontally; rows 0–5 the same vertically. Combined with the CSSbackground-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 ifDate.now() - lastEnterTime > COOLDOWN(1000 ms). UpdatelastEnterTime. 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
.tileNodeList (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), agsap.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"); ``
.setsnaps the tile to its current resting face (rotateX= 180 if the board is flipped, else 0) and zeroesrotateY.- Tween 1 spins ¾ of a turn (+270° on
rotateX) and tilts out totiltY,duration:0.5,ease:"power2.out". - Tween 2 completes the full turn (+360° total from the start) and returns
rotateYto 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°
rotateXflip that lands back on the same face (a flourish, not a reveal), withrotateYgoing 0 →tiltY→ 0. Which poster is visible is governed only byisFlipped.
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'rotateXto 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), appendnumCols * numRows.blockdivs (eachdata-index=i) into#blocks. Store{ numCols, numBlocks }onwindow.blockInfo.document.addEventListener("mousemove", highlightBlock): measure#blocksgetBoundingClientRect(),col = Math.floor((e.clientX - rect.left)/50),row = Math.floor((e.clientY - rect.top)/50),index = row * numCols + col. Add.highlighttoblocks.children[index], thensetTimeout(() => block.classList.remove("highlight"), 250). The CSStransition:border-color 0.3s easefades 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
isFlippedto 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-containerispointer-events:none(over the board,z-index:2), while the nav/button sit atz-index:10with pointer events re-enabled. - No console errors; nothing hijacks scroll (the board is a fixed full-screen stage).