
Words that melt into each other
Text, Scroll, SVG
NewFree
A word standing on the ground line, casting its shadow in perspective. The mouse turns the sun, scrolling stretches the shadow.
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 in piedi con l'ombra lunga</title>
<!-- Mona Sans ha l'asse della larghezza (wdth 75-125): serve per allargare le lettere.
In produzione scarica il font e servilo dal tuo dominio. -->
<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="corsa">
<div class="palco">
<div class="cielo" aria-hidden="true"></div>
<div class="sole" aria-hidden="true"></div>
<div class="terra" aria-hidden="true"></div>
<!-- l'ombra: la stessa parola, capovolta, su un piano ruotato in 3D attorno alla linea di terra -->
<div class="piano-vista" aria-hidden="true">
<div class="piano">
<p class="parola parola--ombra"><span class="parola__testo">Piazza</span></p>
</div>
</div>
<h1 class="parola parola--luce"><span class="parola__testo">Piazza</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="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script>
<script src="script.js"></script>
</body>
</html>/* Lettere in piedi con l'ombra lunga
I colori stanno tutti qui: il cielo e la terra cambiano col sole (script.js li mescola). */
:root {
--lettera: #d9602a; /* faccia delle lettere */
--bordo: #ffb27a; /* il filo di luce sulla testa delle lettere */
--ombra: #0b3b39; /* colore dell'ombra, mai nero puro */
--ombra-forza: 0.52; /* quanto è scura l'ombra */
--terra-chiara: #eadbc4;
--terra-scura: #d8c1a0;
}
html,
body {
margin: 0;
}
body {
background: var(--terra-scura);
font-family: 'Mona Sans Variable', system-ui, sans-serif;
}
.corsa {
height: 280vh; /* quanto si scorre per portare il sole a sera */
}
.palco {
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
}
@supports (height: 100svh) {
.palco { height: 100svh; }
}
.cielo {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, var(--cielo-alto, #cfebe5), var(--cielo-basso, #fff1e0) var(--base-y, 72%));
}
.sole {
position: absolute;
left: var(--sole-x, 40%);
top: var(--sole-y, 30%);
width: 16vmin;
aspect-ratio: 1;
border-radius: 50%;
background: var(--sole, #fff6e6);
box-shadow: 0 0 14vmin 4vmin color-mix(in srgb, var(--sole, #fff6e6) 28%, transparent);
transform: translate(-50%, -50%);
}
.terra {
position: absolute;
left: 0;
right: 0;
top: var(--base-y, 72%);
bottom: 0;
background: linear-gradient(to bottom, var(--terra-chiara), var(--terra-scura));
}
.parola {
margin: 0;
font-weight: 820;
font-stretch: calc(var(--wdth, 100) * 1%);
font-size: var(--corpo, 18vw);
line-height: 0.8;
letter-spacing: -0.01em;
text-transform: uppercase;
white-space: nowrap;
}
.parola__testo {
display: inline-block;
}
.lettera {
display: inline-block;
}
.base-misura {
display: inline-block;
width: 0;
height: 0;
vertical-align: baseline;
}
/* le lettere in piedi: la linea di base cade esattamente sulla linea di terra */
.parola--luce {
position: absolute;
left: 0;
right: 0;
top: var(--parola-top, 50%);
text-align: center;
color: var(--lettera);
text-shadow: 0 calc(var(--corpo, 18vw) * -0.028) 0 var(--bordo);
z-index: 3;
}
/* il piano dell'ombra: parte dalla linea di terra e si sdraia verso chi guarda */
.piano-vista {
position: absolute;
left: -30%;
right: -30%;
top: var(--base-y, 72%);
height: 140%;
perspective: var(--prospettiva, 800px);
perspective-origin: 50% -60%;
pointer-events: none;
mix-blend-mode: multiply;
z-index: 2;
}
.piano {
position: absolute;
inset: 0;
transform-origin: 50% 0;
transform: rotateX(72deg);
}
.parola--ombra {
position: absolute;
left: 0;
right: 0;
top: calc(var(--ombra-base, 0px) * -1);
text-align: center;
color: var(--ombra);
opacity: var(--ombra-forza);
transform-origin: 50% var(--ombra-base, 0px);
-webkit-mask-image: linear-gradient(to top, #000 8%, rgba(0, 0, 0, 0.26) 100%);
mask-image: linear-gradient(to top, #000 8%, rgba(0, 0, 0, 0.26) 100%);
filter: blur(0.6px);
}/* Lettere in piedi con l'ombra lunga
Una parola sta in piedi sulla linea di terra e proietta un'ombra lunga in prospettiva.
L'ombra è la stessa parola capovolta su un piano ruotato in 3D (style.css, .piano):
skewX la gira secondo la direzione del sole (az), scaleY la allunga secondo la sua altezza (el).
Il mouse sposta un poco il sole, lo scorrimento lo porta verso sera e allarga le lettere. */
gsap.registerPlugin(ScrollTrigger, SplitText);
/* ---- valori da cambiare ---- */
const LARGHEZZA = 0.84; /* quanta parte della larghezza occupa la parola */
const BASE = 0.72; /* altezza della linea di terra, in frazione dello schermo */
const MATTINO = { el: 38, az: -16 }; /* sole all'inizio: altezza e direzione in gradi */
const SERA = { el: 8, az: -52 }; /* sole a fine scorrimento */
const WDTH = [84, 122]; /* larghezza delle lettere: all'inizio e alla fine (asse wdth) */
const CIELO = { /* colori del cielo: di giorno e al tramonto */
giorno: { alto: '#cfebe5', basso: '#fff1e0', sole: '#fffaf0' },
sera: { alto: '#3a6f7a', basso: '#ffb27a', sole: '#ffd9a8' },
};
const palco = document.querySelector('.palco');
const corsa = document.querySelector('.corsa');
const luce = document.querySelector('.parola--luce .parola__testo');
const ombra = document.querySelector('.parola--ombra');
const ombraTesto = ombra.querySelector('.parola__testo');
const RIDOTTO = matchMedia('(prefers-reduced-motion: reduce)').matches;
const TOCCO = matchMedia('(hover: none), (pointer: coarse)').matches;
const clamp = gsap.utils.clamp;
const rad = (d) => (d * Math.PI) / 180;
const mescola = {
alto: gsap.utils.interpolate(CIELO.giorno.alto, CIELO.sera.alto),
basso: gsap.utils.interpolate(CIELO.giorno.basso, CIELO.sera.basso),
sole: gsap.utils.interpolate(CIELO.giorno.sole, CIELO.sera.sole),
};
document.fonts.ready.then(avvia);
function avvia() {
const sl = SplitText.create(luce, { type: 'chars', charsClass: 'lettera' });
const so = SplitText.create(ombraTesto, { type: 'chars', charsClass: 'lettera' });
/* un elemento in linea alto zero segna la linea di base del testo: da lì si misura tutto */
const bl = segnaBase(luce);
const bo = segnaBase(ombraTesto);
const stato = { t: 0, mx: 0, my: 0, sole: RIDOTTO ? 0 : 1 };
let sporco = true;
const segna = () => { sporco = true; };
function misura() {
const W = palco.clientWidth;
const H = palco.clientHeight;
/* il corpo si calcola sulla parola alla massima larghezza, così allargandosi non esce */
palco.style.setProperty('--wdth', WDTH[1]);
palco.style.setProperty('--corpo', '100px');
const w100 = luce.offsetWidth || 1;
const corpo = Math.min((W * LARGHEZZA) / w100 * 100, (H * 0.36) / 0.74);
palco.style.setProperty('--corpo', corpo.toFixed(2) + 'px');
const baseY = Math.round(H * BASE);
palco.style.setProperty('--base-y', baseY + 'px');
palco.style.setProperty('--parola-top', baseY - bl.offsetTop + 'px');
palco.style.setProperty('--ombra-base', bo.offsetTop + 'px');
palco.style.setProperty('--prospettiva', Math.round(Math.max(W, H) * 0.95) + 'px');
sl.chars.forEach((c) => gsap.set(c, { transformOrigin: `50% ${bl.offsetTop - c.offsetTop}px` }));
so.chars.forEach((c) => gsap.set(c, { transformOrigin: `50% ${bo.offsetTop - c.offsetTop}px` }));
segna();
}
/* el: altezza del sole in gradi; az: gradi a destra (+) o a sinistra (-) di chi guarda */
function stendi(el, az) {
const lunghezza = clamp(0.25, 9, 1 / Math.tan(rad(clamp(1.5, 89, el))));
const lato = -Math.atan(Math.tan(rad(az)));
ombra.style.transform = `skewX(${lato}rad) scaleY(${-lunghezza})`;
}
function applica() {
if (!sporco) return;
sporco = false;
const t = stato.t;
const el = gsap.utils.interpolate(MATTINO.el, SERA.el, t) - stato.my * 4;
const az = gsap.utils.interpolate(MATTINO.az, SERA.az, t) + stato.mx * 14;
/* stato.sole vale 1 all'ingresso (sole alto di mezzogiorno) e scende a 0 */
const elVero = el + stato.sole * 30;
stendi(elVero, az);
const sera = clamp(0, 1, (MATTINO.el - elVero) / (MATTINO.el - SERA.el));
palco.style.setProperty('--cielo-alto', mescola.alto(sera));
palco.style.setProperty('--cielo-basso', mescola.basso(sera));
palco.style.setProperty('--sole', mescola.sole(sera));
palco.style.setProperty('--sole-x', (50 + clamp(-40, 40, az * 0.8)).toFixed(2) + '%');
const baseY = parseFloat(palco.style.getPropertyValue('--base-y')) || palco.clientHeight * BASE;
palco.style.setProperty('--sole-y', (baseY - clamp(1, 80, elVero) * (baseY / 70)).toFixed(1) + 'px');
palco.style.setProperty('--wdth', gsap.utils.interpolate(WDTH[0], WDTH[1], t).toFixed(2));
}
misura();
gsap.ticker.add(applica);
let attesa;
addEventListener('resize', () => {
clearTimeout(attesa);
attesa = setTimeout(misura, 120);
});
if (RIDOTTO) {
/* movimento ridotto: tutto fermo, il sole nel pomeriggio e le lettere al loro posto */
stato.t = 0.45;
segna();
return;
}
/* ingresso: il sole scende da mezzogiorno e le lettere si alzano dal terreno */
gsap.timeline()
.from(sl.chars, { scaleY: 0, duration: 1, ease: 'back.out(1.6)', stagger: 0.05 }, 0.2)
.from(so.chars, { scaleY: 0, duration: 1, ease: 'back.out(1.6)', stagger: 0.05 }, 0.2)
.to(stato, { sole: 0, duration: 2.4, ease: 'power2.inOut', onUpdate: segna }, 0);
/* scorrimento: il sole va verso sera, le ombre si allungano, le lettere si allargano */
gsap.to(stato, {
t: 1,
ease: 'none',
onUpdate: segna,
scrollTrigger: { trigger: corsa, start: 'top top', end: 'bottom bottom', scrub: 0.8 },
});
if (!TOCCO) {
/* il mouse sposta un poco il sole */
const mx = gsap.quickTo(stato, 'mx', { duration: 1.2, ease: 'power3', onUpdate: segna });
const my = gsap.quickTo(stato, 'my', { duration: 1.2, ease: 'power3', onUpdate: segna });
addEventListener('pointermove', (e) => {
mx((e.clientX / innerWidth) * 2 - 1);
my((e.clientY / innerHeight) * 2 - 1);
}, { passive: true });
} else {
/* senza mouse il sole respira da solo, e si ferma quando la scena esce dallo schermo */
const respiro = gsap.fromTo(stato, { mx: -0.6 }, { mx: 0.6, duration: 6, ease: 'sine.inOut', yoyo: true, repeat: -1, paused: true, onUpdate: segna });
ScrollTrigger.create({ trigger: corsa, start: 'top bottom', end: 'bottom top', onToggle: (s) => (s.isActive ? respiro.play() : respiro.pause()) });
}
}
function segnaBase(testo) {
const i = document.createElement('i');
i.className = 'base-misura';
testo.appendChild(i);
return i;
}The visible word and its shadow are two elements with the same text. The shadow lives inside a plane (.piano) rotated 72 degrees around the ground line, inside a container with perspective.
A zero-height inline element (.base-misura) placed at the end of the text tells where the baseline falls. The word is moved until its baseline sits on the ground line, and the shadow starts exactly there.
The sun's elevation gives the shadow length (1 / tan(el)), its direction gives the slant (atan(tan(az))). The result is skewX(slant) scaleY(-length) on the shadow.
A scroll-linked tween moves one value from 0 to 1: the sun's elevation, its direction, the sky colours and the letter width all come from it. The mouse adds a few degrees with gsap.quickTo.
Every change marks the scene as dirty; a function on the GSAP ticker redraws it once per frame.
| Name | File | Default | What it changes |
|---|---|---|---|
LARGHEZZA | script.js | 0.84 | How much of the screen width the word takes. |
BASE | script.js | 0.72 | Where the ground line falls, as a fraction of the height. |
MATTINO / SERA | script.js | 38° / 8° | Sun elevation and direction at the start and end of the scroll. Below 10 degrees the shadows get very long. |
WDTH | script.js | 84, 122 | Letter width on the wdth axis, at the start and at the end. It needs a variable font with a width axis. |
--ombra, --ombra-forza | style.css | #0b3b39, 0.52 | Shadow colour and strength. A dark colour from your palette works better than black. |
.corsa height | style.css | 280vh | How far you scroll to bring the sun to evening. |
A short, important name, at the top or in the footer of a page. Not for long text: the effect carries one word, two short ones at most.
Without a mouse the sun breathes on its own, back and forth, and stops when the scene leaves the screen. Scrolling works the same.
Everything holds still: the sun stays in the afternoon, letters and shadow are already in place and scrolling changes nothing.
GSAP 3.15.0, ScrollTrigger 3.15.0, SplitText 3.15.0, Mona Sans (font variabile) 5.3.0.
Weight: 11 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.