All components

Products Lightbox Gallery

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

Open live demo ↗ Raw prompt (.md)

What it does

A large draggable canvas holds a 12x12 grid of sneaker product tiles that the user pans by mouse-dragging, with movement GSAP-tweened (x/y, power1.out) for inertial smoothing. Clicking a tile (drag vs click is disambiguated via mousedown/mousemove flags) fades in a full-screen lightbox modal populated with that product's image, name and info via gsap.to opacity 0 to 1.

How it's built

Categorygallery
Techgsap
Complexitypage
Performance costlight
Mobile-safedesktop-first

draggable lightbox modal product-grid gsap canvas-pan ecommerce minimal

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

Products Lightbox Gallery

Goal

Build an explorable product wall: a huge fixed-size canvas (a 12×12 grid of 144 product tiles) that the user pans around by click-dragging with the mouse, where the drag follows the cursor with an eased, slightly inertial lag. Clicking a single tile (as opposed to dragging) fades in a full-screen lightbox modal that shows that product's image, name and info plus a "users also bought" panel. The two star effects are (1) the GSAP-smoothed drag-to-pan of the oversized grid and (2) the opacity cross-fade of the lightbox on open/close, with a precise drag-vs-click disambiguation so panning never accidentally opens a product.

Tech

Vanilla HTML/CSS/JS with ES module imports, bundled by Vite. Use gsap (npm) onlyno GSAP plugins, no ScrollTrigger, no SplitText, no CustomEase, no smooth-scroll library, no requestAnimationFrame/lerp loop, no Three.js. Every motion is a plain gsap.to() / gsap.set() / gsap.getProperty() call driven by native mouse events. Import:

import gsap from "gsap";

The modal close button uses the Ionicons web component (<ion-icon name="close-outline">), loaded via its ESM script from a CDN (ionicons@7.1.0, type="module" + nomodule fallback pair in <head>). If you'd rather not add Ionicons, put a plain "×" glyph inside the close button — the animation only needs a clickable circle.

Wrap all JS in document.addEventListener("DOMContentLoaded", () => { … }).

Layout / HTML

Two independent pieces at the top level of <body>: an empty #container (the grid is generated entirely in JS) and a .modal authored statically in HTML (populated on click).

<div id="container"></div>

<div class="modal">
  <div class="col product-view">
    <div class="close-btn">
      <div class="close"><ion-icon name="close-outline"></ion-icon></div>
    </div>
    <div class="product-img">
      <div class="product-img-container">
        <img src="/img/img1.png" alt="" />
      </div>
    </div>
    <div class="product-name">
      <div class="name">
        <h1>White | Phantom</h1>
        <p>The Volley Advantage</p>
      </div>
      <div class="cart-btn"><button>Add to cart</button></div>
    </div>
  </div>

  <div class="col product-info">
    <div>
      <p>Neutral placeholder paragraph, one or two sentences of product copy.</p>
      <p>A second, longer placeholder paragraph describing the product in three or four sentences.</p>
    </div>
    <div class="suggestions">
      <p>Users also bought</p>
      <div class="box"><img class="img" src="/img/img4.png"><p>The Volley Clubhouse</p><h1>White | Flint</h1></div>
      <div class="box"><img class="img" src="/img/img2.png"><p>The Volley Advantage</p><h1>White | Sand</h1></div>
    </div>
  </div>
</div>

<script type="module" src="./script.js"></script>
  • #container starts empty; JS creates 144 .box tiles inside it, each tile = one <img class="img"> + one .content wrapper holding a <p> (product line) and an <h1> (colorway name).
  • .modal is a two-column flex row: left .col.product-view (the big product view, flex: 3) and right .col.product-info (copy + suggestions, flex: 2).
  • Keep all copy neutral/fictional — no real brand or client names anywhere. Invent a fictional shoe line; use product lines like "The Volley Advantage", "The Volley Clubhouse", "The Volley Centercourt" and colorway names like "White | Sand", "White | Rust", "White | Phantom".

Styling

Global reset: * { margin:0; padding:0; box-sizing:border-box }. html, body { height:100%; overflow:hidden; font-family:"Neue Montreal" } (any clean neutral sans-serif fallback is fine). The page never scrolls — panning is done by dragging the oversized canvas, so overflow:hidden is load-bearing.

Base typography:

  • img { width:100%; height:80%; object-fit:contain } (images are letterboxed inside their tile, not cropped).
  • h1 { margin:5px 0; font-size:26px; font-weight:500 }
  • p { font-size:10px; text-transform:uppercase; font-weight:500; margin-top:-15px; color:gray }
  • ion-icon { position:relative; top:1.5px; font-size:18px }

