GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Lightbox Gallery Animation — 9 Transitions

Among nine css lightbox gallery ideas, one grows from the clicked thumbnail's spot, one times a downward flick to decide close-or-snap, and one glides a

Auto-plays · click a tile to jump to its section · all nine in one zip

Nine lightbox transition patterns sit side by side below, built as separate HTML/CSS/JS files with nothing pulled in from a CDN, so pulling any one folder out of the archive and opening its index.html is enough to see it run on its own.

Demo

Open each thumbnail above to see its own lightbox, then close it with the backdrop, the escape key, or whatever gesture that pattern expects — every measurement and every line of code below comes straight from that same file, not from a summary written after the fact.

Anatomy of nine patterns — where the one layer of calculation runs

The table below reads from the same captures the demos above produced — 24 frames per pattern, sampled identically across all nine. Cumulative area totals how much of the frame the moving pixels covered across the capture; average intensity is how strong that pixel change ran on the frames where something actually moved; frames moved (of 23) counts how many of the 23 frame-to-frame comparisons showed any difference at all.

# Pattern Cumulative area Avg. intensity Frames moved (of 23)
01 Lightbox That Expands From the Thumbnail's Spot 36.70% 111.0 15
02 Backdrop Fade Plus Image Scale-Up 39.72% 79.2 14
03 Lightbox Whose Caption Slides Up From Below 39.87% 110.8 21
04 Lightbox Navigated by Swiping Next and Previous 39.87% 95.2 20
05 Lightbox You Pan by Dragging After Zooming In 30.18% 53.5 9
06 Lightbox That Closes With a Downward Fling 39.87% 74.1 15
07 Lightbox With a Focus Ring That Glides Inside a Trap 39.82% 106.4 20
08 Lightbox With a Thumbnail Strip That Follows Along 39.87% 99.7 10
09 Lightbox That Instantly Reflects Reduced Motion 22.44% 78.2 17

Four patterns — 03, 04, 06, and 08 — tie for the largest cumulative area of the nine at roughly 39.87% each, since all four run a full backdrop dim alongside a full-frame image transform that fills nearly the same portion of the capture no matter which value is actually driving it. Pattern 09 covers the least at 22.44%, and pattern 05 sits just above it at 30.18% — the same pattern also posts the lowest average intensity of the set, 53.5, and the fewest frames moved, just 9 of 23, since its zoom-and-pan only reacts to a drag or a double-tap rather than running its own open-close cycle like the other eight. Pattern 01 posts the highest average intensity at 111.0, edging out pattern 03's 110.8, while pattern 03 itself moves across the most frames of the nine at 21 of 23.

Cluster A — a real measured rectangle drives the interpolation, not a formula (01, 03, 07)

Patterns 01, 03, and 07 each start their motion by calling getBoundingClientRect() on a real element already sitting in the page, rather than computing a start value from a constant. Pattern 01 reads the clicked thumbnail's own screen rectangle to compute the offset and scale the full image morphs in from; pattern 03 reads the caption's own rendered height only after laying it out off-screen, since a longer caption needs a longer push distance than a short one; pattern 07 reads the newly focused control's rectangle to know exactly where the previous ring position should glide to next.

Cluster B — an OR of two thresholds decides which of two branches runs (04, 06, 09)

Patterns 04, 06, and 09 all resolve one decision the same way: two independent conditions are combined with a plain ||, and either one being true is enough to pick a branch. Pattern 04's endDrag() commits to the next or previous image once the drag distance clears SWIPE_COMMIT_RATIO of the viewport width or the release velocity clears SWIPE_VELOCITY_PX_MS, whichever comes first. Pattern 06 checks the same shape of condition — a distance ratio or a velocity past LIGHTBOX_FLING_VELOCITY_PX_MS — to choose between flingClose() and springBack(). Pattern 09's effectiveReduce() is the simplest version of the same idiom, realReduce || simulateReduce, where either the OS setting or the demo's own toggle is enough to switch its lightbox into the instant state.

