
Marquee that speeds up with scroll
Scroll, Text, Hover
Lifetime
The monogram «Q.» opens like an accordion into the full word: the gap widens pushing the full stop along, and the middle letters rise from below the line in random order.
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>Il marchio a fisarmonica</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>
<header class="testa">
<!-- il marchio piccolo della testata: la sigla si apre nella parola intera -->
<button class="marchio marchio--piccolo" type="button" data-marchio="Quasi.">Quasi.</button>
<p class="testa__nota">Studio di grafica</p>
</header>
<main class="eroe">
<!-- lo stesso marchio alla misura di un titolo -->
<button class="marchio marchio--grande" type="button" data-marchio="Quasi." data-autoclic>Quasi.</button>
<p class="eroe__nota">Passa sopra il marchio, o toccalo.</p>
</main>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="script.js"></script>
</body>
</html>/* Il marchio a fisarmonica */
:root {
--fondo: #2340C8;
--testo: #F7F5EF;
--margine: clamp(18px, 4vw, 56px);
--schermo: 100vh;
}
@supports (height: 100svh) {
:root { --schermo: 100svh; }
}
* { box-sizing: border-box; }
html,
body {
margin: 0;
}
body {
font-family: 'Mona Sans Variable', system-ui, sans-serif;
color: var(--testo);
background: var(--fondo);
}
/* ------------------------------------------------------------------ il marchio */
.marchio {
display: inline-flex;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-weight: 900;
font-stretch: 125%;
line-height: 1;
letter-spacing: -0.03em;
text-transform: uppercase;
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.marchio:focus-visible {
outline: 2px solid var(--testo);
outline-offset: 0.12em;
}
/* la parte che si apre: la sua larghezza va da zero alla parola intera */
.marchio__mezzo {
display: inline-flex;
overflow: clip;
}
/* ogni lettera ha la sua maschera, alta una riga: sale da sotto la linea.
Taglia solo in verticale: con la spaziatura stretta il fianco della lettera esce dalla scatola */
.marchio__lettera {
display: inline-block;
flex: none;
overflow-x: visible;
overflow-y: clip;
}
.marchio__lettera > span {
display: inline-block;
}
.marchio--piccolo {
font-size: 28px;
}
.marchio--grande {
font-size: clamp(4rem, 1rem + 15vw, 17rem);
}
/* ------------------------------------------------------------------ la pagina */
.testa {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 22px var(--margine);
}
.testa__nota {
margin: 0;
font-size: 0.95rem;
font-weight: 560;
}
.eroe {
display: grid;
place-content: center;
justify-items: center;
gap: clamp(20px, 3vw, 40px);
min-height: var(--schermo);
padding: 96px var(--margine) var(--margine);
text-align: center;
}
.eroe__nota {
margin: 0;
font-size: clamp(1rem, 0.9rem + 0.4vw, 1.3rem);
font-weight: 500;
opacity: 0.85;
}
@media (max-width: 560px) {
.marchio--piccolo {
font-size: 22px;
}
.testa__nota {
font-size: 0.85rem;
}
}/* Il marchio a fisarmonica
A riposo il marchio è solo la sigla: la prima lettera e il punto. Passandoci sopra si apre come
una fisarmonica: lo spazio fra le due si allarga fino alla parola intera, spingendo il punto a
destra, e intanto le lettere di mezzo salgono da sotto la linea, ognuna nella sua maschera, in
un ordine diverso ogni volta. Uscendo le lettere ricadono e lo spazio si richiude. Ogni gesto
riparte da dove è arrivato il precedente, anche a metà. */
/* ---- valori da cambiare ---- */
const APRE = 0.7; /* secondi perché lo spazio si allarghi o si richiuda */
const SALE = 0.6; /* secondi di ogni lettera */
const SFASA_SU = 0.06; /* secondi fra una lettera e l'altra quando salgono */
const SFASA_GIU = 0.08; /* secondi fra una lettera e l'altra quando ricadono */
const RITARDO = 0.35; /* le lettere partono quando lo spazio è già un po' aperto */
const CURVA = 'power4.inOut';
const RIDOTTO = matchMedia('(prefers-reduced-motion: reduce)').matches;
/* da "Quasi." a: sigla, lettere di mezzo nelle loro maschere, punto */
function costruisci(el) {
const parola = el.dataset.marchio;
const mezzo = [...parola.slice(1, -1)];
el.setAttribute('aria-label', parola.replace(/[.'’*]$/, ''));
el.innerHTML = `<span aria-hidden="true">${parola[0]}</span>`
+ `<span class="marchio__mezzo" aria-hidden="true">${mezzo.map((c) => `<span class="marchio__lettera"><span>${c}</span></span>`).join('')}</span>`
+ `<span aria-hidden="true">${parola.slice(-1)}</span>`;
const m = {
el,
mezzo: el.querySelector('.marchio__mezzo'),
maschere: [...el.querySelectorAll('.marchio__lettera')],
lettere: [...el.querySelectorAll('.marchio__lettera > span')],
aperto: false,
tl: null,
};
gsap.set(m.mezzo, { width: 0 });
gsap.set(m.lettere, RIDOTTO ? { autoAlpha: 0 } : { yPercent: 110 });
return m;
}
/* la larghezza della parola aperta: la somma delle maschere, che non dipende dallo spazio attuale */
const intera = (m) => m.maschere.reduce((s, l) => s + l.getBoundingClientRect().width, 0);
function apri(m) {
if (m.aperto) return;
m.aperto = true;
if (m.tl) m.tl.kill();
if (RIDOTTO) {
gsap.set(m.mezzo, { width: 'auto' });
m.tl = gsap.to(m.lettere, { autoAlpha: 1, duration: 0.2, ease: 'none' });
return;
}
const larga = intera(m);
const gia = Math.min(1, m.mezzo.getBoundingClientRect().width / larga);
const giu = m.lettere.filter((l) => gsap.getProperty(l, 'yPercent') > 0.5);
m.tl = gsap.timeline({ onComplete: () => gsap.set(m.mezzo, { width: 'auto' }) });
/* se lo spazio è già aperto (si era a metà della chiusura) salgono solo le lettere */
if (gia < 0.99) m.tl.to(m.mezzo, { width: larga, duration: APRE * Math.max(0.4, 1 - gia), ease: CURVA }, 0);
m.tl.to(gsap.utils.shuffle(giu), { yPercent: 0, duration: SALE, ease: CURVA, stagger: SFASA_SU }, gia < 0.99 ? RITARDO * (1 - gia) : 0);
}
function chiudi(m) {
if (!m.aperto) return;
m.aperto = false;
if (m.tl) m.tl.kill();
if (RIDOTTO) {
m.tl = gsap.to(m.lettere, { autoAlpha: 0, duration: 0.2, ease: 'none', onComplete: () => gsap.set(m.mezzo, { width: 0 }) });
return;
}
/* da "auto" a pixel, così la larghezza si può animare */
gsap.set(m.mezzo, { width: m.mezzo.getBoundingClientRect().width });
const su = m.lettere.filter((l) => gsap.getProperty(l, 'yPercent') < 109.5);
m.tl = gsap.timeline();
m.tl.to(gsap.utils.shuffle(su), { yPercent: 110, duration: SALE, ease: CURVA, stagger: SFASA_GIU }, 0);
m.tl.to(m.mezzo, { width: 0, duration: APRE, ease: CURVA }, su.length ? '>-0.3' : 0);
}
const marchi = [...document.querySelectorAll('[data-marchio]')].map(costruisci);
let ultimoDito = 'mouse';
marchi.forEach((m) => {
m.el.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') apri(m); });
m.el.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') chiudi(m); });
m.el.addEventListener('pointerdown', (e) => { ultimoDito = e.pointerType; });
m.el.addEventListener('focus', () => { if (m.el.matches(':focus-visible')) apri(m); });
m.el.addEventListener('blur', () => chiudi(m));
m.el.addEventListener('click', (e) => {
/* col mouse comanda il passaggio; il dito, la tastiera e il pilota automatico usano il clic */
if (e.detail > 0 && ultimoDito === 'mouse') return;
if (m.aperto) chiudi(m); else apri(m);
});
});
/* la prima volta che si vedono, i marchi si aprono e si richiudono da soli, per mostrare il gesto */
if (!RIDOTTO) {
const visti = new IntersectionObserver((voci) => {
voci.forEach((v) => {
if (!v.isIntersecting) return;
visti.unobserve(v.target);
const m = marchi.find((x) => x.el === v.target);
const dopo = m.el.classList.contains('marchio--grande') ? 0.7 : 1.1;
gsap.delayedCall(dopo, () => apri(m));
gsap.delayedCall(dopo + 2.6, () => { if (!m.el.matches(':hover, :focus-visible')) chiudi(m); });
});
}, { threshold: 0.6 });
document.fonts.ready.then(() => marchi.forEach((m) => visti.observe(m.el)));
}The script splits the word into monogram, middle letters and full stop. The middle letters sit in a flex container with overflow: clip, zero wide at rest: the full stop sits right after the monogram and follows the widening gap by itself.
The container’s width goes from zero to the sum of the letter masks in 0.7 seconds on power4.inOut. At the end it goes back to «auto», so if the window changes size the open word adapts; before closing it is set back to pixels so it can animate.
Each letter sits in a mask that clips only vertically and rises from yPercent 110 to 0 in 0.6 seconds, 60 milliseconds after the previous one, starting at 0.35 seconds. The order is reshuffled every time with gsap.utils.shuffle: the wordmark never opens the same way twice.
Every gesture kills the running sequence and builds a new one from where things are: if the gap is already open only the letters rise, without waiting; a letter already up does not start again. With a mouse hovering drives it, with a finger or the keyboard a click does.
| Name | File | Default | What it changes |
|---|---|---|---|
APRE | script.js | 0.7 | Seconds for the gap to widen or close. |
SALE | script.js | 0.6 | Seconds for each letter to rise or drop. |
SFASA_SU | script.js | 0.06 | Seconds between letters when they rise (when they drop it is SFASA_GIU, 0.08). |
RITARDO | script.js | 0.35 | How many seconds into the opening the letters start. |
For the header wordmark of a site with a strong monogram: at rest it takes little room, on hover it introduces itself in full. It also works large, as the title of the first screen.
Without hover the wordmark opens and closes on tap. The first time it comes into view it opens by itself and closes after a couple of seconds, to show it is there.
No rising letters and no accordion: the word appears and disappears with a two-tenths-of-a-second fade, and it does not open by itself.
GSAP 3.15.0, Mona Sans (font variabile) 5.3.0.
Weight: 8.2 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.