
Nastro che accelera con lo scorrimento
Scroll, Testo, Hover
A vita
Bottoni quadrati dove una pennellata d’inchiostro entra dal lato da cui arrivi ed esce da quello da cui te ne vai, e il testo cambia colore proprio sul bordo della pennellata.
Una cartella che funziona da sola: index.html, style.css, script.js e le librerie da 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());
});Dentro ogni bottone c’è una seconda copia, in position absolute sopra la prima, con i colori già inchiostrati e lo stesso testo nello stesso punto. Tagliando la copia si scopre l’inchiostro, e il testo di sotto e quello di sopra si incontrano sul taglio: il colore si spezza proprio lì, senza trucchi di fusione.
Il taglio è un clip-path polygon che va da var(--da) a var(--a), con nove punti per lato spostati di qualche pixel in avanti e indietro: il bordo sembra quello di un pennello. Da fermo tutti e due i bordi stanno fuori, al −12%; entrando da sinistra --a corre al 112%, entrando da destra --da corre al −12%.
Un solo ascoltatore di pointermove sulla finestra guarda quale bottone c’è sotto il puntatore: quando cambia, esce dal vecchio e entra nel nuovo, e il lato è la metà del bottone in cui sta il puntatore in quel momento. Uscendo a destra corre il bordo sinistro, uscendo a sinistra il destro.
GSAP anima le due variabili CSS: entrare dura 0,45 secondi in power3.out, uscire 0,38 in power2.inOut. Se la striscia non è vuota quando rientri, non riparte dal bordo: le due variabili ripartono da dove sono, e la pennellata torna indietro senza scatti.
| Nome | File | Di partenza | Cosa cambia |
|---|---|---|---|
ENTRA, ESCE | script.js | 0.45, 0.38 | I secondi per coprire e per scoprire il bottone. |
--inchiostro-fondo, --inchiostro-testo | style.css | per variante | I colori dell’inchiostro e del testo inchiostrato di ogni variante (pieno, vuoto, cartellino). |
clip-path | style.css | 9 punti per lato | Il bordo della pennellata: più punti e spostamenti più grandi, pennello più ruvido. |
Per i bottoni principali di un sito che vuole un tocco fatto a mano: botteghe, tipografie, ristoranti, studi. Funziona con qualunque testo e qualunque colore.
Toccando un bottone l’inchiostro entra dal lato del dito e dopo un attimo esce a destra: il gesto resta, anche senza il passaggio del mouse.
Niente pennellata che corre: l’inchiostro compare e sparisce sul posto con una dissolvenza di 150 millesimi.
GSAP 3.15.0, Mona Sans (font variabile) 5.3.0.
Peso: 11 KB dei nostri file.
Provata il 27 settembre 2026
Puoi usarla nei tuoi progetti e in quelli dei tuoi clienti. Non puoi rivenderla né ridistribuirla così com’è. GSAP resta sotto la sua licenza Standard. Licenza completa.
Con l’email e la password del tuo account.
Hai fatto l’accesso come .
Il tuo piano è gratis. Quando arriverà, l’accesso a vita si attiverà su questo stesso account.