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 itsmousemovelistener here and reads *its* bounding rect for the center..gallery— the single oversized panning surface; the JS writes itstransform. 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:idis intentionally reused across tiles — it is styled/hovered by CSS, never queried by JS), and a.work-video-wrapperwhose 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-playerchild 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 = 1means no damping:deltaXranges over±width/2(≈ ±50vw) anddeltaYover±height/2(≈ ±50vh). Because the gallery is 200vw × 200vh centered withtranslate(-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 thecalc():translate(calc(-50% + Δx), calc(-50% + Δy)). Do not replace the percentage centering with pixels. clientX/clientYare 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 imgopacity 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'soverflow:hidden).#videoNameopacity 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:
- 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
transformwrite per mouse event with GPU-composited CSS transition; no rAF loops, no per-frame layout.