
Standing letters with a long shadow
Text, Scroll, 3D
NewFree
As you scroll, each word melts into the next: the outlines stick together like drops while the font width slides from one to the other.
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>Parole che si fondono</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>
<!-- il filtro "goo": sfoca le lettere e poi taglia la trasparenza, così i bordi si incollano come gocce -->
<svg class="filtri" aria-hidden="true" focusable="false">
<filter id="goo" x="-10%" y="-30%" width="120%" height="160%">
<feGaussianBlur class="goo-sfoca" in="SourceGraphic" stdDeviation="0" result="sfocato" />
<feColorMatrix in="sfocato" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -10" result="goo" />
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
</svg>
<main class="corsa">
<div class="palco">
<p class="sopra">Ogni progetto passa di qui</p>
<!-- data-w: larghezza del font (asse wdth) che ogni parola vuole avere -->
<h1 class="parole">
<span class="sr">idea, sketch, design, live</span>
<span class="parola" data-w="76" style="--c: #0f4f4c" aria-hidden="true">idea</span>
<span class="parola" data-w="96" style="--c: #146662" aria-hidden="true">sketch</span>
<span class="parola" data-w="112" style="--c: #d9602a" aria-hidden="true">design</span>
<span class="parola" data-w="125" style="--c: #ff8a3d" aria-hidden="true">live</span>
</h1>
</div>
</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="script.js"></script>
</body>
</html>/* Parole che si fondono */
:root {
--sfondo: #eef3ef;
--testo: #07201f;
}
html,
body {
margin: 0;
}
body {
background: var(--sfondo);
color: var(--testo);
font-family: 'Mona Sans Variable', system-ui, sans-serif;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.filtri {
position: absolute;
width: 0;
height: 0;
}
.corsa {
height: 420vh; /* più è alta, più lenta è ogni fusione */
}
.palco {
position: sticky;
top: 0;
height: 100vh;
display: grid;
place-content: center;
justify-items: center;
gap: 3vh;
overflow: hidden;
}
@supports (height: 100svh) {
.palco { height: 100svh; }
}
.sopra {
margin: 0;
font-size: clamp(15px, 1.4vw, 20px);
font-weight: 560;
font-stretch: 106%;
opacity: 0.7;
}
/* tutte le parole occupano la stessa cella: una svanisce mentre l'altra compare */
.parole {
margin: 0;
display: grid;
place-items: center;
filter: url(#goo);
font-weight: 880;
font-size: clamp(76px, 19vw, 280px);
line-height: 0.9;
letter-spacing: -0.04em;
}
.parola {
grid-area: 1 / 1;
color: var(--c);
font-stretch: calc(var(--w, 100) * 1%);
opacity: 0;
white-space: nowrap;
padding: 0.1em 0.12em;
}
.sr + .parola {
opacity: 1;
}
/* movimento ridotto: le parole una accanto all'altra, già tutte leggibili */
.fermo .corsa {
height: auto;
}
.fermo .palco {
position: relative;
height: auto;
min-height: 100vh;
padding: 10vh 6vw;
}
.fermo .parole {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0 0.3em;
filter: none;
font-size: clamp(48px, 9vw, 140px);
}
.fermo .parola {
grid-area: auto;
opacity: 1;
}/* Parole che si fondono
Le parole stanno una sopra l'altra nella stessa cella. Scorrendo, ognuna si scioglie nella
successiva: mentre una svanisce e l'altra compare, il filtro "goo" (index.html) sfoca e poi
ritaglia la trasparenza, così i contorni delle due parole si incollano come gocce.
Nello stesso momento la larghezza del font scivola da una parola all'altra. */
gsap.registerPlugin(ScrollTrigger);
/* ---- valori da cambiare ---- */
const PAUSA = 0.18; /* quanta parte di ogni tratto la parola resta ferma prima di sciogliersi */
const GOCCIA = 0.05; /* quanto si sfocano le lettere a metà fusione, in frazione del corpo */
const parole = [...document.querySelectorAll('.parola')];
const scatola = document.querySelector('.parole');
const sfoca = document.querySelector('.goo-sfoca');
const RIDOTTO = matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = gsap.utils.clamp;
const lerp = (a, b, t) => a + (b - a) * t;
const larghezze = parole.map((p) => +p.dataset.w || 100);
const tratti = parole.length - 1;
let corpo = 200;
const misura = () => { corpo = parseFloat(getComputedStyle(scatola).fontSize) || 200; };
if (RIDOTTO) {
document.documentElement.classList.add('fermo');
parole.forEach((p, i) => p.style.setProperty('--w', larghezze[i]));
} else {
misura();
addEventListener('resize', misura);
const stato = { p: 0 };
function disegna() {
const pp = clamp(0, 0.99999, stato.p) * tratti;
const i = Math.floor(pp);
const t = pp - i;
const ora = parole[i];
const poi = parole[i + 1];
/* dentro ogni tratto: ferma, poi si scioglie, poi ferma */
const m = clamp(0, 1, (t - PAUSA) / (1 - PAUSA * 2));
const w = lerp(larghezze[i], larghezze[i + 1], m);
parole.forEach((p) => { if (p !== ora && p !== poi) p.style.opacity = 0; });
ora.style.opacity = 1 - m;
poi.style.opacity = m;
ora.style.setProperty('--w', w.toFixed(2));
poi.style.setProperty('--w', w.toFixed(2));
const goccia = Math.sin(Math.PI * m) * corpo * GOCCIA;
sfoca.setAttribute('stdDeviation', goccia.toFixed(2));
/* a riposo il filtro si spegne: le lettere restano nitide e il browser lavora meno */
scatola.style.filter = goccia > 0.1 ? '' : 'none';
}
gsap.to(stato, {
p: 1,
ease: 'none',
onUpdate: disegna,
scrollTrigger: { trigger: '.corsa', start: 'top top', end: 'bottom bottom', scrub: 0.7 },
});
disegna();
/* ingresso: la prima parola sale dal basso mentre il sottotitolo compare */
gsap.from(parole[0], { yPercent: 40, opacity: 0, duration: 1.2, ease: 'expo.out', delay: 0.2 });
gsap.from('.sopra', { y: 16, opacity: 0, duration: 1, ease: 'expo.out', delay: 0.35 });
}The words are spans in the same grid cell (grid-area: 1 / 1), so they sit exactly on top of each other.
The container has an SVG filter: feGaussianBlur blurs, feColorMatrix with the alpha row '0 0 0 22 -10' cuts the transparency sharply, feComposite puts the real colours back. Two blurred shapes close together become a single drop.
Scroll moves a value from 0 to 1. Divided by the number of steps, it tells which word is leaving, which one is arriving and how far the melt has gone.
The blur follows sin(π · melt): zero at the start, strongest halfway, zero at the end. It is proportional to the font size, so the effect looks the same on every screen.
When the blur is close to zero the filter is removed entirely: letters stay sharp and the browser stops recomputing the filter every frame.
| Name | File | Default | What it changes |
|---|---|---|---|
data-w | index.html | 76 → 125 | The font width (wdth axis) of each word. Long words look good narrow, short words wide. |
--c | index.html | #0f4f4c … | Each word's colour: during the melt the two colours blend. |
GOCCIA | script.js | 0.05 | How much the letters blur halfway through. Higher values make the words more liquid. |
PAUSA | script.js | 0.18 | How long each word holds still before melting. |
.corsa height | style.css | 420vh | The scroll length: the taller it is, the slower the melts. |
To tell a transformation in a few words: a process, a before and after, a promise. It works with short words and a solid colour.
Identical: scrolling with a finger drives the melt. The font size scales with the screen, and the blur with the font size.
No melting: the words appear side by side, all readable, without a pinned scene.
GSAP 3.15.0, ScrollTrigger 3.15.0, Mona Sans (font variabile) 5.3.0.
Weight: 6.1 KB of our own files.
Tested on 26 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.