
Standing letters with a long shadow
Text, Scroll, 3D
NewFree
As you scroll, a huge word repeats itself in steps and loses its last letter: the I tips over, falls under the word and, as it touches the ground, becomes the button to write.
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>La lettera che cade</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/mona-sans@5.3.0/standard.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/big-shoulders@5.3.0/index.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main>
<section class="scena" data-scena>
<h1 class="nascosto">Scrivi</h1>
<p class="cenno" data-cenno>Scorri piano: la parola si ripete a scalini e perde l’ultima lettera.</p>
<!-- sei copie della stessa parola, una sopra l'altra: l'ultima è davanti, e la sua I è un link -->
<div class="eco" data-eco>
<div class="eco__misura" aria-hidden="true" data-misura><span class="l">S</span><span class="l">C</span><span class="l">R</span><span class="l">I</span><span class="l">V</span><span class="l">I</span></div>
<div class="copia" data-copia>
<span class="copia__lastra"></span>
<span class="copia__parola" aria-hidden="true"><span class="l">S</span><span class="l">C</span><span class="l">R</span><span class="l">I</span><span class="l">V</span><span class="l">I</span></span>
</div>
<div class="copia" data-copia>
<span class="copia__lastra"></span>
<span class="copia__parola" aria-hidden="true"><span class="l">S</span><span class="l">C</span><span class="l">R</span><span class="l">I</span><span class="l">V</span><span class="l">I</span></span>
</div>
<div class="copia" data-copia>
<span class="copia__lastra"></span>
<span class="copia__parola" aria-hidden="true"><span class="l">S</span><span class="l">C</span><span class="l">R</span><span class="l">I</span><span class="l">V</span><span class="l">I</span></span>
</div>
<div class="copia" data-copia>
<span class="copia__lastra"></span>
<span class="copia__parola" aria-hidden="true"><span class="l">S</span><span class="l">C</span><span class="l">R</span><span class="l">I</span><span class="l">V</span><span class="l">I</span></span>
</div>
<div class="copia" data-copia>
<span class="copia__lastra"></span>
<span class="copia__parola" aria-hidden="true"><span class="l">S</span><span class="l">C</span><span class="l">R</span><span class="l">I</span><span class="l">V</span><span class="l">I</span></span>
</div>
<div class="copia copia--davanti" data-copia>
<span class="copia__lastra"></span>
<span class="copia__parola" aria-hidden="true"><span class="l">S</span><span class="l">C</span><span class="l">R</span><span class="l">I</span><span class="l">V</span><span class="l" data-i>I</span><span class="copia__base" data-base></span></span>
<a class="goccia" href="#scrivici" data-goccia aria-label="Scrivici" tabindex="-1"><span class="goccia__premi" data-premi><span class="goccia__corpo" data-corpo><span class="goccia__testo" data-etichetta></span></span></span></a>
</div>
</div>
</section>
<section class="coda" id="scrivici">
<h2 class="coda__titolo">Scrivici</h2>
<p class="coda__testo">Una riga basta. Qui va il modulo, o l’indirizzo a cui scrivere.</p>
</section>
</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/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrambleTextPlugin.min.js"></script>
<script src="script.js"></script>
</body>
</html>/* La lettera che cade */
:root {
--carta: #E4E6EA;
--inchiostro: #16181D;
--tenue: #565B65;
--blu: #2B45FF;
--blu-scuro: #1A31D6;
--su-blu: #F4F5F8;
--margine: clamp(18px, 4vw, 56px);
--alto: 100vh;
}
@supports (height: 100svh) {
:root { --alto: 100svh; }
}
* { box-sizing: border-box; }
html,
body {
margin: 0;
}
body {
background: var(--carta);
color: var(--inchiostro);
font-family: 'Mona Sans Variable', system-ui, sans-serif;
}
.nascosto {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* ---- la scena: resta ferma mentre la parola si ripete e la lettera cade ---- */
.scena {
position: relative;
height: var(--alto);
overflow: hidden;
}
.cenno {
position: absolute;
z-index: 2;
top: var(--margine);
left: var(--margine);
max-width: 30ch;
margin: 0;
color: var(--tenue);
font-size: clamp(0.95rem, 0.85rem + 0.35vw, 1.15rem);
font-weight: 520;
line-height: 1.35;
}
/* la parola: la misura dà il posto, le copie ci stanno sopra una dopo l'altra */
.eco {
position: absolute;
left: 50%;
top: 46%;
transform: translate(-50%, -50%);
font-family: 'Big Shoulders Variable', 'Mona Sans Variable', sans-serif;
font-size: 20vw;
font-weight: 900;
line-height: 0.82;
letter-spacing: -0.01em;
text-transform: uppercase;
white-space: nowrap;
}
.eco__misura {
visibility: hidden;
}
.l {
display: inline-block;
}
.copia {
position: absolute;
inset: 0;
isolation: isolate;
transform-origin: 50% 100%;
}
/* la lastra ha il colore della pagina: copre le copie dietro, così di loro resta solo la cima */
.copia__lastra {
position: absolute;
z-index: -1;
top: -9%;
right: -3%;
bottom: -8%;
left: -3%;
background: var(--carta);
}
.eco--pronta .copia--davanti [data-i] {
visibility: hidden;
}
.copia__base {
display: inline-block;
width: 0;
height: 0;
vertical-align: baseline;
}
/* ---- la I: una barra d'inchiostro al posto della lettera, che cadendo diventa un bottone ---- */
.goccia {
position: absolute;
display: block;
color: var(--su-blu);
text-decoration: none;
pointer-events: none;
outline: none;
-webkit-tap-highlight-color: transparent;
}
.goccia--giu {
pointer-events: auto;
cursor: pointer;
}
.goccia__premi {
display: block;
width: 100%;
height: 100%;
transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.goccia--giu:active .goccia__premi {
transform: scale(0.96);
}
.goccia__corpo {
position: relative;
display: block;
width: 100%;
height: 100%;
background: var(--inchiostro);
/* quando tocca terra si schiaccia dal lato che guarda in basso */
transform-origin: 100% 50%;
transition: background-color 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.goccia--giu .goccia__corpo {
background: var(--blu);
}
@media (hover: hover) and (pointer: fine) {
.goccia--giu:hover .goccia__corpo {
background: var(--blu-scuro);
}
}
.goccia:focus-visible .goccia__corpo {
box-shadow: 0 0 0 3px var(--carta), 0 0 0 6px var(--blu);
}
/* il testo è girato al contrario della barra: quando la barra è a terra, si legge dritto */
.goccia__testo {
position: absolute;
left: 50%;
top: 50%;
font-family: 'Mona Sans Variable', system-ui, sans-serif;
font-size: var(--etichetta, 16px);
font-weight: 720;
letter-spacing: 0.04em;
line-height: 1;
white-space: nowrap;
transform: translate(-50%, -50%) rotate(-90deg);
}
/* ---- dove porta il bottone ---- */
.coda {
display: grid;
align-content: center;
justify-items: start;
gap: 1rem;
min-height: calc(var(--alto) * 0.72);
padding: var(--margine);
}
.coda__titolo {
margin: 0;
font-family: 'Big Shoulders Variable', sans-serif;
font-size: clamp(3.4rem, 13vw, 10rem);
font-weight: 900;
line-height: 0.82;
text-transform: uppercase;
}
.coda__testo {
max-width: 34ch;
margin: 0;
color: var(--tenue);
font-size: clamp(1rem, 0.9rem + 0.4vw, 1.25rem);
line-height: 1.45;
}/* La lettera che cade
Una parola enorme che, scorrendo, si ripete a scalini: sei copie una sopra l'altra, ognuna su una lastra
del colore della pagina, sempre più piccole e più in basso, così delle copie dietro resta solo la cima.
Poi l'ultima lettera, una I, si inclina e cade sotto la parola: toccando terra si schiaccia, diventa blu
e si scrive il suo nome. Era una lettera, adesso è un bottone. */
gsap.registerPlugin(ScrollTrigger, ScrambleTextPlugin);
/* ---- valori da cambiare ---- */
const ETICHETTA = 'SCRIVICI'; /* il nome del bottone: si scrive quando la I tocca terra */
const CORSA = 1.8; /* quanti schermi di scorrimento dura la scena */
const LETTERE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const DITO = 46; /* spessore minimo del bottone a terra, in pixel veri */
const RIDOTTO = matchMedia('(prefers-reduced-motion: reduce)').matches;
const MOUSE = matchMedia('(hover: hover) and (pointer: fine)').matches;
const scena = document.querySelector('[data-scena]');
const eco = document.querySelector('[data-eco]');
const misuraEl = document.querySelector('[data-misura]');
const copie = gsap.utils.toArray('[data-copia]');
const davanti = copie[copie.length - 1];
const lettera = davanti.querySelector('[data-i]');
const base = davanti.querySelector('[data-base]');
const goccia = document.querySelector('[data-goccia]');
const premi = goccia.querySelector('[data-premi]');
const corpo = goccia.querySelector('[data-corpo]');
const etichetta = goccia.querySelector('[data-etichetta]');
const cenno = document.querySelector('[data-cenno]');
const FRASI = [cenno.textContent, 'La I è caduta: adesso è un bottone.'];
/* ---------------------------------------------------------------- le misure */
/* tutto in coordinate della copia davanti, senza le sue trasformazioni: la I, il bottone, il punto d'arrivo */
const righello = document.createElement('canvas').getContext('2d');
const geo = { scala: 1 / 13, passoY: 2, sinistra: 0, cima: 0, largo: 10, alto: 10, spesso: 10, lungo: 10, dx: 0, dy: 0 };
function misura() {
const vw = scena.clientWidth;
const vh = scena.clientHeight;
const stretto = vw < 640;
/* ogni copia è un tredicesimo più piccola e scende del 2% (al telefono un dodicesimo e il 10%) */
geo.scala = stretto ? 1 / 12 : 1 / 13;
geo.passoY = stretto ? 10 : 2;
/* il corpo della parola: largo quanto lo schermo, ma mai più alto di così */
eco.style.fontSize = '100px';
const larga = misuraEl.offsetWidth;
righello.font = '900 100px "Big Shoulders Variable"';
const capo = righello.measureText('I').actualBoundingBoxAscent / 100;
const f = Math.min((vw * (stretto ? 0.9 : 0.86) * 100) / larga, (vh * (stretto ? 0.24 : 0.44)) / capo);
eco.style.fontSize = `${f.toFixed(2)}px`;
/* il riquadro dell'inchiostro della I: da dove parte la barra */
righello.font = `900 ${f}px "Big Shoulders Variable"`;
const m = righello.measureText('I');
const linea = base.offsetTop;
geo.sinistra = lettera.offsetLeft - m.actualBoundingBoxLeft;
geo.largo = m.actualBoundingBoxLeft + m.actualBoundingBoxRight;
geo.alto = m.actualBoundingBoxAscent;
geo.cima = linea - geo.alto;
/* il bottone a terra: spesso almeno un dito, lungo abbastanza per il suo nome */
const piccola = 1 - (copie.length - 1) * geo.scala;
geo.spesso = Math.max(geo.largo, DITO / piccola);
const corpoTesto = geo.spesso * 0.34;
righello.font = `720 ${corpoTesto}px "Mona Sans Variable"`;
const nome = righello.measureText(ETICHETTA).width + ETICHETTA.length * corpoTesto * 0.04;
geo.lungo = Math.max(geo.alto, nome + geo.spesso * 1.3);
goccia.style.setProperty('--etichetta', `${corpoTesto.toFixed(1)}px`);
goccia.style.left = `${geo.sinistra}px`;
goccia.style.top = `${geo.cima}px`;
/* coricato, il bottone finisce allineato al bordo destro della parola, poco sotto la riga */
const destra = geo.sinistra + geo.largo;
const arrivoX = destra - geo.lungo / 2;
const arrivoY = linea + geo.alto * 0.22 + geo.spesso / 2;
geo.dx = arrivoX - (geo.sinistra + geo.spesso / 2);
geo.dy = arrivoY - (geo.cima + geo.lungo / 2);
}
/* ---------------------------------------------------------------- l'atterraggio */
let giu = false;
let frase = 0;
function cambiaCenno(i, anima) {
if (frase === i) return;
frase = i;
if (!anima) { cenno.textContent = FRASI[i]; return; }
gsap.to(cenno, {
opacity: 0, y: -6, duration: 0.18, ease: 'power1.in', overwrite: true,
onComplete: () => {
cenno.textContent = FRASI[frase];
gsap.fromTo(cenno, { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: 0.4, ease: 'power2.out' });
},
});
}
function atterra(anima) {
giu = true;
goccia.classList.add('goccia--giu');
goccia.tabIndex = 0;
cambiaCenno(1, anima);
if (!anima) {
etichetta.textContent = ETICHETTA;
return;
}
/* tocca terra: si schiaccia, rimbalza appena e scrive il suo nome (il blu lo mette il CSS) */
gsap.fromTo(corpo, { scaleX: 0.72, scaleY: 1.12 }, { scaleX: 1, scaleY: 1, duration: 0.9, ease: 'elastic.out(1, 0.38)', overwrite: 'auto' });
gsap.to(etichetta, { duration: 0.9, scrambleText: { text: ETICHETTA, chars: LETTERE, speed: 0.6, revealDelay: 0.2 }, ease: 'none', overwrite: true });
}
function solleva() {
giu = false;
goccia.classList.remove('goccia--giu');
goccia.tabIndex = -1;
if (document.activeElement === goccia) goccia.blur();
cambiaCenno(0, true);
gsap.to(corpo, { scaleX: 1, scaleY: 1, duration: 0.25, overwrite: 'auto' });
gsap.to(etichetta, { duration: 0.35, scrambleText: { text: '', chars: LETTERE, speed: 1 }, ease: 'none', overwrite: true });
}
/* ---------------------------------------------------------------- la scena */
const ATTERRA = 2.15;
function montaScena() {
/* l'eco e poi la caduta, in un'unica linea del tempo legata allo scorrimento */
const tl = gsap.timeline({ paused: true, defaults: { ease: 'none' } });
tl.fromTo(copie, { scale: 1, yPercent: 0 }, { scale: (i) => 1 - i * geo.scala, yPercent: (i) => i * geo.passoY, duration: 1 }, 0)
/* la I si inclina sempre più in fretta, scivola un poco a sinistra e cade: arriva a terra alla massima velocità */
.fromTo(goccia, { rotation: 0 }, { rotation: 90, duration: 1, ease: 'power2.in' }, 1.15)
.fromTo(goccia, { x: 0 }, { x: () => geo.dx, duration: 1, ease: 'sine.in' }, 1.15)
.fromTo(goccia, { y: 0 }, { y: () => geo.dy, duration: 1, ease: 'power3.in' }, 1.15)
.fromTo(goccia, { width: () => geo.largo, height: () => geo.alto }, { width: () => geo.spesso, height: () => geo.lungo, duration: 1, ease: 'power1.in' }, 1.15)
.to({}, { duration: 0.3 });
tl.eventCallback('onUpdate', () => {
const t = tl.time();
if (!giu && t >= ATTERRA - 0.01) atterra(true);
else if (giu && t < ATTERRA - 0.07) solleva();
});
ScrollTrigger.addEventListener('refreshInit', misura);
ScrollTrigger.create({
trigger: scena,
start: 'top top',
end: () => `+=${Math.round(innerHeight * CORSA)}`,
pin: true,
scrub: 0.8,
animation: tl,
invalidateOnRefresh: true,
});
}
/* la parola entra lettera per lettera, e la I si alza per ultima */
function ingresso() {
if (scrollY > 8) return;
const t = gsap.timeline({ delay: 0.15 });
t.from(davanti.querySelectorAll('.copia__parola .l:not([data-i])'), { yPercent: 38, opacity: 0, duration: 1.1, ease: 'expo.out', stagger: 0.06 })
.from(premi, { scaleY: 0, transformOrigin: '50% 100%', duration: 0.9, ease: 'expo.out', clearProps: 'transform' }, 0.3)
.from(cenno, { opacity: 0, y: 8, duration: 0.8, ease: 'power2.out' }, 0.45);
}
/* ---------------------------------------------------------------- i gesti */
if (MOUSE) {
goccia.addEventListener('pointerenter', () => {
if (!giu || RIDOTTO) return;
gsap.to(etichetta, { duration: 0.4, scrambleText: { text: ETICHETTA, chars: LETTERE, speed: 1 }, ease: 'none', overwrite: true });
});
}
goccia.addEventListener('click', (e) => {
e.preventDefault();
if (!giu) return;
const meta = document.querySelector(goccia.getAttribute('href'));
window.scrollTo({ top: meta.getBoundingClientRect().top + scrollY, behavior: RIDOTTO ? 'auto' : 'smooth' });
meta.setAttribute('tabindex', '-1');
meta.focus({ preventScroll: true });
});
/* ---------------------------------------------------------------- l'avvio: prima i caratteri, poi le misure */
function avvia() {
misura();
eco.classList.add('eco--pronta');
if (RIDOTTO) {
/* movimento ridotto: la scena è già alla fine, la I già a terra col suo nome */
const fine = () => {
misura();
gsap.set(copie, { scale: (i) => 1 - i * geo.scala, yPercent: (i) => i * geo.passoY });
gsap.set(goccia, { rotation: 90, x: geo.dx, y: geo.dy, width: geo.spesso, height: geo.lungo });
};
fine();
atterra(false);
let attesa = null;
addEventListener('resize', () => { clearTimeout(attesa); attesa = setTimeout(fine, 150); });
return;
}
montaScena();
ingresso();
}
Promise.all([
document.fonts.load('900 100px "Big Shoulders Variable"', 'SCRIVI'),
document.fonts.load('720 20px "Mona Sans Variable"', ETICHETTA),
]).catch(() => {}).then(avvia);The copies are six divs on top of each other with the same word, and each has a plate behind it the colour of the page, a little larger than the word. The timeline takes copy i to scale 1 − i/13 and moves it down by 2i% (on phones 1 − i/12 and 10i%), with the origin at the bottom: the copies behind stay larger, and only their tops show.
In the front copy the I is hidden and a link takes its place: a bar of ink laid exactly over the ink of the letter. The box comes from measureText on a canvas (actualBoundingBoxLeft, Right and Ascent) and the baseline from a zero-height span aligned to the baseline.
In the same timeline, after a pause, the bar rotates 90° with power2.in, drops with power3.in and slides left with sine.in: it speeds up like something tipping over, and hits the ground at full speed. Meanwhile it widens to at least 46 real pixels, because on the ground a finger will have to press it.
When the timeline passes the landing point, a timed animation starts, not tied to scrolling: the bar squashes (0.72 by 1.12) and springs back with elastic.out, turns blue and ScrambleText writes the name on it. Going back, the opposite happens, and the link leaves the Tab order.
| Name | File | Default | What it changes |
|---|---|---|---|
ETICHETTA | script.js | SCRIVICI | The button’s name. The word lives in the HTML, letter by letter: if you change it, the last one must stay a stem letter, like the I. |
CORSA | script.js | 1.8 | How many screens of scrolling the scene lasts. |
DITO | script.js | 46 | The minimum thickness of the button on the ground, in real pixels. |
--blu | style.css | #2B45FF | The colour the I takes when it becomes a button. |
To close a page: the contact word turning into the contact button. It works with any word that ends in a stem letter.
The copies step down further, 10% each, to fill a tall screen, and the bar on the ground widens to a thickness comfortable for a thumb.
No held scene and no fall: the word is already in steps and the I is already on the ground, blue, with its name.
GSAP 3.15.0, Big Shoulders (font variabile) 5.3.0, Mona Sans (font variabile) 5.3.0.
Weight: 16 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.