All components

Pan-Canvas Video Gallery

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

Open live demo ↗ Raw prompt (.md)

What it does

A full-viewport canvas holds an oversized 200vw x 200vh grid of film tiles centered with translate(-50%, -50%); on mousemove the gallery is translated the opposite way of the cursor relative to the container center, eased by a 2000ms cubic-bezier CSS transition, so moving the mouse smoothly pans across the grid. Each tile hover-fades its preview still out and a second frame plus a lower-third film credit in; fixed studio chrome (wordmark, nav, viewfinder corner brackets, pan hint) frames the canvas, and on touch devices the reel auto-drifts via a CSS keyframe animation with credits always visible.

How it's built

Categorygallery
Techvanilla JS
Complexitypage
Performance costmedium
Mobile-safeyes

gallery mousemove pan parallax interactive hover video cinematic cursor

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

Build: Mousemove Pan-Canvas Video Gallery

Goal

A full-viewport black canvas holds an oversized 200vw × 200vh grid of media tiles, centered so only the middle of the grid is visible at rest. Moving the mouse pans the whole grid in the opposite direction of the cursor, letting you "explore" the offscreen tiles by simply steering — no scroll, no drag, no click. The motion is buttery and weighty because the pan is written as a single transform on the grid and eased by a 2-second cubic-bezier CSS transition, so the grid glides toward each new cursor position and trails behind fast movements. Each tile hover-reveals a looping video (its still preview fades out, a zoomed-in looping clip fades in) with the project title centered on top. The star effect is the cursor-inverse translate + long cubic-bezier easing that makes the entire gallery feel like a heavy, floating canvas.

Tech

Vanilla HTML/CSS/JS in a Vite + npm project. No GSAP, no libraries, no framework, no npm dependencies at all — the entire pan is done with one mousemove listener that writes element.style.transform, and *all* of the easing/smoothing lives in a CSS transition on the grid element (not in JS, not in a rAF loop). The hover reveals are pure CSS :hover opacity transitions. Load the script as <script type="module" src="./script.js">. Do not reach for a tween library, ScrollTrigger, Lenis, or a requestAnimationFrame loop — the whole point is that the smoothing is delegated to the browser's CSS transition engine.

Layout / HTML

<body>
  <div class="container">
    <div class="gallery">
      <!-- Row 1 — 4 items -->
      <div class="row">
        <div class="item">
          <div class="preview-img">
            <img src="{preview-1}" alt="Sport Power Ad Campaign" />
          </div>
          <p id="videoName">Sport Power Ad Campaign</p>
          <div class="work-video-wrapper">
            <div class="react-player" style="width: 100%; height: 100%">
              <img src="{reveal-1}" alt="Sport Power Ad Campaign" />
            </div>
          </div>
        </div>
        <!-- 3 more .item blocks: "Brand Vision Promo", "Minimal Motion Graphics", "Project Momentum Highlights" -->
      </div>

      <!-- Row 2 — 3 items -->
      <div class="row">
        <!-- "Ad Strategy Execution", "Sport Drive Showcase", "Brand Essence Storytelling" -->
      </div>

      <!-- Row 3 — 4 items -->
      <div class="row">
        <!-- "Minimal Flair Presentation", "Project Pulse Documentary", "Ad Creativity Concepts", "Sport Icon Journey" -->
      </div>
    </div>
  </div>

  <script type="module" src="./script.js"></script>
</body>

Structure rules the CSS/JS depend on:

  • .container — the fixed full-viewport window; the JS attaches its mousemove listener here and reads *its* bounding rect for the center.
  • .gallery — the single oversized panning surface; the JS writes its transform. It is the ONLY element that moves.
  • .row — 3 rows total. Row 1 = 4 items, Row 2 = 3 items, Row 3 = 4 items (11 tiles, so 22 image slots).
  • .item — one tile. Contains, in this order: a .preview-img (still shown at rest), a <p id="videoName"> title (note: id is intentionally reused across tiles — it is styled/hovered by CSS, never queried by JS), and a .work-video-wrapper whose direct child <div class="react-player"> holds the reveal media.
  • The reveal media is a looping, muted, autoplay video in the original; a second still image is a valid stand-in (the hover cross-fade behaves identically). Keep it as a media element inside the .react-player child div.

