Pubblicato il 29 settembre 2026
Sfondi animati per siti web: gradiente animato, griglia isometrica e caustiche senza librerie
- css
- javascript
- sviluppo-web
Librerie come Aceternity UI e le raccolte di sfondi di shadcn.io hanno reso popolari effetti che oggi si vedono su moltissime landing page: griglie che sfumano verso i bordi, griglie isometriche che si accendono sotto il cursore, luci d'acqua in movimento, gradienti colorati che si mescolano lentamente. Sono componenti React, di solito legati a Tailwind CSS e a librerie di animazione: le pagine di installazione dei componenti di Aceternity, per esempio, indicano come dipendenze motion, clsx e tailwind-merge.
In questo articolo ricostruisco quattro di questi sfondi senza librerie. Alcuni sono solo CSS, altri hanno bisogno di un po' di JavaScript, e per ciascuno spiego perché e quanto costa. È il primo di una serie di tre: nei prossimi articoli passo agli effetti legati al mouse e alle animazioni allo scroll.
Ogni demo ha dei controlli, e il codice sotto l'anteprima si aggiorna con gli stessi valori. Puoi copiarlo in tre forme: HTML/CSS/JS standard, componente React (.tsx con CSS module) oppure componente React con Tailwind CSS v4. Ogni file ha il suo pulsante "Copia".
Griglia e puntini: solo gradienti ripetuti
Un pattern a griglia non richiede immagini. Si ottiene con due linear-gradient sovrapposti: uno disegna una linea verticale sottile, l'altro una orizzontale. Ciascun gradiente passa dal colore della linea a transparent nello stesso punto (per esempio 1px), creando un bordo netto invece di una sfumatura.
La ripetizione la fa background-size: se l'immagine di sfondo è grande 32×32 pixel, il browser la ripete come una piastrella su tutta la superficie. Per i puntini il principio è identico, ma con un solo radial-gradient: un cerchio pieno di 1 o 2 pixel al centro di ogni cella.
Demo
Griglia e puntini con i gradienti
Il tuo titolo qui
Codice
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;
}L'effetto "da landing page" arriva con la dissolvenza verso i bordi, fatta con mask-image: un radial-gradient da nero pieno al centro a transparent verso l'esterno fa sparire gradualmente il pattern. Due dettagli pratici:
- La maschera va sul livello del pattern, non sul contenitore, altrimenti sfuma anche il testo. Nel codice il pattern vive in uno pseudo-elemento
::beforeconz-index: -1, dentro un contenitore conisolation: isolate, così resta sotto al contenuto. - Il prefisso
-webkit-serve ancora per i browser Chromium meno recenti. Secondo i dati di compatibilità di MDN,mask-imagesenza prefisso è supportato da Chrome ed Edge dalla versione 120, da Firefox dalla 53 e da Safari dalla 15.4.
Griglia isometrica che si colora al passaggio del mouse
Il secondo sfondo è ispirato a Boxes di shadcn.io: una griglia vista in prospettiva isometrica, dove ogni casella sotto il cursore si accende di un colore e poi si spegne lentamente, lasciando una scia.
Come spiegano le FAQ della pagina stessa, l'effetto 3D non usa WebGL: è una combinazione di trasformazioni CSS, skewX(-48deg) skewY(14deg) scale(0.675), che inclina una griglia piatta fino a farla sembrare isometrica. Il resto sono due scelte che tengono l'effetto leggero:
- Un solo listener per tutta la griglia. Invece di collegare un evento a ognuna delle 1600 caselle (40×40), ascolto
pointeroverepointeroutsul contenitore e ricavo la casella conclosest(). È la delegazione degli eventi: gli eventi del puntatore risalgono il DOM fino al contenitore. - Il colore va scritto direttamente sull'elemento. Nella versione React il colore non passa dallo stato del componente: lo scrivo con
style.backgroundColorsulla casella. Così passare sopra una casella non fa ri-renderizzare le altre 1599. L'originale di shadcn.io risolve lo stesso problema in un altro modo: secondo le sue FAQ, usaReact.memosulle celle e sulle righe.
La scia nasce da un dettaglio sulle transizioni. Quando il puntatore entra, la casella riceve l'attributo data-lit, che imposta transition: none: il colore compare subito. Quando esce, l'attributo sparisce e torna la transizione normale (per esempio 2 secondi), quindi il colore sfuma lentamente. Tecnicamente background-color è una proprietà che richiede di ridisegnare l'elemento, ma qui riguarda solo le poche caselle toccate dal puntatore, non l'intera griglia.
Demo
Griglia isometrica con caselle che si accendono
Passa il mouse sulla griglia
Codice
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 = "";
});La vignettatura è un livello scuro con una maschera radiale trasparente al centro, messo sopra la griglia con pointer-events: none: copre i bordi senza intercettare il puntatore. La griglia è puramente decorativa (aria-hidden="true"), quindi non serve una versione da tastiera.
Caustiche: quando serve un canvas
Le caustiche sono la rete di luce che si vede sul fondo di una piscina al sole, e qui il CSS non basta: serve calcolare un'immagine che cambia a ogni frame. L'idea viene da Caustics di shadcn.io, che nelle FAQ descrive il proprio approccio: due livelli di rumore passati in una funzione coseno, calcolati su un buffer piccolo (192×108 pixel, circa 20.000 pixel) e colorati con una tabella di 256 colori. La mia implementazione, scritta da zero con quello schema, funziona così:
- Rumore. Per ogni pixel calcolo due campi di value noise, un rumore che varia in modo continuo nello spazio. Ogni campo somma due livelli a scale diverse e si sposta nel tempo in una direzione sua.
- Linee di luce. Passo ciascun campo in
cos()e tengo solo i punti vicini allo zero del coseno, conpow(1 - |cos|, 8). Dove il rumore cambia rapidamente quelle zone sono sottilissime, dove cambia lentamente sono più larghe. Le due reti di linee, sommate, si intrecciano e formano la trama delle caustiche. - Colore. Il valore ottenuto (da 0 a 255) indica direttamente una voce della tabella colori, precalcolata una volta sola.
- Ingrandimento. Il canvas ha una risoluzione interna di 192×108 pixel, ma via CSS occupa tutto il riquadro: lo ingrandisce il browser, e il costo del calcolo non dipende dalle dimensioni dello schermo.
Demo
Caustiche su canvas
Caustiche
Codice
index.html
<section class="caustics">
<canvas class="caustics__canvas" aria-hidden="true"></canvas>
<button class="caustics-toggle" type="button" aria-pressed="false">Pausa</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();
});Quanto costa? L'ho misurato cronometrando esattamente il codice della scheda HTML/CSS/JS, 200 fotogrammi di fila in Chromium, su un portatile con processore AMD Ryzen AI 9 HX 370. Il risultato è circa 2–2,3 ms per fotogramma, che diventano circa 10,4 ms attivando il rallentamento della CPU di 4 volte offerto da Chrome DevTools. A 60 fotogrammi al secondo ogni frame ha circa 16,7 ms in tutto, quindi su un dispositivo lento le caustiche da sole ne occupano più della metà. È un'approssimazione: su un telefono reale non l'ho misurato.
Due accorgimenti limitano il lavoro. Il ciclo con requestAnimationFrame gira solo quando il canvas è visibile, grazie a un IntersectionObserver. E con prefers-reduced-motion attivo viene disegnato un solo fotogramma fermo.
Gradiente animato: blob, hard-light e filtro goo
L'ultimo sfondo riprende Gradient Animation di shadcn.io, di cui la pagina descrive gli ingredienti: più blob di colore animati a velocità diverse, fusi con mix-blend-mode: hard-light, un blob che segue il cursore restando un po' indietro, con un movimento morbido, e un filtro "goo". Qui è ricostruito con i colori di questo sito.
- I blob sono
divcon unradial-gradientmorbido, dal colore pieno al trasparente. Si animano solo contransform(rotazioni attorno a un'origine spostata e traslazioni avanti e indietro), cioè una delle due proprietà che la guida di web.dev How to create high-performance CSS animations consiglia di animare. Nella stessa guida, un'animazione contopeleftperde il 50% dei frame, la stessa contransforml'1%. mix-blend-mode: hard-lightdecide come si fondono i blob dove si sovrappongono. Secondo le FAQ di shadcn.io è la ragione dei colori così vivaci: sovrapponendosi, i gradienti si intensificano a vicenda invece di fare una media.- Il blob che segue il cursore non salta alla posizione del puntatore: a ogni frame percorre 1/20 della distanza che manca, quindi rallenta man mano che si avvicina. Il ciclo si ferma quando la distanza scende sotto mezzo pixel.
Demo
Gradiente animato con blob
Muovi il mouse qui
Codice
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">Pausa</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));
});Il filtro goo e il suo costo
Il filtro "goo" è un filtro SVG applicato al contenitore dei blob. Prima li sfoca (feGaussianBlur). Poi, con feColorMatrix, moltiplica l'opacità e le sottrae una costante: le zone semitrasparenti della sfocatura diventano o piene o invisibili, e i blob vicini sembrano fondersi come gocce.
Il prezzo è che un filtro non segue la regola "solo transform e opacity": il risultato dipende da tutti i blob insieme, quindi quando si muovono il browser deve ricalcolarlo. L'ho misurato su una pagina che contiene solo il codice della scheda HTML/CSS/JS, in Chromium con accelerazione hardware (GPU integrata AMD Radeon 890M), per 5 secondi con il filtro spento e acceso, tre volte ciascuno. In tutte le prove l'animazione è rimasta a 60 fotogrammi al secondo. Il lavoro registrato sul thread principale (la fase che Chromium chiama "Layerize") è salito da circa 1 ms a circa 24 ms ogni 5 secondi. Il filtro vero e proprio lo calcola la GPU, e quella parte questa misura non la vede: su GPU più deboli, come quelle degli smartphone economici, il risultato può essere diverso, e non l'ho misurato. Per questo nella demo il filtro è spento di default: attivalo con il pulsante e confronta.
Movimento e accessibilità
Tutti gli sfondi animati di questo articolo seguono due regole.
- Rispettano
prefers-reduced-motion. La media queryprefers-reduced-motionsegnala che l'utente ha chiesto al sistema operativo di ridurre le animazioni. In quel caso i blob si fermano, le caustiche mostrano un solo fotogramma e il blob-cursore non segue più il puntatore. - Hanno un pulsante di pausa. Il criterio WCAG 2.2 2.2.2 Pause, Stop, Hide (livello A) chiede che per le informazioni in movimento che partono da sole, durano più di cinque secondi e sono presentate insieme ad altri contenuti esista un modo per metterle in pausa, fermarle o nasconderle. Caustiche e gradiente animato rientrano nella descrizione.
La griglia isometrica cambia solo colore, e solo in risposta al puntatore: non si muove da sola.
Riepilogo: cosa costa ogni sfondo
- Griglia e puntini:
linear-gradientoradial-gradientripetuti conbackground-size, con dissolvenza viamask-image. Solo CSS, disegnati una volta. - Griglia isometrica:
skewX+skewY+scalesu una griglia CSS, e un solo listener delegato. Il ridisegno riguarda solo le caselle toccate dal puntatore. - Caustiche: canvas 192×108 calcolato in JavaScript a ogni frame. È l'unico sfondo con un costo continuo sul thread principale, e per questo gira solo quando è visibile.
- Gradiente animato: blob con
radial-gradientanimati contransforme fusi conhard-light. Il filtro goo è opzionale e ha un costo misurabile.
Nella versione Tailwind, i @keyframes vanno aggiunti al CSS globale del progetto (il file globals.css nelle schede di esportazione): Tailwind CSS v4 permette di richiamarli con le classi animate-[...], come spiega la sua documentazione sulle animazioni.
Nel prossimo articolo della serie l'interazione diventa protagonista: una luce che segue il cursore, una card che si inclina in 3D e un riflesso che scorre sulla superficie.