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 become lighter toward the bottom. There’s no video to download or animation library to load. This article’s hero now uses the same component as the homepage.
Here’s how the pieces fit together. The excerpts explain the production component; the playground at the end provides a smaller, standalone example of the same ideas. Its code follows the selected settings and includes mouse interaction only when you turn it on.
What is canvas, and how do you use it?
HTML canvas is a drawing area on a web page. JavaScript can draw shapes, text, images, and other graphics onto it. To animate a scene, the code can draw a new frame over and over, like a flipbook. The shapes become pixels on the canvas rather than separate HTML elements you can select or style individually.
Here’s a small example. First, add a canvas to the HTML. Its width and height attributes set the size of its internal drawing area:
<canvas id="example-canvas" width="480" height="240"
role="img" aria-label="A blue rectangle on a light background">
A blue rectangle on a light background.
</canvas>
CSS selects the element just like any other element. It controls how large the canvas appears and can style its background or border:
#example-canvas {
display: block;
width: 100%;
max-width: 480px;
height: auto;
background: #e6f4f7;
}
Then JavaScript finds that element, asks it for a two-dimensional drawing context, and uses the context to draw. Put this script after the canvas in the HTML, or run it after the document has loaded:
const canvas = document.querySelector("#example-canvas");
const context = canvas?.getContext("2d");
if (context) {
context.fillStyle = "#087bb7";
context.fillRect(40, 40, 160, 100);
}
The rectangle starts 40 units from the left and 40 from the top of the drawing area, and measures 160 by 100. The if check avoids drawing if the element or its 2D context isn’t available. CSS styles the canvas as a whole; the rectangle’s color comes from JavaScript’s fillStyle.
This example draws once. For motion, use requestAnimationFrame() to schedule each new frame, update the positions, and redraw. Also keep the internal drawing size in step with the displayed size: CSS alone can stretch the bitmap and make it blurry. We’ll cover high-density screens and the animation loop below. For an illustration that conveys information, provide an accessible text alternative; this site’s background is decorative, so we hide it from assistive technology.
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. These are the core point fields for the background’s automatic motion; pointer interaction adds separate displacement and velocity fields.
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.
Shared points prevent the geometry from separating, but browser antialiasing can still reveal hairline seams. The full-size component uses a faint stroke after each fill to soften these edges. Compact previews instead overlap the edges with each triangle’s own fill color, covering the canvas beneath without adding contrasting outlines.
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.
The excerpt shows the base motion. The homepage also applies a configurable speed and a smooth acceleration over its first four seconds. That changes how quickly time advances through the same motion calculations, rather than changing the grid itself.
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.
Add optional mouse interaction
On a desktop, nearby corners can also move toward the mouse. We measure the pointer’s position inside the background, calculate how far each corner is from it, and add a temporary displacement to that corner’s normal animated position. The outer edges stay attached to the canvas.
The pull weakens with distance and fades after the pointer stops moving. In the production component, a damped spring eases the corners back into place. A primary mouse click briefly strengthens the pull and adds a small aqua flash.
The playground offers the hover pull through its Mouse interaction button, with simpler easing and no click flash. Turn it on to include the pointer helper and listeners in the displayed JavaScript; turn it off to return to the basic animation. Touch-only devices and visitors who request reduced motion keep this interaction off, with an explanation beside the button.
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. Position and seeded variation influence its color. The homepage’s current Lakes palette keeps the blues saturated while increasing lightness toward the bottom:
const horizontalLight = centerX / width;
const verticalLight = centerY / height;
const variationRange = 18 + verticalLight * 22;
const variation = (seededValue(seed) - 0.5) * variationRange;
const highlightDistance = Math.hypot(horizontalLight - 0.62, verticalLight - 0.62);
const centerHighlight = Math.max(0, 1 - highlightDistance / 0.48) * 10;
const lightness =
35 +
horizontalLight * 3 +
verticalLight * 20 +
Math.pow(verticalLight, 1.7) * 30 +
centerHighlight * 0.4 +
variation * 0.8;
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. The homepage and article hero add a blue shading layer to protect text contrast. 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 a focused visual component built for this site’s visual system. That constraint is useful: it keeps the code small, the motion purposeful, and the page content in charge.
Try the simplified example
The playground’s sliders adjust density, speed, hue, lightness, and movement. The highlighted settings in the JavaScript update as you change them. Reset example restores the defaults and turns mouse interaction off.
The HTML, CSS, and JavaScript reference panels show a standalone version with comments above the main steps. It uses a regular starting grid and a simpler color formula. It leaves out the production component’s irregular starting offsets, speed ramp, high-density bitmap scaling, offscreen observer, and frame-rate cap so the core drawing loop is easier to follow. The live playground itself still includes high-density scaling, offscreen pausing, a 30-frame-per-second cap, and reduced-motion support.
The News thumbnail is a compact illustration of the effect, with its grid sized for the card. It does not need the full hero’s geometry or pointer behavior to show the idea.
Sources and further reading
Tune the mesh. See it move.
Use the controls to explore the animation. The HTML, CSS, and JavaScript below show how the example works.
Turn on, then move your mouse over the preview to pull nearby triangle corners toward it.
Start with the HTML
The container marked data-canvas-preview holds the <canvas> drawing surface and a .light-wash element for a soft lighting overlay. JavaScript uses the container to find the canvas.
HTML Read only
<div data-canvas-preview>
<canvas aria-hidden="true"></canvas>
<span class="light-wash" aria-hidden="true"></span>
</div>CSS Read only
CSS sizes the preview and canvas, clips the edges, and places the light wash over the drawing.
/* Set the page background and remove default spacing. */
html, body {
margin: 0;
background: #147da3;
}
/* Give the preview a height and contain its lighting overlay. */
[data-canvas-preview] {
position: relative;
height: 60vh;
overflow: hidden;
}
/* Stretch the drawing surface to fill the preview. */
canvas {
display: block;
width: 100%;
height: 100%;
}
/* Add soft lighting without blocking mouse events. */
.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
JavaScript gets the canvas’s 2D drawing context, builds a grid of shared points, and redraws the triangles as those points move.
Basic animation only. Turn on Mouse interaction to include its code.
// Find the preview's canvas and get its 2D drawing tools. const preview = document.querySelector("[data-canvas-preview]"); const canvas = preview.querySelector("canvas"); const context = canvas.getContext("2d"); const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); // Adjust the mesh density, motion, and color. const settings = { density: 7, speed: 1.35, hue: 198, topLightness: 34, bottomLift: 50, movement: 0.14 }; // Keep the grid dimensions, points, and animation frame between draws. let width, height, rows, cellWidth, cellHeight; let points, animationFrame; // Produce repeatable variation so resizing does not shuffle the pattern. function random(seed) { const value = Math.sin(seed * 12.9898) * 43758.5453; return value - Math.floor(value); } // Build one grid of points shared by neighboring triangles. function buildMesh() { width = canvas.width = preview.clientWidth; height = canvas.height = preview.clientHeight; rows = Math.max(4, Math.round(settings.density * 0.75)); cellWidth = width / settings.density; cellHeight = height / rows; points = Array.from({ length: rows + 1 }, (_, row) => Array.from({ length: settings.density + 1 }, (_, column) => ({ baseX: column * cellWidth, baseY: row * cellHeight, phase: random(row * 101 + column * 37) * Math.PI * 2, fixedX: column === 0 || column === settings.density, fixedY: row === 0 || row === rows })) ); } // Move interior points while keeping the outer edges attached. function movePoint(point, time) { const sway = time * settings.speed; const position = { x: point.baseX + (point.fixedX ? 0 : Math.sin(sway * 0.0003 + point.phase) * cellWidth * settings.movement), y: point.baseY + (point.fixedY ? 0 : Math.cos(sway * 0.00025 + point.phase) * cellHeight * settings.movement) }; return position; } // Color and fill one triangle, with lighter colors toward the bottom. 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(); } // Move the shared points and redraw every connected triangle. function draw(time) { const motionTime = reducedMotion.matches ? 0 : time; const moved = points.map((row) => row.map((point) => movePoint(point, motionTime)) ); 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); } } if (!document.hidden && !reducedMotion.matches) { animationFrame = requestAnimationFrame(draw); } } // Restart safely after a resize or a change in motion preferences. function syncAnimation() { cancelAnimationFrame(animationFrame); draw(performance.now()); } // Rebuild the grid when the preview changes size. new ResizeObserver(() => { buildMesh(); syncAnimation(); }).observe(preview); // Respond when motion preferences or tab visibility change. reducedMotion.addEventListener("change", syncAnimation); document.addEventListener("visibilitychange", syncAnimation); // Draw the first frame and start the animation when motion is allowed. buildMesh(); syncAnimation();// Find the preview's canvas and get its 2D drawing tools. const preview = document.querySelector("[data-canvas-preview]"); const canvas = preview.querySelector("canvas"); const context = canvas.getContext("2d"); const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); const finePointer = matchMedia("(hover: hover) and (pointer: fine)"); const pointer = { x: 0, y: 0, movedAt: 0 }; // Adjust the mesh density, motion, and color. const settings = { density: 7, speed: 1.35, hue: 198, topLightness: 34, bottomLift: 50, movement: 0.14 }; // Keep the grid dimensions, points, and animation frame between draws. let width, height, rows, cellWidth, cellHeight; let points, animationFrame; // Produce repeatable variation so resizing does not shuffle the pattern. function random(seed) { const value = Math.sin(seed * 12.9898) * 43758.5453; return value - Math.floor(value); } // Build one grid of points shared by neighboring triangles. function buildMesh() { width = canvas.width = preview.clientWidth; height = canvas.height = preview.clientHeight; rows = Math.max(4, Math.round(settings.density * 0.75)); cellWidth = width / settings.density; cellHeight = height / rows; points = Array.from({ length: rows + 1 }, (_, row) => Array.from({ length: settings.density + 1 }, (_, column) => ({ baseX: column * cellWidth, baseY: row * cellHeight, phase: random(row * 101 + column * 37) * Math.PI * 2, fixedX: column === 0 || column === settings.density, fixedY: row === 0 || row === rows, offsetX: 0, offsetY: 0 })) ); } // Move interior points while keeping the outer edges attached. function movePoint(point, time) { const sway = time * settings.speed; const position = { x: point.baseX + (point.fixedX ? 0 : Math.sin(sway * 0.0003 + point.phase) * cellWidth * settings.movement), y: point.baseY + (point.fixedY ? 0 : Math.cos(sway * 0.00025 + point.phase) * cellHeight * settings.movement) }; return pullTowardMouse(position, point, time); } // Pull nearby corners toward the mouse, then ease them back. function pullTowardMouse(position, point, time) { if (!finePointer.matches || reducedMotion.matches) { point.offsetX = point.offsetY = 0; return position; } const activity = pointer.movedAt > 0 ? Math.exp(-(time - pointer.movedAt) / 520) : 0; const distanceX = pointer.x - position.x; const distanceY = pointer.y - position.y; const distance = Math.hypot(distanceX, distanceY); const influence = Math.max(0, 1 - distance / 300) ** 1.2 * activity; const pull = Math.min(distance * 0.62, 46) * influence; const targetX = distance > 0 ? distanceX / distance * pull : 0; const targetY = distance > 0 ? distanceY / distance * pull : 0; point.offsetX = point.fixedX ? 0 : point.offsetX + (targetX - point.offsetX) * 0.18; point.offsetY = point.fixedY ? 0 : point.offsetY + (targetY - point.offsetY) * 0.18; return { x: position.x + point.offsetX, y: position.y + point.offsetY }; } // Color and fill one triangle, with lighter colors toward the bottom. 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(); } // Move the shared points and redraw every connected triangle. function draw(time) { const motionTime = reducedMotion.matches ? 0 : time; const moved = points.map((row) => row.map((point) => movePoint(point, motionTime)) ); 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); } } if (!document.hidden && !reducedMotion.matches) { animationFrame = requestAnimationFrame(draw); } } // Measure mouse movement relative to the preview, not the whole page. preview.addEventListener("pointermove", (event) => { if (!finePointer.matches || reducedMotion.matches || event.pointerType === "touch") return; const bounds = preview.getBoundingClientRect(); pointer.x = event.clientX - bounds.left; pointer.y = event.clientY - bounds.top; pointer.movedAt = performance.now(); }); preview.addEventListener("pointerleave", () => { pointer.movedAt = 0; }); // Restart safely after a resize or a change in motion preferences. function syncAnimation() { cancelAnimationFrame(animationFrame); draw(performance.now()); } // Rebuild the grid when the preview changes size. new ResizeObserver(() => { buildMesh(); syncAnimation(); }).observe(preview); // Respond when motion preferences or tab visibility change. reducedMotion.addEventListener("change", syncAnimation); document.addEventListener("visibilitychange", syncAnimation); // Draw the first frame and start the animation when motion is allowed. buildMesh(); syncAnimation();
Canvas preview ready.