My goal was to create a grid that feels endless — something you can drag or scroll in any direction without ever hitting an edge — while keeping the implementation lightweight and dependency-free.
I shared this “infinite” image grid / gallery in the Okay Dev Discord that I built in my spare time, and I was immediately encouraged to write something about it here.
In this tutorial, we’ll build an infinite grid using only HTML, CSS, and JavaScript — no WebGL or external libraries required.
The grid will:
Scroll infinitely in all directions
Scale and skew based on motion
Always cover the viewport
Remain fully responsive
The key is to simulate camera movement over a tiled surface.
Infinite Grid
Core concept
In order for the grid to appear infinite, I’m using a tiling technique.
It renders a 3×3 grid of images, and then repeats that grid outward in both axes. As you scroll or drag, when you move past one repeated block, another seamlessly appears on the opposite side. Because each repeated block is identical in structure and spacing, the transition is invisible, creating the illusion of endless movement.
3X3 Repeating Grid
Disclaimer: This infinite grid is designed for a desktop experience. For mobile, it’s best to use a simplified approach optimized for smaller screens and lower rendering overhead.
HTML structure
The layout is split into three layers:
#viewport clips everything to the screen (never transforms).
#container is the transform stage (scale/skew live here).
The viewport clips the content, and the container is where transforms are applied. This keeps the clipping boundary perfectly aligned to the viewport even while skewing and scaling.
A state object keeps track of camera position, interactions, effects, and scaling.
Camera position.cameraOffset is the current camera position, while targetOffset is where it’s heading (this enables easing).
Interactions.isDragging, previousMousePosition, and touchStart handle mouse and touch inputs.
Effects. Rotation values (containerRotationX/containerRotationY) and their corresponding targets create a skewing effect when scrolling, with smooth transitions.
Scaling.containerScale and targetScale create a subtle zoom-out effect during fast scrolling.
Speed.scrollSpeed measures how fast movement occurs and drives the rotation and scaling effects.
Thinking of this system as moving a camera rather than moving individual elements makes the logic much easier to reason about.
Item dimensions are calculated based on the viewport, divided into three columns and three rows. The sizing is computed to ensure the grid covers the viewport, even while the container scales down during fast scrolling.
The repeated tile count is also computed from the viewport dimensions so there are always enough surrounding tiles to fill the screen.
const CONFIG = {
COLS: 3,
ROWS: 3,
easingFactor: 0.1,
rotationStrength: 0.1,
rotationEasing: 0.06,
scaleEasing: 0.08,
maxScaleEffect: 0.2,
tileOverscan: 1
};
let cellWidth, cellHeight;
let tilesX = 1, tilesY = 1;
const calculateCellSizeAndTiling = () => {
const vw = window.innerWidth;
const vh = window.innerHeight;
// The container can scale down to (1 - maxScaleEffect).
// We compensate so the grid still covers the viewport at minimum scale.
const minScale = 1 - CONFIG.maxScaleEffect;
const requiredCoverFactor = 1 / minScale;
// "Cover" sizing: base pattern covers the viewport.
const size = Math.max(vw / CONFIG.COLS, vh / CONFIG.ROWS) * requiredCoverFactor;
cellWidth = size;
cellHeight = size;
const totalWidth = cellWidth * CONFIG.COLS;
const totalHeight = cellHeight * CONFIG.ROWS;
// Compute how many repeated blocks are needed in each direction.
tilesX = Math.max(1, Math.ceil((vw * requiredCoverFactor) / totalWidth) + CONFIG.tileOverscan);
tilesY = Math.max(1, Math.ceil((vh * requiredCoverFactor) / totalHeight) + CONFIG.tileOverscan);
};
Creating the grid is done by nesting loops:
The outer loops (tileX and tileY) repeat the entire 3×3 grid outward.
The inner loops create the actual 3×3 grid of images inside each repeated block.
const createGridItems = () => {
grid.innerHTML = '';
state.gridItems = [];
for (let tileY = -tilesY; tileY <= tilesY; tileY++) {
for (let tileX = -tilesX; tileX <= tilesX; tileX++) {
for (let y = 0; y < CONFIG.ROWS; y++) {
for (let x = 0; x < CONFIG.COLS; x++) {
const element = document.createElement('div');
element.className = 'grid-item';
const baseX = x;
const baseY = y;
// Subtle parallax based on x-position
const yOffset = x * cellHeight * 0.15;
element.style.width = `${cellWidth}px`;
element.style.height = `${cellHeight}px`;
const src = imageFor(baseX, baseY);
element.innerHTML = `<img src="${src}" alt="Creature ${baseX}, ${baseY}" loading="lazy" decoding="async">`;
grid.appendChild(element);
state.gridItems.push({ element, baseX, baseY, tileX, tileY, yOffset });
}
}
}
}
};
Calculate the totalWidth and totalHeight of one complete grid block.
Wrap the camera offset using the modulo operator.
Position each item using its base position, tile offset, and wrapped camera offset.
This wrapping is what makes the grid appear infinite — once the camera moves past one repeated block, the modulo operation naturally brings another into view.
Interactive effects
Dragging and scrolling update targetOffset, which makes the camera move.
The movement delta in mouse/touch position determines scroll speed, which triggers additional effects.
Rotation uses skew transforms to create a 3D-like tilt effect when scrolling. Horizontal movement affects vertical skew, and vertical movement affects horizontal skew. These gradually return to neutral when movement stops, creating a satisfying settle animation.
Scale creates a subtle zoom-out effect during fast scrolling. The scrollSpeed (calculated from movement delta) determines how much to scale down, with a maximum effect of 5%. This speed value decays each frame, so the effect fades after you stop scrolling.
Event handling
Mouse, touch, and wheel events all update targetOffset, allowing consistent interaction across input types. The grab cursor state is applied to the viewport so it matches the clipped area.
Even though I love GSAP, I chose not to use it here — partly to keep dependencies minimal, and partly as a challenge to write my own interpolation.
The animation runs continuously using requestAnimationFrame. Each frame eases camera movement, updates positions, applies tilt and scale, and gradually settles motion.
The infinite effect comes from combining tiling, camera offset wrapping, and smooth interpolation.
By treating movement as a camera rather than moving individual elements directly, the logic stays simple while still producing a rich, interactive result.
It’s a lightweight approach that performs well and doesn’t require WebGL or external libraries.