
Marquee that speeds up with scroll
Scroll, Text, Hover
Lifetime
Square buttons where a brushstroke of ink enters from the side you come from and leaves by the side you go, and the text changes colour right on the brushstroke’s edge.
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>L’inchiostro sul bottone</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="banco">
<p class="nota" data-nota>Passa sopra i bottoni: l’inchiostro entra dal lato da cui arrivi ed esce da quello da cui te ne vai.</p>
<section class="invito">
<h1 class="invito__titolo">Stampiamo a mano, una pagina alla volta.</h1>
<p class="invito__testo">Carta di cotone, caratteri di piombo e un torchio del 1932. Raccontaci cosa ti serve.</p>
<div class="invito__bottoni">
<a class="bottone bottone--pieno bottone--grande" href="#" data-bottone><span class="bottone__testo">Chiedi un preventivo<svg class="bottone__freccia" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.5 8h10.5M9 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="bottone__inchiostro" aria-hidden="true"><span class="bottone__testo">Chiedi un preventivo<svg class="bottone__freccia" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.5 8h10.5M9 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></a>
<a class="bottone bottone--vuoto bottone--grande" href="#" data-bottone><span class="bottone__testo">Guarda i lavori</span><span class="bottone__inchiostro" aria-hidden="true"><span class="bottone__testo">Guarda i lavori</span></span></a>
</div>
<p class="invito__etichetta">Cosa stampiamo</p>
<div class="invito__cartellini">
<a class="bottone bottone--cartellino" href="#" data-bottone><span class="bottone__testo">Biglietti</span><span class="bottone__inchiostro" aria-hidden="true"><span class="bottone__testo">Biglietti</span></span></a><a class="bottone bottone--cartellino" href="#" data-bottone><span class="bottone__testo">Inviti</span><span class="bottone__inchiostro" aria-hidden="true"><span class="bottone__testo">Inviti</span></span></a><a class="bottone bottone--cartellino" href="#" data-bottone><span class="bottone__testo">Etichette</span><span class="bottone__inchiostro" aria-hidden="true"><span class="bottone__testo">Etichette</span></span></a><a class="bottone bottone--cartellino" href="#" data-bottone><span class="bottone__testo">Manifesti</span><span class="bottone__inchiostro" aria-hidden="true"><span class="bottone__testo">Manifesti</span></span></a><a class="bottone bottone--cartellino" href="#" data-bottone><span class="bottone__testo">Menù</span><span class="bottone__inchiostro" aria-hidden="true"><span class="bottone__testo">Menù</span></span></a>
</div>
</section>
</main>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="script.js"></script>
</body>
</html>/* L’inchiostro sul bottone */
:root {
--nebbia: #E6ECEF;
--inchiostro: #14212B;
--lampone: #D1495B;
--verde: #2E7D6B;
--carta: #F7F8F6;
--tenue: #4E5B64;
--margine: clamp(18px, 4vw, 56px);
--scorre: cubic-bezier(0.23, 1, 0.32, 1);
--alto: 100vh;
}
@supports (height: 100svh) {
:root { --alto: 100svh; }
}
* { box-sizing: border-box; }
html,
body {
margin: 0;
}
body {
background: var(--nebbia);
color: var(--inchiostro);
font-family: 'Mona Sans Variable', system-ui, sans-serif;
}
.banco {
position: relative;
display: grid;
place-items: center;
min-height: var(--alto);
padding: calc(var(--margine) * 2.6) var(--margine) calc(var(--margine) * 1.4);
}
.nota {
position: absolute;
top: var(--margine);
left: var(--margine);
max-width: 34ch;
margin: 0;
color: var(--tenue);
font-size: clamp(0.92rem, 0.84rem + 0.3vw, 1.08rem);
font-weight: 500;
line-height: 1.38;
}
.invito {
width: min(100%, 780px);
}
.invito__titolo {
max-width: 15ch;
margin: 0 0 1.1rem;
font-size: clamp(2.2rem, 1.2rem + 3.6vw, 4.4rem);
font-weight: 650;
letter-spacing: -0.035em;
line-height: 0.98;
}
.invito__testo {
max-width: 46ch;
margin: 0 0 2rem;
color: var(--tenue);
font-size: clamp(1rem, 0.95rem + 0.3vw, 1.2rem);
line-height: 1.5;
}
.invito__bottoni,
.invito__cartellini {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.invito__cartellini {
gap: 10px;
}
.invito__etichetta {
margin: 2.6rem 0 0.8rem;
color: var(--tenue);
font-size: 0.92rem;
font-weight: 600;
}
/* ---- il bottone: una base e, sopra, lo stesso bottone in inchiostro, tagliato a pennello ---- */
.bottone {
/* la striscia d’inchiostro va da --da ad --a: fuori dal bottone tutte e due, finché non ci passi sopra */
--da: -12%;
--a: -12%;
--fondo: transparent;
--testo: var(--inchiostro);
--bordo: var(--inchiostro);
--inchiostro-fondo: var(--inchiostro);
--inchiostro-testo: var(--carta);
position: relative;
display: inline-flex;
overflow: hidden;
border: 2px solid var(--bordo);
background: var(--fondo);
color: var(--testo);
font-size: 0.95rem;
font-weight: 620;
text-decoration: none;
transition: transform 160ms var(--scorre);
-webkit-tap-highlight-color: transparent;
}
.bottone:active {
transform: scale(0.97);
}
.bottone:focus-visible {
outline: 2px solid var(--inchiostro);
outline-offset: 4px;
}
.bottone__testo {
display: inline-flex;
align-items: center;
gap: 0.6em;
padding: 0.95em 1.35em 1em;
line-height: 1;
white-space: nowrap;
}
.bottone__freccia {
width: 1em;
height: 1em;
transition: transform 320ms var(--scorre);
}
/* l’inchiostro: stesso testo nello stesso punto, così il colore si spezza proprio sul bordo della pennellata */
.bottone__inchiostro {
position: absolute;
inset: 0;
display: flex;
background: var(--inchiostro-fondo);
color: var(--inchiostro-testo);
clip-path: polygon(
calc(var(--da) - 2px) 0%,
calc(var(--a) + 3px) 0%,
calc(var(--a) - 2px) 12.5%,
calc(var(--a) + 4px) 25%,
calc(var(--a) - 3px) 37.5%,
calc(var(--a) + 2px) 50%,
calc(var(--a) - 4px) 62.5%,
calc(var(--a) + 3px) 75%,
calc(var(--a) - 1px) 87.5%,
calc(var(--a) + 2px) 100%,
calc(var(--da) - 3px) 100%,
calc(var(--da) + 4px) 87.5%,
calc(var(--da) - 2px) 75%,
calc(var(--da) + 3px) 62.5%,
calc(var(--da) - 4px) 50%,
calc(var(--da) + 2px) 37.5%,
calc(var(--da) - 3px) 25%,
calc(var(--da) + 3px) 12.5%
);
pointer-events: none;
}
@media (hover: hover) and (pointer: fine) {
.bottone:hover .bottone__freccia {
transform: translateX(4px);
}
}
/* ---- le varianti ---- */
.bottone--grande {
font-size: clamp(1rem, 0.92rem + 0.35vw, 1.15rem);
}
.bottone--pieno {
--fondo: var(--inchiostro);
--testo: var(--carta);
--inchiostro-fondo: var(--lampone);
}
.bottone--cartellino {
--inchiostro-fondo: var(--verde);
border-width: 1.5px;
font-size: 0.88rem;
}
.bottone--cartellino .bottone__testo {
padding: 0.7em 1em 0.75em;
}
/* movimento ridotto: niente pennellata che corre, il colore cambia sul posto */
.bottone--ridotto .bottone__inchiostro {
clip-path: none;
opacity: 0;
}/* L’inchiostro sul bottone
Bottoni quadrati dove, al passaggio, una pennellata d’inchiostro attraversa il bottone: entra dal lato
da cui arriva il puntatore ed esce da quello da cui se ne va. Sopra la base c’è una copia del bottone
già inchiostrata, con lo stesso testo nello stesso punto, tagliata da un clip-path a bordi frastagliati:
così il colore del testo si spezza esattamente sul bordo della pennellata. */
/* ---- valori da cambiare ---- */
const ENTRA = 0.45; /* secondi per coprire il bottone */
const ESCE = 0.38; /* secondi per scoprirlo */
const FUORI = 12; /* di quanto (in %) la pennellata esce dal bottone, per nascondere il bordo frastagliato */
const RIDOTTO = matchMedia('(prefers-reduced-motion: reduce)').matches;
const bottoni = [...document.querySelectorAll('[data-bottone]')];
if (RIDOTTO) bottoni.forEach((b) => b.classList.add('bottone--ridotto'));
const SX = `${-FUORI}%`;
const DX = `${100 + FUORI}%`;
/* da che parte sta il puntatore rispetto al bottone: sinistra o destra della sua metà */
function lato(b, x) {
const r = b.getBoundingClientRect();
return x < r.left + r.width / 2 ? 'sx' : 'dx';
}
function inchiostro(b) {
return b.querySelector('.bottone__inchiostro');
}
/* la striscia è vuota quando i due bordi coincidono */
function vuota(b) {
const s = getComputedStyle(b);
return Math.abs(parseFloat(s.getPropertyValue('--a')) - parseFloat(s.getPropertyValue('--da'))) < 0.5;
}
function entra(b, verso) {
if (RIDOTTO) {
gsap.to(inchiostro(b), { opacity: 1, duration: 0.15, ease: 'none', overwrite: true });
return;
}
gsap.killTweensOf(b);
/* se è vuota, parte dal lato da cui arrivi; se sta ancora uscendo, torna indietro da dov’è */
if (vuota(b)) gsap.set(b, { '--da': verso === 'sx' ? SX : DX, '--a': verso === 'sx' ? SX : DX });
gsap.to(b, { '--da': SX, '--a': DX, duration: ENTRA, ease: 'power3.out' });
}
function esci(b, verso) {
if (RIDOTTO) {
gsap.to(inchiostro(b), { opacity: 0, duration: 0.15, ease: 'none', overwrite: true });
return;
}
gsap.killTweensOf(b);
/* esce dalla parte da cui te ne vai: se vai a destra corre il bordo sinistro, e viceversa */
if (verso === 'dx') gsap.to(b, { '--da': DX, '--a': DX, duration: ESCE, ease: 'power2.inOut' });
else gsap.to(b, { '--da': SX, '--a': SX, duration: ESCE, ease: 'power2.inOut' });
}
/* ---------------------------------------------------------------- il mouse: chi c’è sotto, e da che parte */
let sopra = null;
addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse') return;
const b = e.target instanceof Element ? e.target.closest('[data-bottone]') : null;
if (b === sopra) return;
if (sopra) esci(sopra, lato(sopra, e.clientX));
if (b) entra(b, lato(b, e.clientX));
sopra = b;
});
document.documentElement.addEventListener('mouseleave', (e) => {
if (!sopra) return;
esci(sopra, lato(sopra, e.clientX));
sopra = null;
});
/* ---------------------------------------------------------------- un invito: una pennellata lenta sul primo bottone,
finché nessuno è ancora passato sopra a niente */
let toccato = false;
addEventListener('pointerdown', () => { toccato = true; }, { once: true });
if (!RIDOTTO) {
const primo = bottoni[0];
gsap.delayedCall(2.1, () => {
if (sopra || toccato) return;
gsap.to(primo, { '--a': DX, duration: 0.9, ease: 'power2.inOut' });
gsap.delayedCall(1.5, () => { if (sopra !== primo) esci(primo, 'dx'); });
});
}
/* ---------------------------------------------------------------- il dito: entra dove tocchi, esce subito dopo */
bottoni.forEach((b) => {
b.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse') return;
entra(b, lato(b, e.clientX));
});
const via = (e) => {
if (e.pointerType === 'mouse') return;
setTimeout(() => esci(b, 'dx'), 220);
};
b.addEventListener('pointerup', via);
b.addEventListener('pointercancel', via);
/* con la tastiera: il fuoco inchiostra, andarsene scopre */
b.addEventListener('focus', () => { if (b.matches(':focus-visible')) entra(b, 'sx'); });
b.addEventListener('blur', () => { if (b !== sopra) esci(b, 'dx'); });
/* nella demo i link non portano da nessuna parte */
b.addEventListener('click', (e) => e.preventDefault());
});Inside each button there is a second copy, position absolute on top of the first, with the inked colours and the same text in the same place. Cutting the copy reveals the ink, and the text below and the text above meet on the cut: the colour splits right there, with no blending tricks.
The cut is a clip-path polygon going from var(--da) to var(--a), with nine points per side nudged a few pixels back and forth: the edge looks like a brush’s. At rest both edges sit outside, at −12%; entering from the left --a runs to 112%, entering from the right --da runs to −12%.
A single pointermove listener on the window checks which button is under the pointer: when it changes, it leaves the old one and enters the new one, and the side is the half of the button where the pointer is at that moment. Leaving to the right moves the left edge, leaving to the left moves the right one.
GSAP animates the two CSS variables: entering takes 0.45 seconds in power3.out, leaving 0.38 in power2.inOut. If the band is not empty when you come back, it does not restart from the edge: both variables go on from where they are, and the brushstroke turns back without jumps.
| Name | File | Default | What it changes |
|---|---|---|---|
ENTRA, ESCE | script.js | 0.45, 0.38 | The seconds to cover and to uncover the button. |
--inchiostro-fondo, --inchiostro-testo | style.css | per variante | The colours of the ink and of the inked text for each variant (filled, empty, tag). |
clip-path | style.css | 9 punti per lato | The brushstroke’s edge: more points and larger offsets, a rougher brush. |
For the main buttons of a site that wants a hand-made touch: workshops, print shops, restaurants, studios. It works with any text and any colour.
Tapping a button, the ink enters from the finger’s side and after a moment leaves to the right: the gesture remains, even without a hovering mouse.
No running brushstroke: the ink appears and disappears in place with a 150-millisecond fade.
GSAP 3.15.0, Mona Sans (font variabile) 5.3.0.
Weight: 11 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.