← Torna al blog

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:

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:

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ì:

  1. 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.
  2. Linee di luce. Passo ciascun campo in cos() e tengo solo i punti vicini allo zero del coseno, con pow(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.
  3. Colore. Il valore ottenuto (da 0 a 255) indica direttamente una voce della tabella colori, precalcolata una volta sola.
  4. 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.

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.

La griglia isometrica cambia solo colore, e solo in risposta al puntatore: non si muove da sola.

Riepilogo: cosa costa ogni sfondo

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.