DesignFrontendPerformance

Designing and creating an animated polygon background with canvas

The moving triangles on our homepage are drawn with canvas. Here's how the grid stays connected, plus a demo for trying the settings yourself.

Why animate the background?

We wanted a little movement behind the homepage, with a connection to the triangles in our logo. It also needed to stay out of the way of the text and work on different screen sizes.

The result is drawn in the browser on an HTML canvas. The triangles move slowly and fade toward the bottom. There’s no video to download or animation library to load.

Here’s how the pieces fit together. The numbers suit this design, but you can adjust them for your own background.

Skip ahead to try it out

Why use canvas instead of CSS?

CSS is excellent for a known set of shapes. We used it for the Lakes Interactive logo because that mark contains only 17 triangles in fixed positions. A full-screen background is a different problem: it may contain hundreds of triangles, its dimensions change with the viewport, and every neighboring shape needs to continue sharing an edge while it moves.

Canvas gives us one drawing surface and one animation loop. We can calculate the grid for the current viewport, draw each triangle directly, and clear the whole result without creating hundreds of DOM elements.

The markup stays small:

<div class="polygon-mesh" aria-hidden="true">
  <canvas></canvas>
  <span class="polygon-mesh__light"></span>
</div>

The extra element provides a subtle light wash above the canvas. The canvas owns the geometry; CSS handles the final art direction.

Because the background is decorative, the wrapper is hidden from assistive technology. The page does not depend on the canvas for content or navigation.

Build one shared point grid

The most important rule is that adjacent triangles must use the same point objects. If every triangle calculated its corners independently, tiny rounding or timing differences would open visible cracks between shapes as they moved.

We first divide the available area into rows and columns. Interior points receive a seeded offset so the grid does not look mechanical. Edge points stay attached to the canvas boundary.

type MeshPoint = {
  baseX: number;
  baseY: number;
  x: number;
  y: number;
  amplitudeX: number;
  amplitudeY: number;
  phaseX: number;
  phaseY: number;
  fixedX: boolean;
  fixedY: boolean;
};

columns = Math.max(4, Math.ceil(width / 145));
rows = Math.max(7, Math.ceil(height / 120));

const cellWidth = width / columns;
const cellHeight = height / rows;

Each MeshPoint stores a stable starting position and its current animated position. It also receives a horizontal and vertical movement range, plus separate phases so every point does not sway in unison.

The seeded variation matters. Ordinary Math.random() would generate a different composition after every resize, which can make the background jump. A deterministic value gives the same row and column the same character each time the grid is rebuilt.

Split every cell into two triangles

Four neighboring points form one quadrilateral cell. We divide that cell diagonally and draw two triangles. Alternating the diagonal direction keeps the field from developing an obvious repeated zigzag.

if (seededValue(seed + 43) > 0.5) {
  drawTriangle([topLeft, topRight, bottomRight], seed * 2);
  drawTriangle([topLeft, bottomRight, bottomLeft], seed * 2 + 1);
} else {
  drawTriangle([topLeft, topRight, bottomLeft], seed * 2);
  drawTriangle([topRight, bottomRight, bottomLeft], seed * 2 + 1);
}

Both triangles refer back to the shared grid points. When one point moves, every triangle touching that point moves with it. The surface bends, but its seams remain connected.

We add a very faint stroke after each fill. It is not intended to outline the polygons. It simply softens the occasional subpixel seam that can appear when a browser rasterizes two angled edges.

Move points instead of triangles

Animating whole triangles independently would pull the mesh apart. Instead, we animate the grid points and redraw all of the triangles around their updated positions.

const horizontalCycle = (time / 18000) * Math.PI * 2;
const verticalCycle = (time / 22000) * Math.PI * 2;

point.x = point.fixedX
  ? point.baseX
  : point.baseX + Math.sin(horizontalCycle + point.phaseX) * point.amplitudeX;

point.y = point.fixedY
  ? point.baseY
  : point.baseY + Math.sin(verticalCycle + point.phaseY) * point.amplitudeY;

The horizontal and vertical cycles use different durations. That prevents the motion from quickly returning to an obvious loop. Each point also has its own phase, so the surface feels like it is breathing rather than sliding as one rigid sheet.

Movement remains intentionally small. The goal is not to make visitors watch the background. It is to give the page a little depth when they happen to notice it.

Let position influence color

A uniformly random field produced too many dark patches behind the homepage text. We wanted more structure: richer blues near the top and left, a gentle highlight through the middle, and pale triangles toward the bottom.

For every triangle, we calculate its center as a percentage of the canvas width and height. Those values influence hue, saturation, and lightness.

const horizontalLight = centerX / width;
const verticalLight = centerY / height;

const lowerLift = Math.pow(verticalLight, 1.65) * 55;
const variation = (seededValue(seed) - 0.5) * variationRange;

const lightness =
  35 +
  horizontalLight * 4 +
  verticalLight * 14 +
  lowerLift +
  centerHighlight +
  variation;

The vertical lift is curved rather than linear. The upper field retains enough contrast for white navigation and headings, while the lower field transitions more decisively toward near-white facets.

Seeded variation keeps neighboring polygons distinct. We clamp the final lightness so an unlucky value cannot produce a nearly black or fully white triangle where it would break the composition.

Finally, a CSS overlay adds a soft radial glow and a translucent white gradient. Keeping that treatment in CSS makes it easy to adjust the overall mood without changing the drawing code.

Resize without making the canvas blurry

A canvas has both a CSS display size and an internal bitmap size. If those sizes do not account for a high-density screen, lines and angled edges can look soft.

