OKAY DEV logo OKAY DEV logo
Building an Infinite Grid
CSS
Developer
Front-end
JavaScript
Tutorial
UI/UX
Web Development
Mathias Adolfsson Avatar
Tutorial · 6 min read · 4 days ago

Introduction

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).
  • #grid holds the repeated tiles and items.

     

<div id="viewport">
  <div id="container">
    <div id="grid"></div>
  </div>
</div>

CSS

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.

* {
  margin: 0;
  padding: 0;
}
body {
  overflow: hidden;
}
#viewport {
  position: relative;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
  cursor: grab;
}
#viewport.grabbing {
  cursor: grabbing;
}
#container {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  transform-origin: center center;
  will-change: transform;
}
#grid {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.grid-item {
  position: absolute;
  padding: 25px;
  box-sizing: border-box;
  overflow: hidden;
  will-change: transform;
  user-select: none;
  backface-visibility: hidden;
}
.grid-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  pointer-events: none;
}

State management

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.

const state = {
  gridItems: [],
  cameraOffset: { x: 0, y: 0 },
  targetOffset: { x: 0, y: 0 },
  isDragging: false,
  previousMousePosition: { x: 0, y: 0 },
  touchStart: null,
  containerRotationX: 0,
  containerRotationY: 0,
  targetRotationX: 0,
  targetRotationY: 0,
  containerScale: 1,
  targetScale: 1,
  scrollSpeed: 0
};

The grid system

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 });
        }
      }
    }
  }
};

The infinite scroll

This happens in updateItemPositions().

const updateItemPositions = () => {
  const totalWidth = cellWidth * CONFIG.COLS;
  const totalHeight = cellHeight * CONFIG.ROWS;
  state.gridItems.forEach(({ element, baseX, baseY, tileX, tileY, yOffset }) => {
    const baseOffsetX = state.cameraOffset.x % totalWidth;
    const baseOffsetY = state.cameraOffset.y % totalHeight;
    const x = baseX * cellWidth + tileX * totalWidth - baseOffsetX;
    const y = baseY * cellHeight + tileY * totalHeight - baseOffsetY + yOffset;
    element.style.transform = `translate3d(${x}px, ${y}px, 0)`;
  });
};

The key steps are:

  • 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.

const viewport = document.getElementById('viewport');
const container = document.getElementById('container');
const grid = document.getElementById('grid');
const onMouseDown = (e) => {
  state.isDragging = true;
  viewport.classList.add('grabbing');
  state.previousMousePosition = { x: e.clientX, y: e.clientY };
};
const onMouseUp = () => {
  state.isDragging = false;
  viewport.classList.remove('grabbing');
  state.targetRotationX = 0;
  state.targetRotationY = 0;
};

The animation

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.

const animate = () => {
  requestAnimationFrame(animate);
  const dx = state.targetOffset.x - state.cameraOffset.x;
  const dy = state.targetOffset.y - state.cameraOffset.y;
  if (Math.abs(dx) > 0.01 || Math.abs(dy) > 0.01) {
    state.cameraOffset.x += dx * CONFIG.easingFactor;
    state.cameraOffset.y += dy * CONFIG.easingFactor;
    updateItemPositions();
  }
  const speedFactor = Math.min(state.scrollSpeed * 0.01, 1);
  state.targetScale = 1 - (speedFactor * CONFIG.maxScaleEffect);
  state.scrollSpeed *= 0.85;
  state.containerScale += (state.targetScale - state.containerScale) * CONFIG.scaleEasing;
  state.containerRotationX += (state.targetRotationX - state.containerRotationX) * CONFIG.rotationEasing;
  state.containerRotationY += (state.targetRotationY - state.containerRotationY) * CONFIG.rotationEasing;
  container.style.transform =
    `scale(${state.containerScale}) skewY(${state.containerRotationX}deg) skewX(${state.containerRotationY}deg)`;
};
Skew and Scale
Skew & Scale

Conclusion

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.

Demo and code

Mathias Adolfsson Avatar