← Torna al blog

Pubblicato il 29 settembre 2026

Animazioni allo scroll in CSS: scroll-driven animations, parallasse e marquee infinito

  • css
  • javascript
  • sviluppo-web

Chiudo la serie sugli effetti visivi senza librerie, dopo gli sfondi animati e gli effetti legati al mouse, con gli effetti guidati dallo scroll: elementi che compaiono mentre si scorre, livelli che si muovono a velocità diverse (parallasse), fasce di loghi o testo che scorrono all'infinito.

In Aceternity UI questi effetti corrispondono a componenti come Parallax Scroll e Infinite Moving Cards, che secondo le istruzioni di installazione richiedono motion, clsx e tailwind-merge. Oggi buona parte di questo lavoro la può fare il CSS da solo, grazie alle scroll-driven animations. C'è però un limite di supporto nei browser che va conosciuto prima di usarle.

Due modi di legare un'animazione allo scroll

Conviene distinguere subito due famiglie di effetti, perché si costruiscono in modo diverso:

Scroll-driven animations: scroll() e view()

Le scroll-driven animations sostituiscono il tempo con una timeline di scroll. Si scrive un normale @keyframes, lo si assegna con animation, e poi si indica con animation-timeline cosa deve farlo avanzare. Le due funzioni principali, come le descrive l'articolo di Chrome for Developers Animate elements on scroll with Scroll-driven animations, sono:

Con view() si usa spesso anche animation-range, che dice a quale tratto della timeline agganciare l'animazione. Per esempio, entry 0% cover 30% significa: comincia quando l'elemento inizia a entrare, finisci quando ha percorso il 30% del suo passaggio.

C'è un errore facile da fare, che lo stesso articolo di Chrome segnala esplicitamente: animation-timeline non fa parte dello shorthand animation e va dichiarata dopo, perché lo shorthand reimposta al valore iniziale tutte le proprietà che non include. Se la scrivi prima, viene annullata senza nessun messaggio di errore.

Per le prestazioni, l'articolo di Chrome parla di animazioni guidate dallo scroll "che girano fuori dal thread principale". Vale anche qui la regola già vista negli articoli precedenti: animare transform e opacity.

Il supporto oggi: serve un fallback

Secondo i dati di compatibilità di MDN (@mdn/browser-compat-data, versione 8.1.3 del 24 settembre 2026), animation-timeline è supportata da:

MDN la classifica infatti come "Limited availability": non è Baseline, perché non funziona in alcuni dei browser più usati. La conseguenza pratica è che va usata come miglioramento progressivo: dentro un blocco @supports (animation-timeline: view()), con un'alternativa per chi non la supporta.

Reveal allo scroll, con fallback

La demo qui sotto rileva il supporto nel tuo browser. Se animation-timeline: view() è supportata usa la versione nativa, altrimenti passa a IntersectionObserver. Se il tuo browser la supporta, puoi forzare il fallback con il pulsante e confrontare le due versioni: con quella nativa l'elemento segue lo scroll anche all'indietro, con il fallback compare una volta sola, con una durata fissa.

Demo

Reveal con view() e fallback IntersectionObserver

 

Scorri dentro questo riquadro ↓

Blocco 1
Blocco 2
Blocco 3
Blocco 4
Blocco 5
Blocco 6
Blocco 7
Blocco 8

Codice

index.html

<div class="reveal">…</div>
<div class="reveal">…</div>

styles.css

@keyframes reveal {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: none; }
}

@supports (animation-timeline: view()) {
  .reveal {
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 30%;
  }
}

.reveal--fallback {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}

.reveal--fallback.is-visible {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .reveal { animation: none; }
  .reveal--fallback { opacity: 1; transform: none; transition: none; }
}

script.js

if (!CSS.supports("animation-timeline: view()")) {
  const items = document.querySelectorAll(".reveal");
  const io = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        entry.target.classList.add("is-visible");
        io.unobserve(entry.target);
      }
    }
  }, { threshold: 0.2 });

  items.forEach((el) => {
    el.classList.add("reveal--fallback");
    io.observe(el);
  });
}

Un dettaglio del fallback che conta: lo stato nascosto (opacity: 0) non è nel CSS di base, ma in una classe aggiunta da JavaScript (reveal--fallback). Se lo script non viene caricato, gli elementi restano semplicemente visibili. Il contrario, nasconderli nel CSS e mostrarli con JavaScript, rischia di lasciare contenuti invisibili a causa di un errore di rete o di uno script bloccato.

Parallasse con scroll()

La parallasse è il caso ideale per scroll(): ogni livello trasla verticalmente in proporzione all'avanzamento dello scroll, moltiplicato per una sua "profondità". Nella demo la profondità è una variabile CSS (--depth):

Un solo @keyframes serve tutti i livelli, perché var(--depth) viene risolta su ciascun elemento.

Demo

Parallasse con animation-timeline: scroll()

 

Scorri dentro questo riquadro ↓