Cluster C — a derived ratio keeps a continuous transform inside a moving boundary (02, 05, 08)

Patterns 02, 05, and 08 each run a value through clamp() every time it changes, but for a different reason each time. Pattern 02's fromAspect() derives both the start scale and the backdrop's target dimness from one normalized aspect-ratio value, so a portrait photo and a landscape one open with two different feels from the exact same code path. Pattern 05's panBounds() recalculates the draggable range on every animation frame while scale itself is still easing toward its target, so the pan never allows a position the current zoom level hasn't earned yet. Pattern 08's centerOffsetFor() clamps the strip's target scroll offset so the active thumbnail can re-center without ever dragging the strip past its own first or last item.

Code per pattern

Below is one function pulled from each pattern's own file, left exactly as it runs in the browser — nothing rewritten, nothing shortened, and nothing standing in for a step that was cut for space. Anyone who has typed css lightbox image into a search bar has probably found a dozen open-close transitions that stop at a CSS keyframe; the nine below all keep at least one calculated value like this running underneath the transition instead.

01Lightbox That Expands From the Thumbnail's Spot

Clicking a grid thumbnail makes that exact image grow from its own on-screen spot and size into a full lightbox, unlike a dialog that always appears centered regardless of where it was triggered.

Lightbox That Expands From the Thumbnail's Spot
Open in a new tab

01 · Lightbox That Expands From the Thumbnail's Spot — Measured area 36.697% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRecttransform-originFLIP technique
  function openFrom(idx) {
    if (open) return;
    open = true;
    var thumbImg = thumbs[idx].querySelector('.thumbImg');
    var fromRect = thumbImg.getBoundingClientRect();
    fullImg.className = 'fullImg ' + imgClassByIdx[idx];
    lightbox.style.visibility = 'visible';
    lightbox.setAttribute('aria-hidden', 'false');
    backdrop.style.visibility = 'visible';
    var toRect = fullImg.getBoundingClientRect();
    var dx = (fromRect.left + fromRect.width / 2) - (toRect.left + toRect.width / 2);
    var dy = (fromRect.top + fromRect.height / 2) - (toRect.top + toRect.height / 2);
    var scaleFrom = fromRect.width / toRect.width;
    animate(OPEN_MS, function (e) {
      var s = scaleFrom + (1 - scaleFrom) * e;
      var tx = dx * (1 - e);
      var ty = dy * (1 - e);
      fullImg.style.transform = 'translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px) scale(' + s.toFixed(4) + ')';
      backdrop.style.opacity = (e * 0.72).toFixed(3);
    }, function () {
      closeBtn.focus({ preventScroll: true });
    });
  }

02Backdrop Fade Plus Image Scale-Up

While the backdrop dims, the image itself starts slightly smaller than final size and eases up to rest — a different demo from a generic dialog fade-scale, since the start scale and dim amount are both derived from the image's own aspect ratio.

Backdrop Fade Plus Image Scale-Up
Open in a new tab

02 · Backdrop Fade Plus Image Scale-Up — Measured area 39.722% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

clamp()transform: scalebackdrop opacity
  function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
  function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }

  // Derives the start scale and the backdrop's dimness together from the image's own aspect ratio — both values come from the same normalized point
  function fromAspect(w, h) {
    var aspect = w / h;
    var norm = clamp((aspect - ASPECT_NORM_LO) / (ASPECT_NORM_HI - ASPECT_NORM_LO), 0, 1);
    var scaleFrom = SCALE_FROM_MIN + (SCALE_FROM_MAX - SCALE_FROM_MIN) * norm;
    var dim = DIM_MAX - (DIM_MAX - DIM_MIN) * norm;
    return { aspect: aspect, scaleFrom: scaleFrom, dim: dim };
  }

  var open = false;
  var activeIdx = 1;
  var timers = [];

03Lightbox Whose Caption Slides Up From Below

