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) only — no 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>
#containerstarts empty; JS creates 144.boxtiles inside it, each tile = one<img class="img">+ one.contentwrapper holding a<p>(product line) and an<h1>(colorway name)..modalis 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 tocolumns * boxSize = 12 * 240 = 2880px, so the live canvas is 2880×2880, a solid#cccsquare 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 todisplay:flex(a two-column row) by JS on open. Note there is noopacitydeclared in CSS, so its resting opacity is1(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 graypdefault)..suggestions p { text-transform:uppercase; font-size:12px; font-weight:500; color:#7a7a7a }("Users also bought" label).- Inside
.suggestions, the two.boxcards inherit the same 240×240 tile look andfloat:leftside 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; createimg.imgwithsrc = "/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, appendimg+contentto 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; recordstartCoords = {x:e.clientX, y:e.clientY}; read the current transform withstartTranslate.x = gsap.getProperty(container, "x")andstartTranslate.y = gsap.getProperty(container, "y"); thengsap.set(container, { cursor:"grabbing" })andgsap.set(container, { userSelect:"none" }).onDrag(e): if not dragging, return.e.preventDefault(). ComputedeltaX = e.clientX - startCoords.x,deltaY = e.clientY - startCoords.y, thentranslateX = 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-motionguard if desired. - Light perf cost: pure CSS transforms (
x/yon one element) + an opacity fade; no canvas, WebGL or physics.