Tile titles in DOM order: Row 1 — *Sport Power Ad Campaign*, *Brand Vision Promo*, *Minimal Motion Graphics*, *Project Momentum Highlights*. Row 2 — *Ad Strategy Execution*, *Sport Drive Showcase*, *Brand Essence Storytelling*. Row 3 — *Minimal Flair Presentation*, *Project Pulse Documentary*, *Ad Creativity Concepts*, *Sport Icon Journey*.

Styling

Reset: * { margin:0; padding:0; box-sizing:border-box; }

Fonts: the title uses font-family:"FK Display" — a clean geometric/grotesque display sans; fall back to any neutral sans-serif (e.g. Helvetica/Arial). No other typography.

Color: the only color is the canvas background #000 (pure black) and title text #fff (white). Everything else is imagery.

.container — the viewport window:

.container {
  width: 100vw;
  height: 100vh;
  overflow: hidden;         /* clips the oversized gallery */
  position: relative;
  background: #000;
}

.gallery — the oversized, centered, eased panning surface (this transition IS the effect):

.gallery {
  width: 200vw;             /* twice the viewport each way → room to pan */
  height: 200vh;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);   /* centered: grid center = container center */
  transition: transform 2000ms cubic-bezier(0.075, 0.82, 0.165, 1);  /* the whole easing lives here */
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: 10em;
}

.row — full width, tiles spread across:

.row { width: 100%; display: flex; justify-content: space-between; }
.row:nth-child(2) { justify-content: space-around; }   /* the 3-item middle row spaces differently */

.item — fixed-size clipped tile:

.item { position: relative; width: 400px; height: 275px; overflow: hidden; }

Global image fill: img { width:100%; height:100%; object-fit:cover; }

.preview-img — the still shown at rest, on top by default:

.preview-img { position:absolute; top:0; left:0; width:100%; height:100%; z-index:1; }
.preview-img img { opacity:1; transition:300ms; }
.item:hover .preview-img img { opacity:0; }   /* fades OUT on hover */

.work-video-wrapper — the reveal layer, permanently zoomed 2× so the clip is cropped/close inside the tile:

.work-video-wrapper {
  position:absolute; top:0; left:0; width:100%; height:100%;
  transform: scale(2);          /* always 2× — the reveal media is zoomed in */
  transition: 0.3s all;
}
.work-video-wrapper > div { opacity:0; transition:300ms; }        /* the react-player child */
.item:hover .work-video-wrapper > div { opacity:1; }              /* fades IN on hover */

#videoName — centered title overlay:

#videoName {
  position:absolute; width:50%; top:50%; left:50%;
  transform: translate(-50%, -50%);
  text-align:center; font-size:30px; font-family:"FK Display"; color:#fff;
  opacity:0; transition:0.15s; pointer-events:none; z-index:2;
}
.item:hover #videoName { opacity:1; }   /* snappier fade (0.15s) than the media (0.3s) */

The effect (be exact — this is the whole component)

There is no animation library and no JS easing. The effect is: (1) a mousemove handler that computes a cursor-inverse offset and writes it as a transform on .gallery, and (2) the long CSS cubic-bezier transition on .gallery that smooths every write into a gliding pan. Reproduce the math and the transition verbatim.

1. Bootstrap
const init = () => {
  const container = document.querySelector(".container");
  const gallery   = document.querySelector(".gallery");
  if (!container || !gallery) return;
  container.addEventListener("mousemove", handleMouseMove);
};

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", init);
} else {
  init();
}

Only ONE listener, on .container, for mousemove. Nothing else — no resize, no rAF, no scroll.

2. The pan math (handleMouseMove)
const handleMouseMove = (e) => {
  const { clientX, clientY, currentTarget } = e;
  const { width, height } = currentTarget.getBoundingClientRect();  // the container's size
  const centerX = width / 2;
  const centerY = height / 2;

  const factor = 1;                          // 1 → full 1:1 inverse follow (no damping)
  const deltaX = (centerX - clientX) / factor;
  const deltaY = (centerY - clientY) / factor;

  gallery.style.transform =
    `translate(calc(-50% + ${deltaX}px), calc(-50% + ${deltaY}px))`;
};