A beat after the image finishes expanding, the caption strip slides up from the bottom edge into place, so the image and its caption arrive in two distinct steps rather than together.

Lightbox Whose Caption Slides Up From Below
Open in a new tab

03 · Lightbox Whose Caption Slides Up From Below — Measured area 39.872% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

transform: translateYrequestAnimationFramestaggered timing
    animate(IMAGE_MS, function (e) {
      fullImg.style.transform = 'scale(' + (0.86 + 0.14 * e).toFixed(4) + ')';
      fullImg.style.opacity = e.toFixed(3);
      backdrop.style.opacity = (0.7 * e).toFixed(3);
    }, function () {
      // Measures the caption's own actual rendered height and pushes it up from below by that amount — the starting position differs with text length
      caption.style.visibility = 'hidden';
      caption.style.opacity = '1';
      var capH = caption.getBoundingClientRect().height;
      caption.style.transform = 'translateY(' + capH.toFixed(1) + 'px)';
      caption.style.visibility = 'visible';
      timers.push(setTimeout(function () {
        animate(CAPTION_MS, function (e2) {
          caption.style.transform = 'translateY(' + (capH * (1 - e2)).toFixed(1) + 'px)';
        }, function () {
          closeBtn.focus({ preventScroll: true });
        });
      }, CAPTION_GAP_MS));
    });

04Lightbox Navigated by Swiping Next and Previous

