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: Inter (--sans) for the page, Space Grotesk (--display) for the work titles, Space Mono (--mono) for the small uppercase type labels.

Colour:

:root {
  --canvas: #0f0f0f;   /* the pannable ground */
  --ink: #f5f2ec;      /* titles */
  --muted: #8c8c88;
  --lilac: #c9b8f5;    /* the small type label over each tile */
  --pop: #ffd24a;
}

Everything else on screen 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:

/* film-credit caption, lower third — not a centred title */
.video-name {
  position:absolute; left:0; bottom:0; width:100%;
  padding:18px 20px; display:flex; flex-direction:column; gap:6px;
  opacity:0; transform: translateY(8px);
  transition: opacity 250ms, transform 450ms cubic-bezier(0.16, 1, 0.3, 1);
  pointer-events:none; z-index:3;
}
.item:hover .video-name { opacity:1; transform: translateY(0); }

.work-type  { font-family: var(--mono); font-size:10px; font-weight:700;
              letter-spacing:0.08em; text-transform:uppercase; color: var(--lilac); }
.work-title { font-family: var(--display); font-weight:600; font-size:23px;
              line-height:1.12; letter-spacing:-0.02em; color: var(--ink); text-wrap:balance; }

Each caption is two lines — a mono type label ("Music film", "Aftermovie") over the work's title — rising from the lower third on hover, over a gradient scrim.

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.

Images

This component ships with 23 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/react-pan-canvas/img1.jpg
https://motionprompts.dev/c/react-pan-canvas/img10.jpg
https://motionprompts.dev/c/react-pan-canvas/img11.jpg
https://motionprompts.dev/c/react-pan-canvas/img12.jpg
https://motionprompts.dev/c/react-pan-canvas/img13.jpg
https://motionprompts.dev/c/react-pan-canvas/img14.jpg
… 17 more under https://motionprompts.dev/c/react-pan-canvas/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--canvas, --ink, --muted, --lilac, --pop, plus the type variables --sans, --display, --mono. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.

Adapting this to React

Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: two mousemove listeners on the same container, two independent touch-drag state machines tracking the same finger. The visible symptom is jitter or doubled writes, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script checks document.readyState before subscribing to DOMContentLoaded. That guard exists to survive being loaded late in a plain document; in React it is dead weight, because useEffect already runs after the DOM is committed. Drop the guard and the listener both, and keep only init's body, inside a useEffect with an empty dependency array.

*(2) Element lookups* — init looks up .container and .gallery exactly once, at setup, and never queries again afterward: handleMouseMove reads e.currentTarget for the bounding rect and closes over the gallery reference for the write, and the touch handlers do the same. So there's no per-event document.querySelector to worry about — only these two initial lookups. Give the component a root ref, render .container on it, and resolve .gallery through that same root (root.querySelector(".gallery")) rather than the bare document. An unscoped lookup will happily bind the mousemove handler to whichever .gallery is first in the whole document, and during the StrictMode remount two copies of this subtree briefly coexist.

*(3) Cleanup* — There is no GSAP context, ticker, Lenis instance, or rAF handle to revert here: the entire component is four raw listeners bound to .container (mousemove, touchstart, touchmove, touchend). Cleanup is nothing more than calling removeEventListener four times with the exact function references addEventListener was given — declare handleMouseMove and the three touch handlers as named consts inside the effect rather than inline arrows, or the function removeEventListener receives in the cleanup won't be the same reference that was registered and the call will silently no-op. Skip this and the StrictMode remount leaves the first pass's four listeners bound alongside the second pass's four, permanently, on the same node. For handleMouseMove the leak is silent rather than visibly broken, because it's a pure function of cursor position with no interpolation or accumulator: a duplicate just recomputes and rewrites the identical transform string a second time per event. The touch path is where it stops being silent — panX, panY, lastX, lastY and dragging are closured variables private to one call of init, so a leaked set from an earlier mount keeps its own copy of that state, tracks the same finger independently of the current mount's copy, and both write gallery.style.transform on every touchmove; whichever handler happens to run last for that dispatch wins the paint, a race decided by listener-registration order rather than anything in the effect. Also reset gallery.style.transform to an empty string in the same cleanup: the pan lives entirely in that inline style — the CSS rule only supplies the initial centered transform, and the first mousemove overwrites it for good — so without clearing it, a component that unmounts mid-pan and later remounts starts already offset instead of centered.