26 agosto 2026
Animazioni “scroll-trigger” solo con CSS (e che non si riavviano): una tecnica moderna e pulita
Scroll-driven animations + style queries: fai partire l’animazione quando l’elemento entra in viewport e lasciala “incollata” allo stato finale, senza una riga di JavaScript.
Creare animazioni attivate dallo scroll di solito fa pensare subito a JavaScript. In realtà oggi CSS permette un approccio sorprendentemente solido: puoi rilevare l’ingresso in viewport, far partire un’animazione una sola volta e mantenerne lo stato finale anche quando l’elemento esce dallo schermo. In più, puoi aggiungere uno stagger intelligente in una griglia responsive e gestire tutto in progressive enhancement con @supports e prefers-reduced-motion.
Perché le animazioni “a scroll” sono difficili (e cosa vogliamo davvero)
Le animazioni attivate dallo scroll sono spesso una lama a doppio taglio: belle quando entrano in scena, irritanti quando continuano a riattivarsi mentre si risale e si riscende la pagina.
L’obiettivo più sensato, nella maggior parte dei layout “a card” (portfolio, listati, blog index), è:
- attivare l’animazione quando l’elemento entra in viewport
- farla partire una sola volta
- mantenere lo stato finale anche se poi l’elemento esce e rientra
- senza JS, e con fallback decente.
Oggi ci si arriva con una combinazione di due feature moderne:
- Scroll-driven animations (
animation-timeline: view()) - Container style queries (style queries basate su custom property)
Struttura HTML: due livelli, uno “sensore” e uno “contenuto”
Per questa tecnica conviene avere due layer:
- un wrapper (il “sensore”, quello che osserviamo rispetto alla viewport)
- un elemento interno (il “contenuto” che anima)
Esempio:
<div class="reveal">
<article class="card">
<!-- contenuto -->
</article>
</div>
Il motivo è pratico: il wrapper gestisce lo stato “in vista”, l’interno esegue l’animazione.
Step 1 — Un booleano CSS che diventa vero quando l’elemento entra in view
Sul wrapper iniziamo impostando una custom property come stato:
.reveal {
--in-view: false;
}
Poi definiamo un’animazione che “switcha” quel valore da false a true quando l’elemento entra in viewport, usando una view timeline.
Sì: qui l’animazione non muove pixel, ma cambia uno stato.
@keyframes in-view {
to { --in-view: true; }
}
.reveal {
animation: in-view 1s both;
animation-timeline: view();
}
A questo punto abbiamo un interruttore che si accende quando l’elemento è in vista. Ma da solo non fa nulla: dobbiamo reagire al cambio di stato.
Step 2 — Reagire allo stato con una container style query
Qui entra in gioco la parte elegante: usiamo una style query per applicare stili al contenuto quando --in-view è true.
Serve dichiarare un container sul wrapper (inline-size è sufficiente per questo tipo di query):
.reveal {
container-type: inline-size;
}
Poi, sul contenuto, possiamo fare:
@container style(--in-view: true) {
.card {
outline: 2px solid red; /* solo per visualizzare l'effetto */
}
}
Funziona, ma c’è un problema tipico: spesso l’attivazione risulta “tardiva” perché l’elemento deve entrare in una certa porzione della viewport.
Step 3 — Anticipare il trigger con animation-range
Con le scroll-driven animations puoi definire quando scatta il range di animazione.
Esempio concettuale:
.reveal {
animation-range: entry 20% cover 30%;
}
Questo ti permette di evitare l’effetto “spazio vuoto” (ad esempio quando l’opacità è 0 finché la card non è completamente dentro).
Step 4 — Evitare l’effetto “on/off” e farla partire una sola volta
Il difetto più fastidioso: se lo stile dipende direttamente dall’essere in vista, allora risalendo la pagina l’animazione/stato può tornare indietro.
La soluzione robusta è spostare l’animazione vera e propria sul contenuto e usare lo stato “in view” solo per cambiare il play state:
- di default l’animazione è paused
- quando entra in view diventa running
- quando esce torna paused, ma resta bloccata al frame raggiunto (tipicamente 100%)
Esempio:
.card {
opacity: 0;
transform: translateY(16px) scale(0.98);
animation: reveal 600ms ease forwards;
animation-play-state: paused;
}
@keyframes reveal {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@container style(--in-view: true) {
.card {
animation-play-state: running;
}
}
Risultato: l’animazione parte quando la card entra in viewport e non si riavvolge quando torni indietro.
Step 5 — Uno stagger “intelligente” in una griglia responsive
Lo stagger rende tutto più naturale, ma farlo bene su una griglia che cambia colonne è il punto difficile.
L’idea è assegnare a ogni card un indice di “ritardo” (--stagger) in base alla colonna in cui si trova, usando media query e selettori che tengono conto della geometria dell’auto-grid.
In pratica:
- definisci i due numeri chiave: min column size e gap
- sul parent imposti un container (
container-type: inline-size) - poi, in base a quante colonne ci sono a quella larghezza, assegni
--staggerai figli della 2ª colonna, 3ª colonna, ecc.
Una volta che ogni card ha --stagger, l’applicazione è semplice:
.card {
animation-delay: calc(var(--stagger, 0) * 120ms);
}
Note importanti:
- è una tecnica che può richiedere valori “magic number” perché in alcuni punti non puoi infilare custom property dove vorresti
- conviene usarla quando lo stagger “per colonna” è un requisito reale (portfolio/grid), altrimenti uno stagger lineare per
nth-child()può bastare.
Progressive enhancement: @supports e accessibilità con prefers-reduced-motion
Due accortezze rendono questo approccio production-friendly:
1) Non lasciare le card invisibili nei browser non compatibili
Se la tua UI di base mette opacity: 0 in attesa dell’animazione, devi proteggere tutto con @supports.
Il check più pragmatico è sul supporto di animation-timeline: view():
@supports (animation-timeline: view()) {
/* qui dentro: timeline, range, play-state, ecc. */
}
Fuori da @supports, tieni lo stile “statico” visibile.
2) Rispetta prefers-reduced-motion
Se l’utente chiede meno animazioni, niente reveal: contenuti subito presenti.
@media (prefers-reduced-motion: reduce) {
.card {
animation: none !important;
opacity: 1;
transform: none;
}
}
Sintesi operativa
- Usa un wrapper che anima una custom property (
--in-view) tramiteanimation-timeline: view(). - Reagisci a quello stato con una container style query.
- Per evitare “riattivazioni”, anima davvero la card e controlla solo
animation-play-state. - Aggiungi
animation-rangeper scegliere un punto di trigger più piacevole. - Se serve, calcola uno stagger per colonne e applicalo con
animation-delay. - Chiudi il cerchio con
@supportseprefers-reduced-motion.
Il risultato è un pattern moderno, pulito e sorprendentemente mantenibile: un’animazione “scroll-trigger” che sembra JavaScript, ma resta CSS puro — e soprattutto non penalizza chi non ha supporto o non vuole motion.