
Marquee that speeds up with scroll
Scroll, Text, Hover
Lifetime
Passing over the letters, each one does a cartoon jump: it squashes, rises, takes a colour and falls back with a bounce. Its neighbours make a wave.
A folder that runs on its own: index.html, style.css, script.js and the libraries from a CDN.
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lettere che saltano</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/mona-sans@5.3.0/standard.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="palco">
<p class="sopra">Passa sulle lettere col mouse, o strisciaci sopra col dito</p>
<h1 class="titolo" data-salta>
<span class="riga">Lettere</span>
<span class="riga">che saltano!</span>
</h1>
<p class="sotto">Ogni lettera si schiaccia, salta, prende un colore e ricade con un rimbalzo. Le vicine fanno l’onda.</p>
</main>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script>
<script src="script.js"></script>
</body>
</html>/* Lettere che saltano */
:root {
--fondo: #07201f;
--lettere: #f6f7f3;
--tenue: #86a8a2;
}
* { box-sizing: border-box; }
html,
body {
margin: 0;
height: 100%;
}
body {
font-family: 'Mona Sans Variable', system-ui, sans-serif;
background: var(--fondo);
color: var(--lettere);
overflow: hidden;
}
.palco {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
padding: clamp(20px, 6vw, 88px);
}
.sopra {
margin: 0 0 clamp(14px, 3vh, 28px);
font-size: 14px;
font-weight: 620;
color: var(--tenue);
}
.titolo {
margin: 0;
font-size: clamp(54px, 12.5vw, 210px);
font-weight: 900;
font-stretch: 112%;
line-height: 0.92;
letter-spacing: -0.04em;
/* il dito può strisciare di lato sul titolo: la pagina scorre solo in verticale */
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
}
.riga {
display: block;
}
/* le lettere saltano fuori dalla riga: niente maschere */
.lettera {
display: inline-block;
transform-origin: 50% 100%;
will-change: transform;
}
.sotto {
max-width: 40ch;
margin: clamp(18px, 4vh, 36px) 0 0;
font-size: clamp(15px, 1.2vw, 18px);
line-height: 1.45;
color: #c9dcd8;
}/* Lettere che saltano
Ogni lettera del titolo, quando il puntatore (o il dito) ci passa sopra, fa il salto dei cartoni
animati: si schiaccia e si allarga, sale, si inclina un po' a caso, prende un colore; poi ricade
con una curva elastica e torna al suo colore. Le due lettere vicine per lato fanno l'onda, più
piccola e un attimo dopo. Col dito si striscia di lato sul titolo: la lettera sotto il dito salta. */
gsap.registerPlugin(SplitText);
/* ---- valori da cambiare ---- */
const COLORI = ['#ff8a3d', '#ffb27a', '#86cfc4', '#3aa39b', '#ffd27a']; /* il salto ne pesca uno */
const SALTO = -20; /* di quanto sale la lettera, in percentuale della sua altezza */
const INCLINA = 12; /* rotazione massima, in gradi */
const RIMBALZO = 'elastic.out(1, 0.32)';
const ONDA = [-8, -3]; /* quanto salgono la prima e la seconda vicina */
const RICHIAMO = 5; /* secondi fra un'onda automatica e l'altra, quando nessuno tocca il titolo */
const RIDOTTO = matchMedia('(prefers-reduced-motion: reduce)').matches;
const titolo = document.querySelector('[data-salta]');
const COLORE = getComputedStyle(document.documentElement).getPropertyValue('--lettere').trim() || '#f6f7f3';
function salta(l) {
gsap.timeline({ defaults: { overwrite: 'auto' } })
.to(l, {
yPercent: SALTO, scaleX: 1.12, scaleY: 0.86,
rotation: gsap.utils.random(-INCLINA, INCLINA), color: gsap.utils.random(COLORI),
duration: 0.16, ease: 'power2.out',
})
.to(l, { yPercent: 0, scaleX: 1, scaleY: 1, rotation: 0, duration: 1, ease: RIMBALZO })
.to(l, { color: COLORE, duration: 0.7, ease: 'power1.out' }, '-=0.85');
}
function onda(l, forza, ritardo) {
gsap.timeline({ delay: ritardo, defaults: { overwrite: 'auto' } })
.to(l, { yPercent: forza, duration: 0.16, ease: 'power2.out' })
.to(l, { yPercent: 0, duration: 0.9, ease: 'elastic.out(1, 0.4)' });
}
document.fonts.ready.then(() => {
if (RIDOTTO) return;
/* ogni parola in un blocco a sé, così l'onda resta dentro la parola */
const split = SplitText.create(titolo, { type: 'words,chars', charsClass: 'lettera' });
const parole = split.words.map((w) => [...w.querySelectorAll('.lettera')]);
let ultima = null;
let tocco = -Infinity;
function colpisci(l, dalPuntatore = true) {
if (!l || l === ultima) return;
ultima = l;
if (dalPuntatore) tocco = performance.now();
salta(l);
const parola = parole.find((p) => p.includes(l));
const i = parola.indexOf(l);
[[i - 1, 0], [i + 1, 0], [i - 2, 1], [i + 2, 1]].forEach(([j, k]) => {
if (parola[j]) onda(parola[j], ONDA[k], (k + 1) * 0.05);
});
}
/* col mouse: basta entrare nella lettera */
parole.flat().forEach((l) => {
l.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') colpisci(l); });
l.addEventListener('pointerleave', () => { if (ultima === l) ultima = null; });
});
/* col dito: il tocco resta "catturato" dal primo elemento, quindi si cerca la lettera sotto il dito */
titolo.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse') return;
ultima = null;
colpisci(e.target.closest('.lettera'));
});
titolo.addEventListener('pointermove', (e) => {
if (e.pointerType === 'mouse') return;
const sotto = document.elementFromPoint(e.clientX, e.clientY);
colpisci(sotto && sotto.closest ? sotto.closest('.lettera') : null);
});
/* ingresso: le lettere cadono dall'alto una dopo l'altra, già con il rimbalzo */
gsap.from(split.chars, { yPercent: -140, rotation: () => gsap.utils.random(-20, 20), autoAlpha: 0, duration: 1.1, ease: 'bounce.out', stagger: 0.035 });
/* richiamo: se nessuno tocca il titolo, ogni tanto un'onda lo attraversa da sola e mostra il gioco */
const tutte = split.chars;
function giro() {
if (performance.now() - tocco > RICHIAMO * 600) {
tutte.forEach((l, i) => gsap.delayedCall(i * 0.06, () => { ultima = null; colpisci(l, false); }));
}
gsap.delayedCall(RICHIAMO, giro);
}
gsap.delayedCall(2.2, giro);
});SplitText splits the title into words and letters; every letter is an inline-block with its origin at the bottom centre, so squashing it keeps it sitting on the line.
First a short hit (0.16 s): scaleX 1.12 and scaleY 0.86 while it rises 20% of its height. It is the cartoon principle: weight shows in the moment the letter deforms.
Then a full second of elastic.out(1, 0.32): the letter comes down, overshoots the line slightly and wobbles. The colour returns in parallel, a little slower.
The two neighbours on each side rise 8% and 3% with 50 and 100 milliseconds of delay, only within the same word. With a finger the touch stays captured by the first element: on every pointermove the letter under the finger is found with elementFromPoint.
| Name | File | Default | What it changes |
|---|---|---|---|
COLORI | script.js | arancio, pesca, menta, verde acqua, giallo | The colours the jump picks at random. |
SALTO | script.js | -20 | How far the letter rises, as a percentage of its height. |
INCLINA | script.js | 12 | The maximum rotation in degrees, in a random direction. |
RIMBALZO | script.js | elastic.out(1, 0.32) | The fall: lower the second number for more wobbles. |
RICHIAMO | script.js | 5 | How many seconds between automatic waves across the title when nobody touches it. Remove it if the title should stay still. |
For a big title with character: a portfolio, a playful brand, a sign. Not for text to be read: a jumping paragraph cannot be read.
Swipe sideways across the title with a finger and the letter under it jumps; tapping a letter makes it jump. The title still lets the page scroll vertically (touch-action: pan-y).
The letters stay still.
GSAP 3.15.0, SplitText (GSAP) 3.15.0, Mona Sans (font variabile) 5.3.0.
Weight: 6.1 KB of our own files.
Tested on 27 September 2026
Use it in your own projects and in your clients’ projects. Do not resell it or redistribute it as is. GSAP stays under its own Standard license. Full license.
With your account’s email and password.
You’re signed in as .
Your plan is free. When lifetime access arrives, it will switch on for this same account.