← Back to blog

Published on September 29, 2026

Animated Website Backgrounds: Animated Gradient, Isometric Grid and Caustics Without Libraries

  • css
  • javascript
  • web-development

Libraries like Aceternity UI and the background collections on shadcn.io have popularised effects you now see on countless landing pages: grids fading towards the edges, isometric grids lighting up under the cursor, moving water light, colour gradients slowly blending into each other. They are React components, usually tied to Tailwind CSS and animation libraries: the installation pages of Aceternity's components, for example, list motion, clsx and tailwind-merge as dependencies.

In this article I rebuild four of these backgrounds without libraries. Some are pure CSS, others need a bit of JavaScript, and for each one I explain why and what it costs. It's the first of a three-part series: next come mouse-driven effects and scroll animations.

Every demo has controls, and the code below the preview updates with the same values. You can copy it in three forms: standard HTML/CSS/JS, a React component (.tsx with a CSS module) or a React component with Tailwind CSS v4. Each file has its own "Copy" button.

Grid and dots: just repeated gradients

A grid pattern doesn't need an image. You get it from two stacked linear-gradients: one draws a thin vertical line, the other a horizontal one. Each gradient switches from the line colour to transparent at the same point (say 1px), producing a hard edge instead of a blend.

background-size does the repeating: if the background image is 32×32 pixels, the browser tiles it across the whole surface. Dots work the same way, but with a single radial-gradient: a solid 1–2 pixel circle in the centre of each cell.

Demo

Grid and dots with gradients

Your headline here

Code

index.html

<section class="bg-pattern">…</section>

styles.css

.bg-pattern {
  position: relative;
  isolation: isolate;
  background-color: #0a0a0a;
}