Key facts to keep exact:

  • The offset is cursor-INVERSE: delta = center - cursor. When the cursor is at the container's right edge, deltaX = centerX - width = -centerX (negative) → the gallery translates left, revealing its right side. Cursor left edge → gallery moves right. Same inversion vertically. The grid always moves *away* from the cursor.
  • factor = 1 means no damping: deltaX ranges over ±width/2 (≈ ±50vw) and deltaY over ±height/2 (≈ ±50vh). Because the gallery is 200vw × 200vh centered with translate(-50%,-50%), it overhangs the container by exactly 50vw / 50vh on each side — so a full-range cursor sweep pans the grid through its entire hidden extent (corner to corner), never past it.
  • The transform preserves the -50%,-50% centering by keeping it inside the calc(): translate(calc(-50% + Δx), calc(-50% + Δy)). Do not replace the percentage centering with pixels.
  • clientX/clientY are viewport-relative and the container fills the viewport, so no offset subtraction is needed.
3. The easing (CSS, not JS)

Every mousemove fires many times a second and each one instantly rewrites gallery.style.transform to the *raw* target for the current cursor position — there is no interpolation in JS. The smoothing is entirely the transition: transform 2000ms cubic-bezier(0.075, 0.82, 0.165, 1) on .gallery:

  • The 2000ms duration is long, so the grid takes ~2s to fully catch up to a new target and trails/floats behind quick cursor moves, continuously re-targeting as the cursor keeps moving.
  • The bezier cubic-bezier(0.075, 0.82, 0.165, 1) is a strong ease-out (fast start, very soft settle) — the grid lunges toward the new position then decelerates gently into place. This exact curve is what gives the "heavy floating canvas" feel; keep the four control values.
4. Hover reveal (per tile, pure CSS)

On .item:hover, three CSS transitions run simultaneously (all defined above, no JS):

  • .preview-img img opacity 1 → 0 over 300ms (still fades out).
  • .work-video-wrapper > div (the .react-player) opacity 0 → 1 over 300ms (the always-scale(2) looping clip fades in, cropped by the tile's overflow:hidden).
  • #videoName opacity 0 → 1 over 150ms (title appears slightly ahead of the media swap).

Net feel: hover a tile and its still cross-dissolves into a zoomed-in looping video with the project title floating centered in white; the whole grid meanwhile drifts under the cursor.

Assets / images

22 image slots (2 per tile × 11 tiles), alternating roles: for each tile, one preview still (shown at rest) and one reveal frame (the looping video's poster / the zoomed hover state). Source files are landscape ~16:9, but every tile is a fixed 400×275 box with object-fit:cover (and the reveal layer is scaled 2×), so exact aspect is flexible — center-croppable framing matters more than resolution. The set is a cohesive cinematic creative-agency reel: editorial, minimal, high-production imagery with a mix of full-color and black-and-white frames. By role/mood:

  1. Dynamic figures in motion — energetic sport-campaign feel. 2. Sneaker + apparel product shot, athletic gear. 3. Surreal editorial composition, bold brand-vision imagery. 4. Abstract 3D render, sculptural forms. 5. Minimal metallic still life, clean motion-graphics vibe. 6. Architectural interior, minimal geometry. 7. Warm lifestyle scene, momentum and movement. 8. People in motion, highlight-reel energy. 9. Color portrait, editorial subject. 10. Surreal editorial frame, conceptual imagery. 11. Dynamic people in motion, sport-drive showcase. 12. Neon-lit street scene, night-time energy. 13. Black-and-white portrait, brand-essence storytelling. 14. Warm lifestyle moment, human and inviting. 15. Architectural interior, minimal flair. 16. Abstract render, sculptural composition. 17. Backlit silhouette, documentary mood. 18. Black-and-white portrait, intimate documentary tone. 19. Pop-art portrait, creative ad concept. 20. Surreal editorial image, imaginative concept work. 21. Sneaker + apparel hero, sport-icon styling. 22. Figure in motion, athletic journey.

Assign them in DOM order (tile 1 preview = image 1, tile 1 reveal = image 2, tile 2 preview = image 3, …). No brand logos or real client imagery — the project titles are neutral placeholder copy; keep or swap for any neutral names. Any cohesive set of ~22 cinematic editorial frames works; strong tonal contrast makes the hover reveal read best.

Behavior notes

  • Desktop / pointer only. The entire experience is mousemove-driven — no touch, scroll, drag, click, or keyboard, and no reduced-motion branch. It is not mobile-safe (touch devices get a static centered grid with hover reveals unavailable).
  • At rest (before any mouse movement) the grid sits centered showing only its middle tiles; move the mouse to pan and discover the offscreen rows/columns.
  • No resize handling in the original — the container reads its live bounding rect on each mousemove, so a resized window is handled implicitly on the next move.
  • Light perf cost — a single transform write per mouse event with GPU-composited CSS transition; no rAF loops, no per-frame layout.