Lento
Normale
Veloce

Codice

index.html

<div class="layer" style="--depth: 1">…</div>
<div class="layer" style="--depth: 0">…</div>
<div class="layer" style="--depth: -1">…</div>

styles.css

@keyframes parallax {
  to { transform: translateY(calc(var(--depth) * 150px)); }
}

@supports (animation-timeline: scroll()) {
  .layer {
    animation: parallax linear both;
    animation-timeline: scroll();
  }
}

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

Qui il fallback scelto è nessun effetto: nei browser senza supporto i livelli scorrono normalmente. È una scelta voluta. Ricostruire la parallasse in JavaScript vorrebbe dire ascoltare l'evento scroll e aggiornare le posizioni a ogni frame sul thread principale, cioè proprio il lavoro che le scroll-driven animations evitano. Per un effetto decorativo, perderlo in un browser è un costo più basso che appesantire lo scroll per tutti.

Marquee infinito senza JavaScript

La fascia che scorre all'infinito (loghi di clienti, tecnologie, parole chiave) non è legata allo scroll, ma è l'altro grande effetto "in movimento" delle landing page, e ha una storia da conoscere. L'elemento HTML <marquee> esiste ancora, ma MDN lo segnala come deprecato, ne "sconsiglia fortemente" l'uso e suggerisce di ricorrere ad animazioni e trasformazioni CSS, con prefers-reduced-motion per fermarle.

La tecnica CSS più semplice è questa:

  1. il contenuto viene scritto due volte, in due gruppi identici affiancati in un'unica traccia;
  2. la traccia trasla da 0 a translateX(-50%): a metà strada il secondo gruppo si trova esattamente dove era il primo;
  3. l'animazione ricomincia da capo e il salto non si vede.

Perché il salto sia invisibile, ogni gruppo deve avere anche lo spazio finale (qui un padding-right uguale al gap). Il secondo gruppo ha aria-hidden="true": è un duplicato visivo, e uno screen reader non deve leggere l'elenco due volte.

Demo

Marquee infinito solo CSS, con pausa

  • HTML
  • CSS
  • JavaScript
  • SVG
  • Accessibilità
  • Performance
  • Core Web Vitals
  • Responsive

Codice

index.html

<div class="marquee">
  <div class="marquee__track">
    <ul class="marquee__group">
    <li>HTML</li>
    <li>CSS</li>
    <li>JavaScript</li>
    <li>SVG</li>
    <li>Accessibilità</li>
    <li>Performance</li>
    <li>Core Web Vitals</li>
    <li>Responsive</li>
    </ul>
    <ul class="marquee__group" aria-hidden="true">
    <li>HTML</li>
    <li>CSS</li>
    <li>JavaScript</li>
    <li>SVG</li>
    <li>Accessibilità</li>
    <li>Performance</li>
    <li>Core Web Vitals</li>
    <li>Responsive</li>
    </ul>
  </div>
</div>
<button class="marquee-toggle" type="button" aria-pressed="false">Pausa</button>

styles.css

.marquee {
  overflow: hidden;
}

.marquee__track {
  display: flex;
  width: max-content;
  animation: marquee 24s linear infinite;
}

.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track,
.marquee.is-paused .marquee__track {
  animation-play-state: paused;
}

.marquee__group {
  display: flex;
  flex-shrink: 0;
  gap: 3rem;
  padding-right: 3rem;
  margin: 0;
  list-style: none;
  white-space: nowrap;
}

@keyframes marquee {
  to { transform: translateX(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .marquee__track { animation: none; }
  .marquee { overflow-x: auto; }
}

script.js

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

Il limite di questa versione: ogni gruppo deve essere largo almeno quanto il contenitore, altrimenti durante lo scorrimento compare uno spazio vuoto. Con pochi elementi su schermi larghi si risolve ripetendo gli elementi dentro ciascun gruppo. In alternativa si può calcolare con JavaScript quante copie servono, misurando la larghezza reale del contenitore e dei gruppi (per esempio con ResizeObserver, che avvisa quando cambiano).

Pausa: un requisito, non un optional

Un marquee parte da solo e dura ben più di cinque secondi, quindi ricade esattamente nel criterio WCAG 2.2 2.2.2 Pause, Stop, Hide (livello A): serve un meccanismo per metterlo in pausa, fermarlo o nasconderlo. Nella demo i meccanismi sono tre, tutti basati su animation-play-state: paused:

Con prefers-reduced-motion: reduce l'animazione si ferma del tutto e la fascia diventa scorribile a mano (overflow-x: auto), così il contenuto resta raggiungibile.

Riepilogo della serie

In tre articoli, sfondi, effetti legati al mouse e scroll, tutti gli effetti ricostruiti seguono le stesse tre regole: animare solo transform e opacity, lasciare a JavaScript il minimo indispensabile (misurare, non disegnare), e prevedere sempre una versione ferma per chi ha chiesto meno movimento.