.bg-pattern::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background-image:
    linear-gradient(to right, rgb(250 250 249 / 0.22) 1px, transparent 1px),
    linear-gradient(to bottom, rgb(250 250 249 / 0.22) 1px, transparent 1px);
  background-size: 32px 32px;
  -webkit-mask-image: radial-gradient(ellipse at center, #000 20%, transparent 70%);
  mask-image: radial-gradient(ellipse at center, #000 20%, transparent 70%);
  pointer-events: none;
}

The "landing page" look comes from fading towards the edges with mask-image: a radial-gradient from solid black in the centre to transparent towards the outside gradually hides the pattern. Two practical details:

An isometric grid that lights up on hover

The second background is inspired by shadcn.io's Boxes: a grid seen in isometric perspective, where each box under the cursor lights up in a colour and then slowly fades, leaving a trail.

As the page's own FAQ explains, the 3D look doesn't use WebGL: it's a combination of CSS transforms, skewX(-48deg) skewY(14deg) scale(0.675), that shears a flat grid until it looks isometric. The rest comes down to two choices that keep the effect light:

The trail comes from a detail in the transitions. When the pointer enters, the box gets a data-lit attribute that sets transition: none, so the colour appears instantly. When it leaves, the attribute is removed and the normal transition comes back (say 2 seconds), so the colour fades slowly. Technically background-color is a property that requires the element to be repainted, but here that only affects the few boxes the pointer touches, not the whole grid.

Demo

Isometric grid with boxes that light up

Hover over the grid

Code

index.html

<section class="boxes">
  <div class="boxes__grid" aria-hidden="true"></div>
  <div class="boxes__vignette"></div>
  <div class="boxes__content">…</div>
</section>

styles.css

.boxes {
  position: relative;
  overflow: hidden;
  min-height: 24rem;
  background: #0a0a0a;
}

.boxes__grid {
  position: absolute;
  left: 50%;
  top: 50%;
  display: grid;
  grid-template-columns: repeat(40, 64px);
  grid-auto-rows: 32px;
  transform: translate(-50%, -50%) skewX(-48deg) skewY(14deg) scale(0.675);
}

.boxes__cell {
  border-top: 1px solid rgb(250 250 249 / 0.12);
  border-right: 1px solid rgb(250 250 249 / 0.12);
  transition: background-color 2s ease-out;
}

.boxes__cell[data-lit] {
  transition: none;
}

.boxes__vignette {
  position: absolute;
  inset: 0;
  z-index: 1;
  background: #0a0a0a;
  -webkit-mask-image: radial-gradient(transparent 20%, #000 75%);
  mask-image: radial-gradient(transparent 20%, #000 75%);
  pointer-events: none;
}

.boxes__content {
  position: relative;
  z-index: 2;
}

script.js

const COLORS = [
  "rgb(16 185 129)",
  "rgb(110 231 183)",
  "rgb(13 98 70)",
  "rgb(180 241 216)",
  "rgb(63 208 156)",
  "rgb(250 250 249 / 0.4)",
];
const ROWS = 40;
const COLS = 40;
const grid = document.querySelector(".boxes__grid");

const cells = document.createDocumentFragment();
for (let i = 0; i < ROWS * COLS; i++) {
  const cell = document.createElement("div");
  cell.className = "boxes__cell";
  cells.append(cell);
}
grid.append(cells);

grid.addEventListener("pointerover", (e) => {
  const cell = e.target.closest(".boxes__cell");
  if (!cell) return;
  cell.dataset.lit = "";
  cell.style.backgroundColor = COLORS[Math.floor(Math.random() * COLORS.length)];
});

grid.addEventListener("pointerout", (e) => {
  const cell = e.target.closest(".boxes__cell");
  if (!cell) return;
  delete cell.dataset.lit;
  cell.style.backgroundColor = "";
});

The vignette is a dark layer with a radial mask that's transparent in the middle, placed on top of the grid with pointer-events: none: it covers the edges without intercepting the pointer. The grid is purely decorative (aria-hidden="true"), so it doesn't need a keyboard version.

Caustics: when you need a canvas

Caustics are the net of light you see on the bottom of a swimming pool on a sunny day, and here CSS isn't enough: you have to compute an image that changes every frame. The idea comes from shadcn.io's Caustics, whose FAQ describes its approach: two layers of noise fed through a cosine function, computed on a small buffer (192×108 pixels, about 20,000 pixels) and coloured with a 256-colour table. My implementation, written from scratch following that outline, works like this:

  1. Noise. For each pixel I compute two value noise fields, noise that varies smoothly across space. Each field adds up two layers at different scales and drifts over time in its own direction.
  2. Lines of light. I pass each field through cos() and keep only the points near the cosine's zeros, with pow(1 - |cos|, 8). Where the noise changes quickly those zones are razor thin, where it changes slowly they're wider. Added together, the two networks of lines interweave into the caustic pattern.
  3. Colour. The resulting value (0 to 255) is used directly as an index into the colour table, computed once.
  4. Upscaling. The canvas has an internal resolution of 192×108 pixels, but CSS stretches it over the whole box: the browser does the scaling, and the cost of the computation doesn't depend on the screen size.

Demo

Caustics on canvas

Caustics

Code

index.html

<section class="caustics">
  <canvas class="caustics__canvas" aria-hidden="true"></canvas>
  <button class="caustics-toggle" type="button" aria-pressed="false">Pause</button>
</section>

styles.css

.caustics {
  position: relative;
  overflow: hidden;
  min-height: 24rem;
}

.caustics__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.caustics-toggle {
  position: relative;
}

script.js

function hash(x, y) {
  let h = Math.imul(x, 374761393) + Math.imul(y, 668265263);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

function noise(x, y) {
  const xi = Math.floor(x);
  const yi = Math.floor(y);
  const xf = x - xi;
  const yf = y - yi;
  const u = xf * xf * (3 - 2 * xf);
  const v = yf * yf * (3 - 2 * yf);
  const a = hash(xi, yi);
  const b = hash(xi + 1, yi);
  const c = hash(xi, yi + 1);
  const d = hash(xi + 1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

function buildLut(stops) {
  const lut = new Uint8ClampedArray(256 * 3);
  for (let i = 0; i < 256; i++) {
    const t = i / 255;
    let k = 0;
    while (k < stops.length - 2 && t > stops[k + 1][0]) k++;
    const [p0, c0] = stops[k];
    const [p1, c1] = stops[k + 1];
    const f = Math.min(1, Math.max(0, (t - p0) / (p1 - p0)));
    for (let j = 0; j < 3; j++) lut[i * 3 + j] = c0[j] + (c1[j] - c0[j]) * f;
  }
  return lut;
}

function renderCaustics(ctx, img, lut, time, scale) {
  const { width: w, height: h, data } = img;
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const nx = (x / h) * 3 * scale;
      const ny = (y / h) * 3 * scale;
      const n1 = noise(nx * 1.5 + time * 0.3, ny * 1.5 - time * 0.2) + 0.5 * noise(nx * 3 - time * 0.4, ny * 3 + time * 0.35);
      const n2 = noise(nx * 1.5 - time * 0.25 + 17, ny * 1.5 + time * 0.3 + 9) + 0.5 * noise(nx * 3 + time * 0.3 + 5, ny * 3 - time * 0.2 + 3);
      const line = Math.min(
        1,
        Math.pow(1 - Math.abs(Math.cos(n1 * Math.PI * 4)), 8) + Math.pow(1 - Math.abs(Math.cos(n2 * Math.PI * 4)), 8)
      );
      const i = ((line * 255) | 0) * 3;
      const o = (y * w + x) * 4;
      data[o] = lut[i];
      data[o + 1] = lut[i + 1];
      data[o + 2] = lut[i + 2];
      data[o + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
}

const STOPS = [
  [0, [11, 31, 24]],
  [0.5, [16, 185, 129]],
  [0.8, [110, 231, 183]],
  [1, [250, 250, 249]],
];
const SCALE = 1;
const SPEED = 1;

const canvas = document.querySelector(".caustics__canvas");
canvas.width = 192;
canvas.height = 108;
const ctx = canvas.getContext("2d");
const img = ctx.createImageData(canvas.width, canvas.height);
const lut = buildLut(STOPS);
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
let time = 0;
let last = 0;
let frame = 0;
let visible = false;
let paused = false;

function loop(now) {
  if (last) time += ((now - last) / 1000) * SPEED;
  last = now;
  renderCaustics(ctx, img, lut, time, SCALE);
  frame = requestAnimationFrame(loop);
}

function update() {
  cancelAnimationFrame(frame);
  last = 0;
  if (visible && !paused && !reduced) frame = requestAnimationFrame(loop);
}

renderCaustics(ctx, img, lut, time, SCALE);

new IntersectionObserver(([entry]) => {
  visible = entry.isIntersecting;
  update();
}).observe(canvas);

document.querySelector(".caustics-toggle").addEventListener("click", (e) => {
  paused = !paused;
  e.currentTarget.setAttribute("aria-pressed", String(paused));
  update();
});

What does it cost? I measured it by timing exactly the code in the HTML/CSS/JS tab, 200 frames in a row in Chromium, on a laptop with an AMD Ryzen AI 9 HX 370 processor. The result is about 2–2.3 ms per frame, rising to about 10.4 ms with Chrome DevTools' 4× CPU slowdown enabled. At 60 frames per second each frame has about 16.7 ms in total, so on a slow device the caustics alone take up more than half of it. That's an approximation: I haven't measured it on a real phone.

Two safeguards limit the work. The requestAnimationFrame loop only runs while the canvas is visible, thanks to an IntersectionObserver. And with prefers-reduced-motion enabled, a single still frame is drawn.

Animated gradient: blobs, hard-light and a goo filter

The last background is based on shadcn.io's Gradient Animation, whose page describes the ingredients: several colour blobs animated at different speeds, blended with mix-blend-mode: hard-light, a blob that follows the cursor, trailing slightly behind with a smooth motion, and a "goo" filter. Here it's rebuilt in this site's colours.

Demo

Animated gradient with blobs

Move your mouse here

Code

index.html

<section class="gradient-bg">
  <svg width="0" height="0" aria-hidden="true" style="position: absolute">
    <filter id="goo">
      <feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
      <feColorMatrix in="blur" type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -8" result="goo" />
      <feBlend in="SourceGraphic" in2="goo" />
    </filter>
  </svg>
  <div class="gradient-bg__blobs" aria-hidden="true">
    <div class="blob blob--1"></div>
    <div class="blob blob--2"></div>
    <div class="blob blob--3"></div>
    <div class="blob blob--4"></div>
    <div class="blob blob--5"></div>
    <div class="blob blob--pointer"></div>
  </div>
  <div class="gradient-bg__content">…</div>
</section>
<button class="gradient-toggle" type="button" aria-pressed="false">Pause</button>

styles.css

.gradient-bg {
  position: relative;
  overflow: hidden;
  min-height: 24rem;
  background: linear-gradient(40deg, rgb(11 31 24), #0a0a0a);
}

.gradient-bg__blobs {
  position: absolute;
  inset: 0;
}

.blob {
  position: absolute;
  top: 10%;
  left: 10%;
  width: 80%;
  height: 80%;
  mix-blend-mode: hard-light;
  background: radial-gradient(closest-side, rgb(var(--c) / 0.8), rgb(var(--c) / 0)) no-repeat;
}

.blob--1 {
  --c: 16 185 129;
  transform-origin: center;
  animation: blob-vertical 30s ease infinite;
}

.blob--2 {
  --c: 110 231 183;
  transform-origin: 30% 50%;
  animation: blob-circle 20s reverse infinite;
}

.blob--3 {
  --c: 13 98 70;
  transform-origin: 70% 50%;
  animation: blob-circle 40s linear infinite;
}

.blob--4 {
  --c: 180 241 216;
  transform-origin: 40% 50%;
  animation: blob-horizontal 40s ease infinite;
  opacity: 0.7;
}

.blob--5 {
  --c: 63 208 156;
  transform-origin: 10% 90%;
  animation: blob-circle 20s ease infinite;
}

.blob--pointer {
  --c: 110 231 183;
  top: -50%;
  left: -50%;
  width: 100%;
  height: 100%;
  opacity: 0.7;
  transform: translate(var(--px, 0px), var(--py, 0px));
}

.gradient-bg.is-paused .blob {
  animation-play-state: paused;
}

@keyframes blob-circle {
  to { transform: rotate(360deg); }
}

@keyframes blob-vertical {
  0%, 100% { transform: translateY(-50%); }
  50% { transform: translateY(50%); }
}

@keyframes blob-horizontal {
  0%, 100% { transform: translateX(-50%) translateY(-10%); }
  50% { transform: translateX(50%) translateY(10%); }
}

@media (prefers-reduced-motion: reduce) {
  .blob { animation: none; }
}

.gradient-bg__content {
  position: relative;
}

script.js

const bg = document.querySelector(".gradient-bg");
const pointer = document.querySelector(".blob--pointer");
const rect = bg.getBoundingClientRect();
let tx = rect.width / 2;
let ty = rect.height / 2;
let cx = tx;
let cy = ty;
let frame = 0;

function apply() {
  pointer.style.setProperty("--px", `${cx}px`);
  pointer.style.setProperty("--py", `${cy}px`);
}

function step() {
  cx += (tx - cx) / 20;
  cy += (ty - cy) / 20;
  apply();
  frame = Math.abs(tx - cx) > 0.5 || Math.abs(ty - cy) > 0.5 ? requestAnimationFrame(step) : 0;
}

apply();
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) {
  bg.addEventListener("pointermove", (e) => {
    const r = bg.getBoundingClientRect();
    tx = e.clientX - r.left;
    ty = e.clientY - r.top;
    if (!frame) frame = requestAnimationFrame(step);
  });
}

document.querySelector(".gradient-toggle").addEventListener("click", (e) => {
  const paused = bg.classList.toggle("is-paused");
  e.currentTarget.setAttribute("aria-pressed", String(paused));
});

The goo filter and its cost

The "goo" filter is an SVG filter applied to the blobs' container. First it blurs them (feGaussianBlur). Then, with feColorMatrix, it multiplies the alpha channel and subtracts a constant: the semi-transparent areas of the blur become either solid or invisible, and nearby blobs seem to merge like drops.

The price is that a filter doesn't follow the "transform and opacity only" rule: its result depends on all the blobs together, so when they move the browser has to recompute it. I measured it on a page containing only the code from the HTML/CSS/JS tab, in Chromium with hardware acceleration (integrated AMD Radeon 890M GPU), for 5 seconds with the filter off and on, three times each. In every run the animation stayed at 60 frames per second. The work recorded on the main thread (the phase Chromium calls "Layerize") rose from about 1 ms to about 24 ms per 5 seconds. The filter itself is computed by the GPU, and this measurement doesn't see that part: on weaker GPUs, like those in budget phones, the result may differ, and I haven't measured it. That's why the filter is off by default in the demo: switch it on with the button and compare.

Motion and accessibility

All the animated backgrounds in this article follow two rules.

The isometric grid only changes colour, and only in response to the pointer: it doesn't move on its own.

Recap: what each background costs

In the Tailwind version, the @keyframes go in your project's global CSS (the globals.css file in the export tabs): Tailwind CSS v4 lets you reference them with animate-[...] classes, as its animation documentation explains.

In the next article in the series interaction takes centre stage: a light following the cursor, a card tilting in 3D and a glare sweeping across its surface.