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:
- Animazioni innescate dallo scroll: quando un elemento entra nella finestra parte un'animazione con una durata sua (per esempio 0,6 secondi), indipendente da quanto velocemente si scorre. È il caso classico dei "reveal", e si fa con
IntersectionObserver. - Animazioni collegate allo scroll: l'avanzamento dell'animazione è la posizione di scroll. Se ti fermi a metà, l'animazione resta a metà; se torni indietro, torna indietro anche lei. È ciò che fanno le scroll-driven animations, ed è il modello giusto per la parallasse.
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:
scroll(): una timeline anonima legata alla posizione di scroll di un contenitore, dove l'intera escursione di scroll diventa un avanzamento da 0% a 100%;view(): una timeline anonima che segue la posizione di un elemento all'interno del suo contenitore di scroll, da quando l'elemento entra nell'area visibile a quando ne esce.
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:
- Chrome ed Edge dalla versione 115;
- Safari (macOS e iOS) dalla versione 26;
- Firefox: solo nelle versioni di anteprima, non ancora nella versione stabile.
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 ↓
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):
1trasla verso il basso mentre scorri, quindi il livello sembra muoversi più lentamente, come uno sfondo lontano;0non trasla e scorre normalmente;-1trasla verso l'alto e sembra più veloce, come un primo piano.
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 ↓
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:
- il contenuto viene scritto due volte, in due gruppi identici affiancati in un'unica traccia;
- la traccia trasla da
0atranslateX(-50%): a metà strada il secondo gruppo si trova esattamente dove era il primo; - 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:
- il passaggio del mouse (
:hover), comodo ma non sufficiente da solo, perché non esiste su touch; - il focus (
:focus-within), se la fascia contiene link o pulsanti; - un pulsante esplicito con
aria-pressed, l'unico che funziona per tutti.
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
- Reveal:
animation-timeline: view()conanimation-range, dentro@supports, eIntersectionObservercome fallback. Lo stato nascosto lo aggiunge JavaScript, non il CSS di base. - Parallasse:
animation-timeline: scroll()con una variabile--depthper livello. Nessun fallback in JavaScript: senza supporto l'effetto semplicemente manca. - Marquee: due gruppi identici e
translateX(-50%), il duplicato conaria-hidden, e una pausa accessibile a tutti. - Ricorda:
animation-timelineva sempre dichiarata dopo lo shorthandanimation.
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.