The grid canvas (the thing you drag):

  • #container: width:2080px; height:2880px; background:#ccc; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%). JS overrides the width to columns * boxSize = 12 * 240 = 2880px, so the live canvas is 2880×2880, a solid #ccc square centered in the viewport and far larger than the screen (only a portion is ever visible → you drag to explore).
  • .box: width:240px; height:240px; background:#f4efef; border:1px solid #dadada; float:left; padding:10px (border-box, so 240 includes border+padding → exactly 12 tiles per row, 12 rows = 144 tiles perfectly tiling the 2880² canvas). Each tile shows its contained product image on top and its .content (uppercase gray line + 26px name) below.
  • .suggestions .box p { margin:-15px 0 0 0 } (tightens the label above the name inside suggestion cards).

The lightbox modal:

  • .modal: position:fixed; width:100vw; height:100vh; top:0; left:0; background:#f4efef; z-index:2; display:none. It is hidden by default and switched to display:flex (a two-column row) by JS on open. Note there is no opacity declared in CSS, so its resting opacity is 1 (see the fade quirk in the GSAP section).
  • .col { height:100%; padding:2em }.
  • .product-view { position:relative; flex:3; display:flex; flex-direction:column }.
  • .product-info { flex:2; display:flex; flex-direction:column; justify-content:space-between; padding-bottom:5em }.
  • .close-btn { position:absolute; right:0; width:40px; height:40px; display:flex; justify-content:center; align-items:center; background:#000; color:#fff; border-radius:100%; cursor:pointer } (a black circle with a white close icon, pinned top-right of the product view).
  • .product-img { flex:5; padding-top:60px } (the dominant hero image area).
  • .product-name { width:100%; height:100%; flex:3; display:flex; justify-content:space-between; align-items:center; padding:0 20px }.
  • .product-name h1 { font-size:60px; letter-spacing:-2px } (large product title).
  • .product-name p { margin:10px 0; font-size:15px }.
  • .cart-btn button { background:#000; color:#fff; border:none; outline:none; padding:15px 40px; border-radius:4px; font-size:12px; text-transform:uppercase } (a black "Add to cart" pill).
  • .product-info p { margin-top:0; margin-bottom:20px; font-size:20px; text-transform:none; color:#555555; line-height:100%; font-weight:400 } (the body copy overrides the tiny uppercase gray p default).
  • .suggestions p { text-transform:uppercase; font-size:12px; font-weight:500; color:#7a7a7a } ("Users also bought" label).
  • Inside .suggestions, the two .box cards inherit the same 240×240 tile look and float:left side by side, each with a contained image + line + name.

GSAP effect (exhaustive)

There is no timeline and no load animation — nothing moves until the user interacts. Three independent interaction handlers drive everything. Precise spec:

1. Grid generation (JS, no animation)

Constants: boxCount = 12 * 12 (144), boxSize = 240, totalImages = 5, columns = 12. Set container.style.width = columns * boxSize + "px" (2880px). Define a products array of 15 objects { info, name } (the fictional lines + colorways). Then loop for (let i = 0; i < boxCount; i++):

  • Create .box; create img.img with src = "/img/img" + ((i % totalImages) + 1) + ".png" → the 5 images cycle (img1…img5, img1…) across the whole wall.
  • product = products[i % products.length] → the 15 products cycle independently, so image↔product pairing repeats every 15 tiles.
  • Create <p> = product.info, <h1> = product.name, wrap both in .content, append img + content to the box, append box to #container.
  • Attach the three per-tile listeners below.
2. Drag-to-pan the whole canvas (the smoothed inertial pan)

State: isContainerDragging = false, startCoords = {x,y}, startTranslate = {x,y}. Listeners on #container: mousedown → onDragStart, mouseup → onDragEnd, mouseleave → onDragEnd, mousemove → onDrag.

  • onDragStart(e): isContainerDragging = true; record startCoords = {x:e.clientX, y:e.clientY}; read the current transform with startTranslate.x = gsap.getProperty(container, "x") and startTranslate.y = gsap.getProperty(container, "y"); then gsap.set(container, { cursor:"grabbing" }) and gsap.set(container, { userSelect:"none" }).
  • onDrag(e): if not dragging, return. e.preventDefault(). Compute deltaX = e.clientX - startCoords.x, deltaY = e.clientY - startCoords.y, then translateX = startTranslate.x + deltaX, translateY = startTranslate.y + deltaY, and animate:

``js gsap.to(container, { x: translateX, y: translateY, duration: 0.5, ease: "power1.out" }); ` This is the whole "inertia" trick: every mousemove fires a fresh 0.5s power1.out` tween toward the latest cursor-derived target. Because a new tween continuously overrides the previous one before it finishes, the canvas eases toward the cursor with a soft trailing lag rather than snapping 1:1 — and when the user stops moving, the last tween runs out its 0.5s, giving a gentle glide-to-rest. (No lerp loop or Draggable plugin — the smoothing is purely the overlapping short tweens.)

  • onDragEnd(): if not dragging, return; isContainerDragging = false; gsap.set(container, { cursor:"grab" }); gsap.set(container, { userSelect:"auto" }).
3. Tile click → open lightbox (with drag-vs-click disambiguation)

Each .box carries its own isDragging / isClicking flags:

  • box.mousedown → isDragging = false; isClicking = true.
  • box.mousemove → isDragging = true; isClicking = false (any movement over the tile marks it a drag, not a click).
  • box.click: only if !isDragging && isClicking (i.e. a clean click with no intervening move) do:

``js gsap.set(modal, { display: "flex" }); gsap.to(modal, { opacity: 1, duration: 0.4 }); productImg.src = img.src; // copy this tile's image into the hero modalProductName.textContent = product.name; // .modal .product-name h1 modalProductInfo.textContent = product.info; // .modal .product-name p ` So the modal is un-hidden (display:flex), its opacity is tweened toward 1` over 0.4s, and the clicked product's image + name + line are injected into the product view.

4. Close button → fade out & hide

.close-btn click:

gsap.to(modal, {
  opacity: 0,
  duration: 0.4,
  onComplete: () => gsap.set(modal, { display: "none" }),
});

The modal fades opacity 1 → 0 over 0.4s, then onComplete sets display:none to fully remove it from layout.

Opacity fade quirk (reproduce as-is): because CSS never sets an initial opacity, the modal's resting opacity is 1. On the very first open, gsap.to(opacity:1) is a no-op (already 1) so it appears instantly; the close tween takes it to 0; every subsequent open then animates 0 → 1, a visible 0.4s fade. This is the original behavior. (Optional polish: add opacity:0 to .modal in CSS so even the first open fades in — but the reference does not.)

Eases/durations summary: pan = duration:0.5, ease:"power1.out" (re-fired per mousemove); modal open fade = duration:0.4 (default ease); modal close fade = duration:0.4 (default ease) + display toggle on complete. No stagger, no delay, no scrub, no pin anywhere.

Assets / images

5 wide, full-color landscape images (~2:1, ~1024×510), referenced as /img/img1.png … /img/img5.png and cycled across all 144 tiles (and reused as the modal hero + the two suggestion thumbnails). They are object-fit: contain, so each sits letterboxed inside a light #f4efef tile — transparent or plain-background product shots read best. It is a mixed product / editorial set sharing a clean, minimal, catalog mood; the exact subjects don't matter, only that there are 5 wide full-color images. A representative set (describe by role, no brands):

  • img1 — studio close-up of a tan suede ankle boot with a stacked wooden block heel and squared toe, on a pale seamless backdrop.
  • img2 — warm sepia-toned macro beauty shot of a brow and closed eye (skin/hair texture), full-bleed.
  • img3 — black-and-white studio shot of an open hinged twin-mirror compact / silver case on a black ground.
  • img4 — a cream-and-rust high-top athletic sneaker floating amid red and white daisies against a soft pink backdrop.
  • img5 — overhead flat-lay of purple-themed streetwear (lilac backpack, folded white tee, chain, beanie, windbreaker and a pair of white/purple high-tops) on a deep-purple ground.

Behavior notes

  • Desktop / mouse only. All interaction is mouse events (mousedown/mousemove/mouseup/mouseleave/click); there is no touch or pointer-event handling in the original, so it is not mobile-safe as written.
  • Nothing animates on load or scroll. The page is a fixed, non-scrolling screen; the only motion is the drag-pan and the modal fades.
  • The disambiguation flags mean a click that included *any* mouse movement over the tile is treated as a pan and will not open the modal — only a clean, stationary click opens a product.
  • Reduced-motion is not handled in the original; the 0.4s/0.5s tweens are short enough to be unobtrusive, but you may add a prefers-reduced-motion guard if desired.
  • Light perf cost: pure CSS transforms (x/y on one element) + an opacity fade; no canvas, WebGL or physics.