Dragging left or right while the lightbox is open pushes the current image out and slides the next or previous one into its place (a different demo from 082's inline carousel slider — this one advances inside a full-screen overlay alongside a numbered counter).

Lightbox Navigated by Swiping Next and Previous
Open in a new tab

04 · Lightbox Navigated by Swiping Next and Previous — Measured area 39.872% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

Pointer EventssetPointerCapturevelocity threshold
  function endDrag(e) {
    if (!dragging) return;
    dragging = false;
    var deltaX = e.clientX - dragStartX;
    var w = viewportW();
    var commit = Math.abs(deltaX) > w * SWIPE_COMMIT_RATIO || Math.abs(velocity) > SWIPE_VELOCITY_PX_MS;
    if (commit) {
      goTo(idx + (deltaX < 0 ? 1 : -1));
    } else {
      goTo(idx);
    }
  }

05Lightbox You Pan by Dragging After Zooming In

Once the image is zoomed in by a double-tap or button, dragging moves it around, but the movable range is recalculated each time so it never drags past the extra space the zoom created (a different demo from 256's magnifier and minimap viewer — this one only covers zoom-and-pan while the lightbox itself is open).

Lightbox You Pan by Dragging After Zooming In
Open in a new tab

05 · Lightbox You Pan by Dragging After Zooming In — Measured area 30.183% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

clamp()pointer drag pantransform: scale
  function panBounds(s) {
    var r = frame.getBoundingClientRect();
    return { x: (s - 1) * r.width / 2, y: (s - 1) * r.height / 2 };
  }

  function apply() {
    fullImg.style.transform = 'translate(' + offX.toFixed(2) + 'px,' + offY.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
    zoomNote.textContent = scale.toFixed(1) + '×';
  }

  function zoomTo(target, onDone) {
    var fromScale = scale;
    var fromX = offX, fromY = offY;
    animate(ZOOM_MS, function (e) {
      scale = fromScale + (target - fromScale) * e;
      var b = panBounds(scale);
      offX = clamp(fromX * (1 - e), -b.x, b.x);
      offY = clamp(fromY * (1 - e), -b.y, b.y);
      apply();
    }, onDone);
  }

06Lightbox That Closes With a Downward Fling

The moment a downward drag is released, the last movement's speed is calculated — only a fast enough flick sends the image flying off-screen closed, while a slow release springs it back to place.

Lightbox That Closes With a Downward Fling
Open in a new tab

06 · Lightbox That Closes With a Downward Fling — Measured area 39.872% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointer velocityspringBack()translate()
  function flingClose(vy) {
    var h = stageH();
    var fromY = curY;
    var fromX = curX;
    var targetY = fromY + Math.sign(vy || 1) * (h * 1.4);
    var duration = clamp(220 / Math.max(Math.abs(vy), 0.35), 120, 320);
    animate(duration, function (e) {
      var y = fromY + (targetY - fromY) * e;
      var progress = clamp(Math.abs(y) / h, 0, 1);
      setDrag(fromX * (1 - e), y, progress);
    }, closeLightbox, function (t) { return t; });
  }

  function springBack() {
    var fromX = curX;
    var fromY = curY;
    animate(SPRING_BACK_MS, function (e) {
      setDrag(fromX * (1 - e), fromY * (1 - e), 0);
    }, function () {
      curX = 0; curY = 0;
      closeBtn.focus({ preventScroll: true });
    }, easeSpringBack);
  }

07Lightbox With a Focus Ring That Glides Inside a Trap

Every time tab or arrow-key focus moves to the next control inside the lightbox, the focus outline doesn't jump instantly but glides from its previous position to the new one, so the eye can keep tracking where focus currently sits.

Lightbox With a Focus Ring That Glides Inside a Trap
Open in a new tab

07 · Lightbox With a Focus Ring That Glides Inside a Trap — Measured area 39.817% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRectfocus trapwill-change: transform
    var fromRect = lastRingRect || { x: toX, y: toY, w: toW, h: toH };
    focusRing.style.width = toW + 'px';
    focusRing.style.height = toH + 'px';
    focusRing.style.transform = 'translate(' + toX + 'px,' + toY + 'px)';
    var dx = fromRect.x - toX;
    var dy = fromRect.y - toY;
    var sx = fromRect.w / toW;
    var sy = fromRect.h / toH;
    focusRing.style.opacity = '1';
    animate(LIGHTBOX_FOCUS_RING_MS, function (e) {
      var ix = dx * (1 - e);
      var iy = dy * (1 - e);
      var isx = sx + (1 - sx) * e;
      var isy = sy + (1 - sy) * e;
      focusRing.style.transform = 'translate(' + (toX + ix).toFixed(2) + 'px,' + (toY + iy).toFixed(2) + 'px) scale(' + isx.toFixed(4) + ',' + isy.toFixed(4) + ')';
    }, function () {
      lastRingRect = { x: toX, y: toY, w: toW, h: toH };
    });

08Lightbox With a Thumbnail Strip That Follows Along

In the small thumbnail strip along the lightbox's bottom edge, only the thumbnail for the currently viewed photo gets an outline, and each time the image changes the whole strip re-centers around that outlined thumbnail (a different demo from 082's own slider thumbnail track — this strip is a companion to an already-open lightbox, not the slider body itself).

Lightbox With a Thumbnail Strip That Follows Along
Open in a new tab

08 · Lightbox With a Thumbnail Strip That Follows Along — Measured area 39.872% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

centerOffsetFor()transform: translateXscroll sync
  function goTo(nextIdx, onDone) {
    nextIdx = clamp(nextIdx, 0, COUNT - 1);
    idx = nextIdx;
    fullImg.className = 'fullImg fullImg--' + imgClassByIdx[idx];
    stripThumbs.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
    var target = centerOffsetFor(stripThumbs[idx]);
    var fromX = currentX;
    animate(SYNC_MS, function (e) {
      var x = fromX + (target - fromX) * e;
      setStripX(x);
    }, function () {
      currentX = target;
      if (onDone) onDone();
    });
  }

09Lightbox That Instantly Reflects Reduced Motion

When reduced motion is requested, every expand, swipe, and fling movement is skipped, and the image and backdrop simply appear and disappear instantly like a switch.

Lightbox That Instantly Reflects Reduced Motion
Open in a new tab

09 · Lightbox That Instantly Reflects Reduced Motion — Measured area 22.44% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

matchMediaprefers-reduced-motionchange event
  // The real OS/browser setting — when this is true, it always switches instantly regardless of the simulation switch
  var mql = window.matchMedia('(prefers-reduced-motion: reduce)');
  var realReduce = mql.matches;
  // A demo-only simulation switch — even when the real setting is normal, it forces this lightbox alone to switch instantly so the difference can be compared
  var simulateReduce = false;

  function effectiveReduce() { return realReduce || simulateReduce; }

Where values change the UI — scale origin, velocity threshold, hold time

Most css lightbox write-ups stop at how long the fade lasts, but every pattern above hangs off one variable that does the real work — change that one variable and the whole pattern's feel shifts with it.

01 — the start scale ratio

scaleFrom is nothing but fromRect.width / toRect.width — the thumbnail's own measured width divided by the full image's — so the morph's starting size is never a fixed constant; a small thumbnail and a large one produce two visibly different start points from the same function.

02 — the normalized aspect point

norm is the one number fromAspect() produces from an image's width and height, and both scaleFrom and dim are just that same norm interpolated between two different constant pairs — change the image's aspect ratio and both values move together, never independently.

03 — the measured caption height

capH comes from caption.getBoundingClientRect().height measured the instant before the slide starts, so a one-line caption travels a short distance while a three-line caption travels much farther, off the same CAPTION_MS duration.

04 — the commit threshold

SWIPE_COMMIT_RATIO and SWIPE_VELOCITY_PX_MS are the two constants endDrag() checks — either a long enough drag or a fast enough flick commits to the next image, and lowering either constant makes a shorter or gentler gesture count as a swipe.

05 — the recalculated pan bounds

panBounds(scale) runs again on every single animation frame inside zoomTo(), not once at the end, so offX and offY are clamped against a boundary that is itself still changing while the zoom eases toward its target.

06 — the velocity-scaled duration

duration in flingClose() is clamp(220 / Math.max(Math.abs(vy), 0.35), 120, 320) — a faster release produces a shorter, snappier fling toward the edge, while a release right at the threshold stretches close to the full 320ms ceiling.

07 — the measured ring rectangle

lastRingRect is the one piece of state glideRingTo() keeps between calls — without it, the ring would have no fromRect to interpolate away from, and every focus move would have to snap instead of glide.

08 — the clamped center offset

centerOffsetFor() feeds straight into clamp() before goTo() ever animates toward it, which is the one line stopping the strip from scrolling the first or last thumbnail past the point where no thumbnail is left to fill the gap.

09 — the OR that also drives every other pattern's own switch

effectiveReduce() — realReduce || simulateReduce — is this pattern's own version of a check repeated independently inside every one of the other eight files' animate() helper; the only difference here is the second, demo-only switch that can force the instant state on for comparison even when the OS itself is set to normal motion.

Where it breaks — accessibility, reduced-motion, focus movement

Pattern 07's order array cycles keyboard focus through four controls — close, previous, info, and next — and glideRingTo() runs on every one of their native focus events, so the ring visibly slides to whichever control Tab or Shift+Tab lands on next, correctly wrapping from the last control back to the first. But only closeBtn has a click handler in this demo; prevBtn, infoBtn, and nextBtn sit inside the trap and take focus like any real control, yet pressing Enter or Space on any of the three does nothing at all — the ring is honest about where focus currently is, but three of its four stops stay inert until a real gallery wires up their behavior.

What reduced motion leaves behind

Every pattern's own trigger — a click, a drag, a keypress — still works the same way under reduced motion; only the eased motion in between gets skipped, since each pattern's shared animate() helper calls its final frame once and returns instead of running its usual loop.

All nine of these files share one password, nhfnnp8k, so unzipping the archive once puts a working copy of every pattern above on disk, ready to open directly in a browser.

  • 01 thumbnail morph — the interpolation stops; fullImg snaps straight to its final position and scale, and the backdrop opacity jumps straight to 0.72.
  • 02 backdrop fade-scale — the eased scale-up stops; the image appears already at its aspect-derived final scale with the backdrop already at its target dimness.
  • 03 caption slide-up — both staggered animations stop; the image appears at full opacity with the caption already in place, no delay and no slide.
  • 04 swipe next/prev — dragging still works and still commits by distance or velocity, but the commit itself snaps to the next image instead of easing into place.
  • 05 zoom-pan-drag — double-tap and button zoom still change scale, but the value jumps to its target instead of easing, and the pan bounds update with it in one step.
  • 06 close-velocity-fling — a fast release and a slow one both resolve instantly, either closing or springing back with no arc and no overshoot in between.
  • 07 focus-trap ring — Tab and Shift+Tab still wrap at the ends of order, but the ring teleports to the next control instead of gliding across the gap.
  • 08 thumbnail-strip sync — the strip still re-centers on centerOffsetFor()'s target every time the image changes, just in a single frame instead of easing there.
  • 09 reduced-motion instant — nothing changes here, since instant switching is already the point of this pattern; effectiveReduce() keeps reflecting the OS setting and the simulation switch exactly the same either way.

Three variants and accessibility

Nothing here needs an image zoom animation css codepen pulled in from elsewhere — every constant a variant below touches lives in the same file already open in the archive.

Variant Value Feel
Widen the thumbnail-expand start scale 01 LIGHTBOX_MORPH_SCALE_FROM 0.3 → 0.6 Widening the start size from 30% to 60% of the stage width means the thumbnail starts already larger, so it travels a shorter distance and the expand feels more subtle.
Lower the fling-dismiss velocity threshold 06 LIGHTBOX_FLING_VELOCITY_PX_MS 0.5 → 0.2 Lowering the velocity threshold from 0.5 to 0.2 means even a slower release counts as a fling, so the image closes far more easily.
Extend the focus-ring move duration 07 LIGHTBOX_FOCUS_RING_MS 220 → 400 Extending the duration from 220ms to 400ms makes the focus ring glide to the next spot more slowly, making it easier for the eye to follow.

All nine lightboxes share the same dialog contract: the trigger thumbnail carries aria-haspopup="dialog", the lightbox itself is role="dialog" with aria-modal="true", and aria-hidden flips between true and false as it opens and closes — a screen reader gets the same signal regardless of which of the nine transitions is doing the opening. Pattern 04 layers an aria-live="polite" region on top for its image counter, so a swipe announces which photo is now showing without the eased slide itself needing to be heard. Pattern 09's simulation toggle is the only control among the nine built as role="switch" with aria-checked, since it represents a genuine on/off state rather than a one-shot action like every other button here.

FAQ

Do these nine css lightbox transitions need a real image host or backend to run?

No — every image in all nine files is a plain CSS gradient or inline SVG, with nothing fetched from a network and no Math.random() anywhere in the set, so reloading any demo produces the exact same numbers and the exact same motion every time.

How do I check whether prefers-reduced-motion is actually respected here?

Turn on "reduce motion" in your OS accessibility settings and reload any demo above — all nine call window.matchMedia('(prefers-reduced-motion: reduce)') and switch straight to their final state through the same animate() guard, while pattern 09 goes one step further and lets you flip its own on-screen switch to compare both states side by side without changing the OS setting at all.

Is there a css lightbox gallery codepen version of these nine patterns, or do I need to download something?

A prebuilt embed is handy for a quick look at one pattern in isolation, but there's no embed to load on this page — downloading the archive gives you the same nine index.html files already unzipped and ready to edit on your own disk.

Sources: MDN — Element.getBoundingClientRect(), MDN — Pointer events, MDN — dialog role, MDN — prefers-reduced-motion.

Related reading: CSS Image Gallery Animation 9 One Line, Copy-Paste · CSS Image Slider — 9 Responsive Sliders, No GSAP · 9 Image Zoom Viewer Interactions — No Photo Files

Enter the archive password

The password is inside this article. You will find it as you read.