We scale the bitmap using the device pixel ratio, but cap that ratio at 1.5. A decorative background does not need the same pixel density as small text or detailed illustration, and the cap prevents unnecessary work on very dense displays.

const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5);

canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;

context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);

A ResizeObserver rebuilds the grid when the component changes size. We schedule that work through requestAnimationFrame so several resize notifications can collapse into one update.

Spend animation work only when it is visible

Canvas animation can be lightweight, but it is never free. A decorative effect should not keep drawing when the visitor cannot see it.

The loop checks three conditions:

  • The mesh intersects the viewport.
  • The browser tab is visible.
  • The visitor has not requested reduced motion.
const shouldAnimate = () =>
  isInView &&
  !document.hidden &&
  !reducedMotion.matches;

An IntersectionObserver pauses work when the mesh leaves the viewport. The Page Visibility API stops it when the visitor changes tabs. The drawing loop is also capped at 30 frames per second; slow geometric motion does not benefit enough from 60 frames per second to justify twice the work.

When animation pauses, the last frame remains visible. There is no flashing, placeholder, or layout shift.

Treat reduced motion as a different mode

prefers-reduced-motion should do more than shorten the animation. In this case, we stop the loop and render one deterministic frame.

if (reducedMotion.matches) {
  drawMesh(0);
} else if (shouldAnimate()) {
  animationFrame = requestAnimationFrame(animateMesh);
}

The background still looks intentional, but none of its points move. If the preference changes while the page is open, the component updates without requiring a reload.

This is possible because motion was never essential to the layout. The static state was designed alongside the animated state rather than treated as a fallback at the end.

Keep the effect subordinate to the page

The hardest part of an animated background is rarely drawing the shapes. It is knowing when the effect is quiet enough.

We tuned the homepage around a few practical checks:

  • Navigation and hero copy retain strong contrast across the full motion range.
  • Triangle movement is slow and bounded.
  • The palette follows a deliberate lightness path instead of changing randomly.
  • The canvas pauses when it cannot contribute to the experience.
  • Reduced-motion visitors receive a composed static background.
  • The effect adds no third-party request and no animation dependency.

The result is not a general-purpose particle system. It is a focused visual component built for one page and one brand. That constraint is useful: it keeps the code small, the motion purposeful, and the homepage content in charge.

Canvas playground

Tune the mesh. See it move.

Use the safe controls to explore the animation. The CSS and JavaScript below are read-only reference examples.

CSS Read only

html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #147da3; }

canvas { display: block; width: 100%; height: 100%; }

.light-wash {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    radial-gradient(circle at 66% 62%, rgb(139 225 230 / .14), transparent 24%),
    linear-gradient(to bottom, transparent 34%, rgb(249 254 255 / .4));
  mix-blend-mode: screen;
}

JavaScript Read only

const preview = document.querySelector("[data-canvas-preview]");
const canvas = preview.querySelector("canvas");
const context = canvas.getContext("2d");

const settings = {
  density: 7,
  speed: 1.35,
  hue: 198,
  topLightness: 34,
  bottomLift: 50,
  movement: 0.14
};

let width, height, rows, points;
const random = (seed) => {
  const value = Math.sin(seed * 12.9898) * 43758.5453;
  return value - Math.floor(value);
};

function buildMesh() {
  width = canvas.width = preview.clientWidth;
  height = canvas.height = preview.clientHeight;
  rows = Math.round(settings.density * 0.75);
  points = Array.from({ length: rows + 1 }, (_, row) =>
    Array.from({ length: settings.density + 1 }, (_, column) => ({
      x: column * width / settings.density,
      y: row * height / rows,
      phase: random(row * 101 + column * 37) * Math.PI * 2
    }))
  );
}

function drawTriangle(a, b, c, seed) {
  const depth = (a.y + b.y + c.y) / 3 / height;
  const lightness = settings.topLightness +
    depth * settings.bottomLift + (random(seed) - 0.5) * 18;
  context.fillStyle = `hsl(${settings.hue} 62% ${lightness}%)`;
  context.beginPath();
  context.moveTo(a.x, a.y);
  context.lineTo(b.x, b.y);
  context.lineTo(c.x, c.y);
  context.fill();
}

function draw(time) {
  const moved = points.map((line) => line.map((point) => ({
    x: point.x + Math.sin(time * 0.0003 * settings.speed + point.phase) *
      width / settings.density * settings.movement,
    y: point.y + Math.cos(time * 0.00025 * settings.speed + point.phase) *
      height / rows * settings.movement
  })));

  context.fillStyle = "#147da3";
  context.fillRect(0, 0, width, height);
  for (let row = 0; row < rows; row += 1) {
    for (let column = 0; column < settings.density; column += 1) {
      const a = moved[row][column];
      const b = moved[row][column + 1];
      const c = moved[row + 1][column];
      const d = moved[row + 1][column + 1];
      const seed = row * settings.density + column;
      drawTriangle(a, b, d, seed * 2);
      drawTriangle(a, d, c, seed * 2 + 1);
    }
  }
  requestAnimationFrame(draw);
}

document.querySelectorAll("[data-canvas-setting]").forEach((input) => {
  input.addEventListener("input", () => {
    const name = input.dataset.canvasSetting;
    settings[name] = name === "movement" ? input.value / 100 : Number(input.value);
    if (name === "density") buildMesh();
  });
});

new ResizeObserver(buildMesh).observe(preview);
buildMesh();
requestAnimationFrame(draw);

Canvas preview ready.