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:
- The mask goes on the pattern layer, not the container, otherwise the text fades too. In the code the pattern lives in a
::beforepseudo-element withz-index: -1, inside a container withisolation: isolate, so it stays below the content. - The
-webkit-prefix still matters for older Chromium browsers. According to MDN's compatibility data, unprefixedmask-imageis supported from Chrome and Edge 120, Firefox 53 and Safari 15.4.
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:
- One listener for the whole grid. Instead of attaching an event to each of the 1,600 boxes (40×40), I listen for
pointeroverandpointerouton the container and find the box withclosest(). This is event delegation: pointer events bubble up the DOM to the container. - The colour is written straight onto the element. In the React version the colour doesn't go through component state: I set
style.backgroundColoron the box, so hovering one box doesn't re-render the other 1,599. The shadcn.io original solves the same problem differently: according to its FAQ, it usesReact.memoon cells and rows.
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:
- 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.
- Lines of light. I pass each field through
cos()and keep only the points near the cosine's zeros, withpow(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. - Colour. The resulting value (0 to 255) is used directly as an index into the colour table, computed once.
- 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.
- The blobs are
divs with a softradial-gradient, from solid colour to transparent. They're animated withtransformonly (rotations around an offset origin and back-and-forth translations), one of the two properties web.dev's guide How to create high-performance CSS animations recommends animating. In the same guide, an animation usingtopandleftdrops 50% of frames, the same one usingtransformdrops 1%. mix-blend-mode: hard-lightdecides how the blobs combine where they overlap. According to shadcn.io's FAQ, it's the reason the colours are so vivid: overlapping gradients intensify each other instead of averaging out.- The cursor blob doesn't jump to the pointer: on every frame it covers 1/20 of the remaining distance, so it slows down as it gets closer. The loop stops once the distance drops below half a pixel.
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.
- They respect
prefers-reduced-motion. Theprefers-reduced-motionmedia query signals that the user has asked their operating system to minimise animation. In that case the blobs stop, the caustics show a single frame and the cursor blob no longer follows the pointer. - They have a pause button. WCAG 2.2 success criterion 2.2.2 Pause, Stop, Hide (level A) requires that for moving information that starts automatically, lasts more than five seconds and is presented alongside other content, there's a way to pause, stop or hide it. The caustics and the animated gradient fit that description.
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
- Grid and dots:
linear-gradientorradial-gradientrepeated withbackground-size, faded withmask-image. CSS only, painted once. - Isometric grid:
skewX+skewY+scaleon a CSS grid, and one delegated listener. Repainting only affects the boxes the pointer touches. - Caustics: a 192×108 canvas computed in JavaScript every frame. It's the only background with a continuous cost on the main thread, which is why it only runs while visible.
- Animated gradient: blobs with
radial-gradientanimated withtransformand blended withhard-light. The goo filter is optional and has a measurable cost.
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.