Build: Sliding Lightbox Gallery
Goal
An editorial click-triggered image lightbox: a 4×4 grid of 16 numbered thumbnails on the left, a tall portrait preview box bottom-right. Clicking any thumbnail slides a full-size copy of that image into the preview box from the right edge, while the image that was already there scales up to 1.5× and slides off to the left, and the whole page background tweens (via a CSS transition) to a color hand-matched to that thumbnail. Old previews are never removed — they pile up off to the left, clipped by the box's overflow:hidden. The star effect is the pure gsap.to cross-slide (incoming from the right, outgoing zooming out to the left) over a 1s duration, paired with the 1s background-color fade.
Tech
Vanilla HTML/CSS/JS with ES module imports (Vite/npm project). Use gsap (npm) only — no plugins, no framework, no ScrollTrigger, no SplitText, no Lenis, no Three.js. A single import:
import gsap from "gsap";
The animation is driven entirely by two gsap.to() calls inside a click handler; the background color change is a plain CSS transition (not GSAP).
Layout / HTML
Class names are load-bearing (the JS queries .item .img img and .preview-container). Structure:
<nav>
<div class="col">
<div class="copy"><p>(Shadows)</p><p>Lorem ipsum dolor sit, amet consectetur adipisicing elit.</p></div>
<div class="copy"><p>(Light Exploration)</p><p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quam nobis, possimus id excepturi.</p></div>
</div>
<div class="col">
<div class="copy"><p>(Clay)</p><p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quam nobis, possimus id.</p></div>
<div class="copy"><p>(Concrete Forms)</p><p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quam nobis, possimus id excepturi exercitationem nesciunt.</p></div>
<div class="copy"><p>(Mixed Materials)</p><p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quam nobis, possimus id excepturi exercitationem nesciunt facere non dolorem deleniti aliquid!</p></div>
<div class="copy"><p>(Closeups)</p><p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quam nobis, possimus!</p></div>
</div>
<div class="col">
<p class="logo">1-01</p>
</div>
</nav>
<div class="container">
<div class="gallery">
<div class="row">
<div class="item"><div class="index"><p>1</p></div><div class="img"><img src="/c/lightbox/1.jpeg" alt="" /></div></div>
<div class="item"><div class="index"><p>2</p></div><div class="img"><img src="/c/lightbox/2.jpeg" alt="" /></div></div>
<div class="item"><div class="index"><p>3</p></div><div class="img"><img src="/c/lightbox/3.jpeg" alt="" /></div></div>
<div class="item"><div class="index"><p>4</p></div><div class="img"><img src="/c/lightbox/4.jpeg" alt="" /></div></div>
</div>
<!-- row 2: items 5–8, row 3: items 9–12, row 4: items 13–16 — same pattern -->
</div>
<div class="preview">
<div class="preview-container">
<img src="/c/lightbox/1.jpeg" alt="" /> <!-- initial preview = image 1 -->
</div>
</div>
</div>
<script type="module" src="./script.js"></script>
- 16 items total, laid out as 4 rows × 4 items. Each
.item= an.indexblock holding its number<p>1</p>…<p>16</p>(stacked above) and an.imgwrapper holding one<img>whose filename is/c/lightbox/<n>.jpeg(the number must be recoverable from the src — the JS regex-matches(\d+)\.jpeg). - The
.preview-containerstarts with one<img>already inside it — image1.jpeg— so the box is not empty on load. - Nav copy labels —
(Shadows),(Light Exploration),(Clay),(Concrete Forms),(Mixed Materials),(Closeups)— and the logo1-01are neutral demo text; keep them verbatim, no real brand names.
Styling
Font: "Editorial New" (a high-contrast editorial serif) applied to all <p> and to .container. If unavailable, fall back to a serif (Georgia, serif). p { font-size:14px; }.
Global reset & page:
* { margin:0; padding:0; box-sizing:border-box; }
html, body {
width:100%; height:100%;
background:#b2b4aa; /* muted sage-grey base */
transition: background-color 1s; /* CRITICAL: the JS sets body bg inline; this 1s transition IS the color fade */
}
img { width:100%; height:100%; object-fit:cover; } /* every img cover-fills its box */
Top nav (absolutely positioned band):
nav { position:absolute; top:0; width:100%; display:flex; padding:2em; gap:1em; }
.col { display:flex; gap:1em; } /* copy blocks sit in a row inside each column */
.copy { flex:1; }
.col:nth-child(1){ flex:2; }
.col:nth-child(2){ flex:5; }
.col:nth-child(3){ flex:3; }
p.logo { width:100%; text-align:right; }
Main container — gallery (left, flex 2) beside preview area (right, flex 4):
.container { position:relative; top:175px; width:100%; height:80vh; display:flex; font-family:"Editorial New"; }
.gallery { flex:2; display:flex; flex-direction:column; height:100%; }
.row { flex:1; width:100%; display:flex; }
.item { flex:1; padding:1em; display:flex; flex-direction:column; align-items:center; gap:1em; }
.img { width:80%; height:80px; cursor:pointer; } /* small thumbnail box; cursor:pointer signals it's clickable */
.preview { width:100%; flex:4; height:100%; display:flex; justify-content:flex-end; align-items:flex-end; padding:2em; }
Preview box — this is the stage for the effect:
.preview-container {
position:relative;
width:400px; height:600px; /* 2:3 portrait */
background:#e3e3e3; /* light grey showing before/behind images */
overflow:hidden; /* CRITICAL: clips outgoing/incoming images sliding past the edges */
}
.preview-container img { position:absolute; } /* every preview img is absolutely positioned so left/right can be tweened */
Responsive @media (max-width:900px):
body { height:200vh; }
.col:nth-child(1), .col:nth-child(2) { display:none; } /* hide the two copy columns, keep the logo */
.preview-container { width:100%; }
.container { flex-direction:column; gap:5em; } /* gallery stacks above preview */
GSAP effect (the important part — be exhaustive)
There is no plugin and no timeline. The entire effect is a click handler that fires two simultaneous gsap.to() tweens plus one inline style write. GSAP's global default ease applies to both tweens (no ease is passed), i.e. power1.out, duration 1s, no delay, no stagger, no repeat.
Setup
import gsap from "gsap";
const images = document.querySelectorAll(".item .img img"); // all 16 thumbnails
// One color per image, index 0 → image 1 … index 15 → image 16.
// Each is the dominant/average tone of that photo; clicking image N paints the page this color.
const colorArray = [
"#B69671", "#6D7783", "#B9A288", "#B5B8AC",
"#A2B0B1", "#5F5F5F", "#7E98B1", "#857F71",
"#A6A6A6", "#B5B4AB", "#503D31", "#B1A591",
"#616161", "#C29651", "#5E6471", "#B0A89D",
];
The click handler (attach to every thumbnail <img>)
function handleImageClick(event) {
const imgSrc = event.currentTarget.src; // e.g. ".../c/lightbox/7.jpeg"
const imgNumber = imgSrc.match(/(\d+)\.jpeg/)[1]; // "7"
// 1) Background color — inline style write; the CSS `transition: background-color 1s` animates it.
document.body.style.backgroundColor = colorArray[parseInt(imgNumber) - 1];
const newImgSrc = `/c/lightbox/${imgNumber}.jpeg`;
const previewContainer = document.querySelector(".preview-container");
const currentLastImg = previewContainer.querySelector("img:last-child"); // the image currently on top
// 2) OUTGOING tween — zoom the current image up and drift it off to the left.
if (currentLastImg) {
gsap.to(currentLastImg, { duration: 1, scale: 1.5, left: "-50%" });
}
// 3) Build the INCOMING image, parked fully off-screen to the right.
const newImg = document.createElement("img");
newImg.src = newImgSrc;
newImg.style.position = "absolute";
newImg.style.right = "-100%"; // its right edge is one container-width past the right edge
previewContainer.appendChild(newImg); // appended LAST → it becomes the new img:last-child
// 4) INCOMING tween — slide it in to fill the box.
gsap.to(newImg, { duration: 1, right: "0%" });
}
images.forEach((img) => img.addEventListener("click", handleImageClick));
Exact motion, value-by-value
- Incoming image: created fresh each click,
position:absolute, inheritswidth:100% height:100% object-fit:coverfrom the globalimgrule. Starts atright:-100%(completely off the right side of the 400px box) and tweens toright:0%over 1s → it slides leftward into the box until it exactly fills it. Because it is appended last, it lands on top of everything. - Outgoing image: whatever was previously
img:last-child. It tweensscale1 → 1.5 (transform-origin center) andleft(0) → -50% over the same 1s, i.e. it enlarges to 1.5× while sliding half a container-width to the left, exiting under theoverflow:hiddenclip on the left side. The two tweens run concurrently, so you read it as a cross-slide: new photo pushes in from the right as the old one blooms and leaves left. - Note the deliberate asymmetry: the incoming image animates the
rightoffset, the outgoing animates theleftoffset (and addsscale). Preserve that — don't unify them. - Ease/duration/delay/stagger: ease = GSAP default
power1.out; duration =1; no delay, no stagger, no repeat, no yoyo, no timeline. Both are independent one-shotgsap.totweens. - No cleanup: outgoing images are never removed from the DOM. After several clicks the container holds a growing stack of
<img>s, each frozen where its tween ended (scaled 1.5, left -50%), overlapping and mostly hidden byoverflow:hidden. Only the newest is fully visible. - Background fade is NOT GSAP: it is the CSS
transition: background-color 1sonhtml, bodyreacting to the inlinebackgroundColorwrite — so it fades over 1s in parallel with the slide. - First click is a special but automatic case: the initial
1.jpegsitting in the container is thecurrentLastImg, so it gets the outgoing zoom-out treatment while the clicked image slides in.
Assets / images
16 gallery images. Each appears twice: as a small cover-cropped thumbnail (~80px tall box) in the grid, and, when clicked, as a full 2:3 portrait filling the 400×600 preview box (also object-fit:cover, so any source aspect crops cleanly, but frame them portrait-friendly). The set reads as one calm, muted, editorial mix — natural/neutral tones on plain or dark backdrops, spanning warm tans, slate greys, sage, sky-blue and deep browns to match the per-image background colors above. Describe by role, no brands or logos:
- Warm close-up of a bare shoulder and upper back, soft skin tones on a plain backdrop. *(bg #B69671)*
- Stacked bundles of rolled grey material on a wooden pallet, industrial. *(bg #6D7783)*
- Close-up of feet in strappy lace-up leather sandals on a wood floor. *(bg #B9A288)*
- White daisy-like flowers against dark green foliage. *(bg #B5B8AC)*
- Cluster of small snail shells scattered on a muted teal surface. *(bg #A2B0B1)*
- Black-and-white studio portrait, hands clasped on the head, dark background. *(bg #5F5F5F)*
- A dark glass bottle silhouetted against a clear blue sky (product shot). *(bg #7E98B1)*
- A weathered white window frame on a building exterior in soft light. *(bg #857F71)*
- Black-and-white portrait, head tilted back, eyes closed, sleek dark hair. *(bg #A6A6A6)*
- A bar of soap on a glossy reflective surface (product still-life). *(bg #B5B4AB)*
- Close-up of a brown horse's eye and bridle against blue sky. *(bg #503D31)*
- A folded, pleated paper fan-like lamp/sculpture in warm tones. *(bg #B1A591)*
- A balanced stack of grey stones with a white cube, sculptural, on black. *(bg #616161)*
- A golden-green pear carved open, with a blue cast shadow (still life). *(bg #C29651)*
- A figure in a flowing white dress, barefoot, holding a wooden frame (minimal editorial). *(bg #5E6471)*
- A small pleated parasol-style lamp casting a long shadow in moody light. *(bg #B0A89D)*
If you have fewer than 16, repeat in order — the effect is identical regardless of content; the color-per-index mapping is what matters for the background fade.
Behavior notes
- Click is the only trigger — no hover, no scroll, no autoplay. Every thumbnail
<img>gets the same listener. - The number in each filename is load-bearing: the JS extracts it with
/(\d+)\.jpeg/to both pick the background color (colorArray[n-1]) and build the preview src (/c/lightbox/<n>.jpeg). Keep numeric<n>.jpegfilenames. - Desktop-oriented; below 900px the two copy columns hide, the layout stacks vertically, and the preview grows to full width (page becomes 200vh tall).
- No reduced-motion branch in the original. Both tweens use GSAP's default
power1.outease at 1s.