Introduzione agli Effetti Scroll-Based con CSS e JavaScript
Gli effetti scroll-based sono animazioni che si attivano mentre l’utente scorre la pagina, trasformando il semplice movimento verticale in un’esperienza interattiva e coinvolgente.
Attraverso l’uso combinato di CSS per le transizioni e JavaScript per il calcolo delle posizioni, è possibile creare interfacce dinamiche e fluide che guidano l’attenzione dell’utente e rendono la navigazione più piacevole.
Perché usare animazioni legate allo scroll
Implementare animazioni che reagiscono allo scroll non è solo una scelta estetica: ha anche valore funzionale e comunicativo.
Ecco i principali vantaggi:
- Aumento del coinvolgimento visivo: l’utente resta più tempo sulla pagina.
- Migliore storytelling digitale: accompagna la narrazione del contenuto.
- Maggiore percezione di modernità e professionalità del sito.
- Evidenziazione di sezioni o elementi chiave in modo naturale.
Quando ben dosate, queste microanimazioni migliorano l’esperienza d’uso senza compromettere la performance.
Preparazione dell’Ambiente di Sviluppo per Effetti Scroll-Based
Prima di creare animazioni reattive allo scroll, è importante preparare una struttura di base ordinata e leggibile. Una buona architettura del progetto permette di mantenere il controllo su transizioni, performance e accessibilità.
Struttura HTML di partenza
Inizia creando un file index.html con le sezioni principali del tuo sito.
Collega poi i fogli di stile e gli script JavaScript per gestire le animazioni scroll-based.
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Effetti Scroll-Based</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<section class="hero">Benvenuto</section>
<section class="content">Contenuto che scorre...</section>
<script src="script.js"></script>
</body>
</html>
Organizzazione del progetto
Mantieni una struttura chiara come la seguente:
/project-root
│
├── index.html
├── style.css
└── script.js
Buone pratiche iniziali
- Usa classi semanticamente corrette per facilitare il targeting degli elementi.
- Mantieni CSS e JS separati, così da migliorare la manutenzione del codice.
- Imposta un reset CSS o una base di normalizzazione per garantire compatibilità cross-browser.
Questa base ti permetterà di aggiungere facilmente animazioni senza perdere il controllo sull’interfaccia.
1. Parallax (solo CSS, semplice e leggero)
Il parallax crea profondità muovendo lo sfondo più lentamente del contenuto.
<section class="parallax">
<div class="overlay">
<h2>Parallax Hero</h2>
</div>
</section>
<section class="content">
<p>Contenuto della pagina…</p>
</section>
.parallax {
min-height: 100vh;
background-image: url('bg.jpg');
background-size: cover;
background-position: center;
background-attachment: fixed; /* fallback semplice */
display: grid;
place-items: center;
position: relative;
}
.parallax::after { /* leggera tinta per leggibilità del testo */
content: "";
position: absolute; inset: 0;
background: rgb(0 0 0 / 0.25);
}
.parallax .overlay { position: relative; z-index: 1; color: #fff; }
.content { padding: 6rem 1.5rem; max-width: 70ch; margin: 0 auto; }
Note SEO/UX: usa immagini ottimizzate (webp/avif) e background-position coerenti con il soggetto.
2. Hover Scroll (microinterazione reattiva allo scroll)
Combina lo scroll con una leggera inclinazione al mouse per dare feedback tattile senza impattare le performance.
<section class="cards">
<article class="card">Card 1</article>
<article class="card">Card 2</article>
<article class="card">Card 3</article>
</section>
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem; padding: 4rem 1.5rem; max-width: 1100px; margin: 0 auto;
}
.card {
background: #111; color: #fff; border-radius: 16px;
padding: 2rem; transform-style: preserve-3d;
transition: transform .2s ease, box-shadow .2s ease;
will-change: transform;
}
.card:hover { box-shadow: 0 12px 30px rgb(0 0 0 / .25); }
const cards = document.querySelectorAll('.card');
let ticking = false;
function onScrollTilt() {
const scrollFactor = window.scrollY * 0.003; // valore piccolo per evitare eccessi
cards.forEach((c, i) => c.style.transform = `rotate(${(i%2? -1:1)*scrollFactor}deg)`);
ticking = false;
}
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(onScrollTilt);
ticking = true;
}
});
// micro tilt al mouse solo quando l’utente interagisce
cards.forEach(card => {
card.addEventListener('mousemove', (e) => {
const r = card.getBoundingClientRect();
const rx = ((e.clientY - r.top) / r.height - 0.5) * 6;
const ry = ((e.clientX - r.left) / r.width - 0.5) * -6;
card.style.transform += ` rotateX(${rx}deg) rotateY(${ry}deg)`;
});
card.addEventListener('mouseleave', () => card.style.transform = '');
});
Performance tip: throttle con requestAnimationFrame e anima solo transform/opacity.
3. Fade-In su Scorrimento (trigger semplice)
Un classico fade-in on-scroll che rivela gli elementi quando entrano nel viewport.
<section class="grid">
<div class="fade-in">Blocco A</div>
<div class="fade-in">Blocco B</div>
<div class="fade-in">Blocco C</div>
</section>
.grid {
display: grid; gap: 1rem; padding: 4rem 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
max-width: 1100px; margin: 0 auto;
}
.fade-in {
opacity: 0; transform: translateY(20px);
transition: opacity .6s ease, transform .6s ease;
will-change: opacity, transform;
background: #f6f7fb; border-radius: 12px; padding: 2rem;
}
.fade-in.visible { opacity: 1; transform: translateY(0); }
const items = document.querySelectorAll('.fade-in');
function revealOnScroll() {
const vh = window.innerHeight;
items.forEach(el => {
const { top } = el.getBoundingClientRect();
if (top < vh * 0.85) el.classList.add('visible');
});
}
['scroll','resize','load'].forEach(evt =>
window.addEventListener(evt, () => requestAnimationFrame(revealOnScroll))
);
revealOnScroll();
Accessibilità: mantieni contrasto elevato, assicurati che il contenuto sia fruibile anche senza animazioni (prefers-reduced-motion).
4. Intersection Observer (trigger moderno e performante)
Usiamo l’API per aggiungere la classe .is-visible quando gli elementi entrano nel viewport, evitando listener di scroll continui.
<section class="io-grid">
<article class="io-item">Sezione 1</article>
<article class="io-item">Sezione 2</article>
<article class="io-item">Sezione 3</article>
</section>
.io-grid {
display: grid; gap: 1rem; padding: 4rem 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
max-width: 1100px; margin: 0 auto;
}
.io-item {
opacity: 0; transform: translateY(16px);
transition: opacity .6s ease, transform .6s ease;
background: #f8fafc; border-radius: 12px; padding: 2rem;
will-change: opacity, transform;
}
.io-item.is-visible { opacity: 1; transform: translateY(0); }
/* Rispetto preferenze utente */
@media (prefers-reduced-motion: reduce) {
.io-item, .slide-up, .scale-in { transition: none !important; transform: none !important; }
}
const ioItems = document.querySelectorAll('.io-item');
const io = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
obs.unobserve(entry.target); // evita ricalcoli successivi
}
});
}, {
root: null, // viewport
rootMargin: '0px 0px -10% 0px', // inizia poco prima
threshold: 0.15 // 15% visibile
});
ioItems.forEach(el => io.observe(el));
Tip SEO/UX: il caricamento progressivo mantiene l’utente ingaggiato senza blocchi; usa contenuti significativi sopra la piega.
5. Slide-Up (emersione dal basso)
Pattern ideale per liste o card in sequenza.
<section class="list">
<div class="slide-up">Card A</div>
<div class="slide-up">Card B</div>
<div class="slide-up">Card C</div>
</section>
.list { max-width: 1100px; margin: 0 auto; padding: 4rem 1.5rem; display: grid; gap: 1rem; }
.slide-up {
opacity: 0;
transform: translateY(24px);
transition: transform .5s cubic-bezier(.2,.8,.2,1), opacity .5s ease;
background: #111; color: #fff; border-radius: 14px; padding: 2rem;
}
.slide-up.is-visible { opacity: 1; transform: translateY(0); }
const slideItems = document.querySelectorAll('.slide-up');
const slideObserver = new IntersectionObserver((entries, obs) => {
entries.forEach((e, i) => {
if (e.isIntersecting) {
// piccolo staggering
e.target.style.transitionDelay = `${i * 60}ms`;
e.target.classList.add('is-visible');
obs.unobserve(e.target);
}
});
}, { threshold: 0.2 });
slideItems.forEach(el => slideObserver.observe(el));
Performance: anima solo transform e opacity; usa transition-delay per un effetto “a cascata” senza JS complesso.
6. Scale-In (zoom progressivo controllato)
L’elemento entra con un leggero zoom e fade, efficace per hero secondari o immagini.
<section class="gallery">
<figure class="scale-in"><img src="img1.webp" alt="Anteprima 1"></figure>
<figure class="scale-in"><img src="img2.webp" alt="Anteprima 2"></figure>
<figure class="scale-in"><img src="img3.webp" alt="Anteprima 3"></figure>
</section>
.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px,1fr)); gap: 1rem; padding: 4rem 1.5rem; max-width: 1100px; margin: 0 auto; }
.scale-in {
opacity: 0; transform: scale(.96);
transition: transform .5s ease, opacity .5s ease;
background: #fff; border-radius: 14px; overflow: hidden;
box-shadow: 0 8px 24px rgb(0 0 0 / .08);
}
.scale-in.is-visible { opacity: 1; transform: scale(1); }
.scale-in img { display: block; width: 100%; height: auto; }
const scaleItems = document.querySelectorAll('.scale-in');
const scaleObserver = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
obs.unobserve(entry.target);
}
});
}, { threshold: 0.12 });
scaleItems.forEach(el => scaleObserver.observe(el));
Accessibilità/Immagini: usa formati moderni (webp/avif), alt descrittivi e lazy loading (<img loading="lazy">) per migliorare LCP e SEO.
7. Blur Reveal (svela contenuti rimuovendo la sfocatura)
Gli elementi si “mettono a fuoco” quando entrano nel viewport.
<section class="blur-grid">
<article class="blur-reveal">Contenuto nitido al momento giusto</article>
<article class="blur-reveal">Testo di esempio</article>
<article class="blur-reveal">Un altro blocco</article>
</section>
.blur-grid {
display: grid; gap: 1rem; padding: 4rem 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
max-width: 1100px; margin: 0 auto;
}
.blur-reveal {
filter: blur(8px);
opacity: 0.6;
transform: translateY(18px);
transition: filter .6s ease, opacity .6s ease, transform .6s ease;
background: #ffffff; border-radius: 12px; padding: 2rem; box-shadow: 0 8px 24px rgb(0 0 0 / .06);
}
.blur-reveal.is-visible {
filter: blur(0);
opacity: 1;
transform: translateY(0);
}
const blurItems = document.querySelectorAll('.blur-reveal');
const blurObserver = new IntersectionObserver((entries, obs) => {
entries.forEach(e => {
if (e.isIntersecting) {
e.target.classList.add('is-visible');
obs.unobserve(e.target);
}
});
}, { threshold: 0.2 });
blurItems.forEach(el => blurObserver.observe(el));
UX: evita blur troppo elevati per non compromettere la leggibilità; mantieni contrasto AA/AAA.
8. Color Shift (variazione cromatica sincronizzata allo scroll)
Cambia colore di sfondi o header in base alla progressione di scroll. Usiamo una variabile CSS aggiornata via JS.
<header class="color-header">Sezione dinamica</header>
<section class="tall-section">Contenuto lungo…</section>
:root { --scroll-p: 0; } /* 0..1 */
.color-header {
position: sticky; top: 0; z-index: 10;
color: #fff; padding: 1rem 1.5rem; font-weight: 600;
background: linear-gradient(90deg,
hsl(calc(200 + 160*var(--scroll-p)) 90% 55%),
hsl(calc(260 + 160*var(--scroll-p)) 90% 45%)
);
transition: background .15s linear; /* micro smussatura */
}
.tall-section { height: 200vh; background: #0f172a; color: #e2e8f0; padding: 2rem 1.5rem; }
const docHeight = () => document.documentElement.scrollHeight - window.innerHeight;
function updateColorShift() {
const p = Math.max(0, Math.min(1, window.scrollY / docHeight()));
document.documentElement.style.setProperty('--scroll-p', p.toFixed(4));
requestAnimationFrame(updateColorShift);
}
requestAnimationFrame(updateColorShift);
Performance: aggiorna solo CSS variables; evita ricalcoli costosi. Funziona bene con position: sticky.
9. Scroll-Synced Text (testi che reagiscono alla progressione)
Sincronizza opacità e tracking del testo con lo scroll per creare una lettura “guidata”.
<section class="synced">
<h2 class="sync-title">Progetta interazioni significative</h2>
<p class="sync-copy">Le animazioni scroll-based con CSS e JavaScript migliorano la narrazione senza appesantire la pagina.</p>
</section>
<section class="spacer"></section>
:root { --sync: 0; }
.synced { min-height: 120vh; display: grid; place-items: center; text-align: center; padding: 4rem 1.5rem; }
.sync-title {
font-size: clamp(2rem, 6vw, 3.5rem);
letter-spacing: calc(0.5px + 2px * var(--sync));
opacity: calc(.4 + .6 * var(--sync));
transform: translateY(calc(12px * (1 - var(--sync))));
transition: opacity .2s linear;
}
.sync-copy {
max-width: 65ch; color: #94a3b8;
opacity: calc(.3 + .7 * var(--sync));
}
.spacer { height: 160vh; }
const synced = document.querySelector('.synced');
function syncText() {
const r = synced.getBoundingClientRect();
const vh = window.innerHeight;
const visible = 1 - Math.max(0, Math.min(1, (r.top + r.height/2) / (vh + r.height/2)));
document.documentElement.style.setProperty('--sync', visible.toFixed(4));
requestAnimationFrame(syncText);
}
requestAnimationFrame(syncText);
Accessibilità: evita movimenti eccessivi; rispetta prefers-reduced-motion disattivando le trasformazioni.
@media (prefers-reduced-motion: reduce) {
.sync-title, .sync-copy { transition: none !important; transform: none !important; }
}
10. Sticky Scroll (sezioni “pin” con progresso)
Crea sezioni “pinnate” che restano ferme mentre il contenuto interno avanza; ottimo per storytelling.
<section class="pin-wrapper">
<div class="pin" aria-label="Sezione pinnata con indicatore di progresso">
<div class="pin-step is-active">Intro</div>
<div class="pin-step">Dettaglio</div>
<div class="pin-step">Conclusione</div>
<div class="pin-progress"><span></span></div>
</div>
</section>
<section class="pin-spacer"></section>
.pin-wrapper { position: relative; height: 220vh; }
.pin {
position: sticky; top: 0; height: 100vh;
display: grid; place-items: center; gap: 1rem;
background: #0b1020; color: #e5e7eb;
}
.pin-step { opacity: .25; font-size: clamp(1.25rem, 3vw, 2rem); transition: opacity .3s ease; }
.pin-step.is-active { opacity: 1; }
.pin-progress {
position: absolute; left: 24px; top: 50%; transform: translateY(-50%);
width: 4px; height: 60vh; background: #1f2937; border-radius: 999px; overflow: hidden;
}
.pin-progress span {
display: block; width: 100%; height: 0; background: #22d3ee; transition: height .2s linear;
}
.pin-spacer { height: 120vh; }
const pin = document.querySelector('.pin');
const steps = [...document.querySelectorAll('.pin-step')];
const bar = document.querySelector('.pin-progress span');
function updatePin() {
const r = pin.getBoundingClientRect();
const vh = window.innerHeight; // pin occupa l'altezza del viewport
const start = 0; const end = vh; // range di progress calcolato dentro lo sticky
const p = Math.max(0, Math.min(1, (vh - r.bottom) / (end - start)));
bar.style.height = `${p * 100}%`;
// attiva la step corrente in base al progresso
const idx = Math.min(steps.length - 1, Math.floor(p * steps.length));
steps.forEach((s, i) => s.classList.toggle('is-active', i === idx));
requestAnimationFrame(updatePin);
}
requestAnimationFrame(updatePin);
SEO/UX: lo Sticky Scroll incrementa il dwell time; mantieni messaggi sintetici e chiari in ogni “step” per evitare overload cognitivo.
Ottimizzazione delle Performance e Best Practice
Obiettivo: animazioni fluide, accessibili e sostenibili anche su mobile. Ecco le linee guida essenziali con snippet pronti all’uso.
Anima solo proprietà “compositabili”
Preferisci transform e opacity (GPU-friendly). Evita layout/reflow (es. top/left/width).
.item { will-change: transform, opacity; } /* usa con moderazione, solo vicino all’uso */
Scroll “cheap”: rAF, passive listeners, niente lavoro pesante nel callback
Usa requestAnimationFrame per sincronizzare gli update al frame.
let ticking = false;
function onScrollFrame() {
// aggiornamenti DOM/CSS leggeri qui
ticking = false;
}
window.addEventListener('scroll', () => {
if (!ticking) { requestAnimationFrame(onScrollFrame); ticking = true; }
}, { passive: true });
IntersectionObserver: pochi observer, molte target
Evita listener di scroll continui; disiscrivi gli elementi già rivelati.
const io = new IntersectionObserver((entries, obs) => {
entries.forEach(e => {
if (e.isIntersecting) { e.target.classList.add('is-visible'); obs.unobserve(e.target); }
});
}, { threshold: 0.15, rootMargin: '0px 0px -10% 0px' });
document.querySelectorAll('.reveal').forEach(el => io.observe(el));
Riduci il lavoro di layout (layout thrashing)
- Batching letture/scritture: leggi prima, scrivi dopo.
- Cache di misure ricorrenti (es.
vh, offset). - Usa ResizeObserver per ricalcoli mirati.
let vh = window.innerHeight;
new ResizeObserver(() => { vh = window.innerHeight; }).observe(document.documentElement);
Contenimento e rendering condizionale
content-visibility e contain limitano l’impatto di sezioni fuori viewport.
.section { content-visibility: auto; contain-intrinsic-size: 800px 1000px; }
.card-list { contain: layout paint style; }
Lazy loading media e prefetch mirato
Migliora LCP e riduci banda.
<img src="hero.webp" alt="Anteprima" width="1200" height="800" loading="lazy" decoding="async">
<link rel="preload" as="image" href="hero.webp" imagesrcset="hero@2x.webp 2x">
Evita CLS (Cumulative Layout Shift)
- Riserva sempre spazio a immagini/video (
width/heightoaspect-ratio). - Attenzione a font swap e sticky bar che compaiono in ritardo.
img { aspect-ratio: 3 / 2; width: 100%; height: auto; display: block; }
Rispetta prefers-reduced-motion
Consenti un’esperienza “no motion” senza perdere contenuto.
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition: none !important; }
}
Progressivo: fallback e feature detection
Attiva effetti avanzati solo dove supportati.
@supports (animation-timeline: view()) {
/* Scroll-Driven Animations moderne (ViewTimeline) qui */
}
Pulizia e budget prestazionale
- Minifica/Bundle CSS e JS, rimuovi codice morto.
- Evita librerie pesanti per effetti semplici.
- Profiling con Performance panel (FPS, JS heap, long tasks).
- Memoria: rimuovi listener su teardown, evita chiusure non necessarie.
function mount() {
const onScroll = () => {/*...*/};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}
const unmount = mount();
// chiamare unmount() quando la vista cambiaConclusioni e Invito alla Sperimentazione
Gli effetti scroll-based con CSS e JavaScript sono uno strumento potente per guidare l’attenzione, migliorare la narrazione e aumentare l’engagement senza sacrificare le prestazioni. In questo articolo abbiamo visto 10 pattern concreti — dal Parallax allo Sticky Scroll — insieme a tecniche moderne come Intersection Observer e variabili CSS per sincronizzare gli stati visivi allo scroll.
Cosa portarti a casa
- Priorità alla performance: anima solo
transform/opacity, usa rAF e IntersectionObserver. - Accessibilità al centro: rispetta
prefers-reduced-motion, mantieni contenuti comprensibili anche senza animazioni. - Progressive enhancement: verifica il supporto delle API e fornisci fallback.
- Misura e ottimizza: profila FPS, LCP, CLS e riduci il costo delle animazioni.
Checklist rapida per i tuoi progetti
- Struttura pulita (
index.html,style.css,script.js). - Classi semantiche e componenti riutilizzabili.
- Trigger delle animazioni via IntersectionObserver.
- Lazy loading e
content-visibilityper sezioni fuori viewport. - Test su mobile/retina e con riduzione movimento attiva.
Prossimi passi consigliati
- Scegli 2–3 effetti chiave per ogni pagina (evita di usarli tutti insieme).
- Crea un design token per tempi, easing e intensità (coerenza visiva).
- Integra una suite di test visivi (Percy, Playwright) per prevenire regressioni.
- Documenta i pattern in una piccola design/animation guideline condivisa con il team.
Obiettivo finale: usare le animazioni per chiarire, non per distrarre. Quando la microinterazione sostiene il contenuto, l’esperienza risulta più memorabile e il SEO beneficia di un maggiore dwell time e di segnali di coinvolgimento migliori.

