
Il tubo catodico
WebGL, Testo, Transizioni
NuovaGratis
Un velo arancio copre la pagina mentre il numero sale a cento, poi prende fuoco dal basso: un fronte frastagliato con la brace sul bordo, disegnato da uno shader.
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>Il velo che brucia</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>
<!-- la pagina vera, sotto il velo -->
<main class="pagina">
<p class="sopra">Benvenuto</p>
<h1 class="titolo">
<span class="riga"><span>Il sito compare</span></span>
<span class="riga"><span>dietro la carta</span></span>
<span class="riga"><span>che brucia.</span></span>
</h1>
<p class="sotto">Il velo è un solo triangolo a tutto schermo: il fronte del fuoco lo disegna lo shader, con un rumore frattale che sale dal basso.</p>
</main>
<!-- il velo: in WebGL se c'è, altrimenti un div che si ritira -->
<div class="velo" aria-hidden="true">
<canvas class="velo__tela"></canvas>
<div class="velo__piano"></div>
<div class="conta">
<p class="conta__numero"><span class="conta__cifre">000</span></p>
<p class="conta__sotto">caricamento</p>
</div>
</div>
<button type="button" class="rivedi" hidden>Rivedi il fuoco</button>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="script.js"></script>
</body>
</html>/* Il velo che brucia */
:root {
--velo: #ff8a3d; /* il colore del velo (va uguale anche in script.js) */
--inchiostro: #07201f;
--pagina: #072a29;
--testo: #eef3ef;
}
* { box-sizing: border-box; }
html,
body {
margin: 0;
height: 100%;
}
body {
font-family: 'Mona Sans Variable', system-ui, sans-serif;
background: var(--pagina);
color: var(--testo);
overflow: hidden;
}
.pagina {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 100vh;
min-height: 100dvh;
padding: clamp(24px, 6vw, 80px);
background:
radial-gradient(120% 70% at 80% 0%, rgba(58, 163, 155, 0.22), transparent 60%),
var(--pagina);
}
.sopra {
margin: 0 0 18px;
font-size: 14px;
font-weight: 620;
color: #86cfc4;
}
.titolo {
margin: 0;
font-size: clamp(42px, 8.4vw, 136px);
font-weight: 700;
font-stretch: 94%;
line-height: 0.94;
letter-spacing: -0.045em;
}
.riga {
display: block;
overflow: clip;
padding-bottom: 0.06em;
}
.riga > span {
display: block;
}
.sotto {
max-width: 44ch;
margin: 26px 0 0;
font-size: clamp(15px, 1.2vw, 18px);
line-height: 1.45;
color: #a9c4bf;
}
/* il velo copre tutto; la tela WebGL e il piano di riserva stanno uno sopra l'altro */
.velo {
position: fixed;
inset: 0;
z-index: 10;
}
.velo__tela,
.velo__piano {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.velo__tela {
display: block;
}
.velo__piano {
background: var(--velo);
}
.webgl .velo__piano {
display: none;
}
.conta {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
color: var(--inchiostro);
}
.conta__numero {
margin: 0;
overflow: clip;
font-size: clamp(96px, 22vw, 320px);
font-weight: 280;
font-stretch: 125%;
letter-spacing: -0.04em;
line-height: 0.9;
font-variant-numeric: tabular-nums;
}
.conta__cifre {
display: block;
}
.conta__sotto {
margin: 10px 0 0;
font-size: 13px;
font-weight: 640;
font-stretch: 120%;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.rivedi {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 20;
padding: 10px 16px;
border: 1px solid rgba(238, 243, 239, 0.25);
border-radius: 999px;
background: transparent;
color: var(--testo);
font: 600 14px/1 'Mona Sans Variable', system-ui, sans-serif;
cursor: pointer;
transition: transform 0.16s ease-out, border-color 0.2s ease;
}
.rivedi:hover {
border-color: var(--velo);
}
.rivedi:active {
transform: scale(0.97);
}
.fermo .velo {
display: none;
}/* Il velo che brucia
Un velo arancio copre la pagina mentre il numero sale a cento. Poi il velo prende fuoco dal basso.
Il velo è un solo triangolo a tutto schermo in WebGL: nello shader un rumore frattale deforma la
linea del fronte, che sale con uFronte da 0 a 1. Sopra il fronte il velo resta; appena prima diventa
scuro (la bruciatura), sul bordo c'è la brace, sotto il velo non c'è più e si vede la pagina.
Senza WebGL il velo è un div che si ritira verso l'alto. */
/* ---- valori da cambiare ---- */
const CONTEGGIO = 2.2; /* secondi per arrivare a cento */
const FUOCO = 2.6; /* secondi perché il fronte attraversi lo schermo */
const VELO = [1.0, 0.541, 0.239]; /* il colore del velo in RGB da 0 a 1 (uguale a --velo) */
const BRACE = [1.0, 0.36, 0.08]; /* il colore della brace */
const ONDE = 3.0; /* quanto è frastagliato il fronte: più alto, più fiammelle */
const RIDOTTO = matchMedia('(prefers-reduced-motion: reduce)').matches;
const velo = document.querySelector('.velo');
const tela = document.querySelector('.velo__tela');
const piano = document.querySelector('.velo__piano');
const conta = document.querySelector('.conta');
const cifre = document.querySelector('.conta__cifre');
const righe = document.querySelectorAll('.riga > span');
const altri = document.querySelectorAll('.sopra, .sotto');
const rivedi = document.querySelector('.rivedi');
const VERT = `
attribute vec2 p;
varying vec2 vUv;
void main() {
vUv = p * 0.5 + 0.5;
gl_Position = vec4(p, 0.0, 1.0);
}`;
const FRAG = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform vec2 uRis;
uniform float uT;
uniform float uFronte;
uniform float uOnde;
uniform vec3 uVelo;
uniform vec3 uBrace;
varying vec2 vUv;
float caso(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
float rumore(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(caso(i), caso(i + vec2(1.0, 0.0)), u.x), mix(caso(i + vec2(0.0, 1.0)), caso(i + vec2(1.0, 1.0)), u.x), u.y);
}
float frattale(vec2 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 5; i++) { v += a * rumore(p); p *= 2.03; a *= 0.5; }
return v;
}
void main() {
vec2 q = vec2(vUv.x * uRis.x / uRis.y, vUv.y);
/* le fiammelle salgono piano: il rumore scorre verso l'alto col tempo */
float n = frattale(q * uOnde + vec2(0.0, -uT * 0.35));
/* il fronte parte sotto lo schermo e finisce sopra, così all'inizio e alla fine non si vede */
float fronte = mix(-0.4, 1.4, uFronte);
float d = vUv.y + (n - 0.5) * 0.55 - fronte;
if (d < 0.0) {
/* già bruciato: resta solo un alone della brace sulla pagina, appena sotto il bordo */
float alone = smoothstep(-0.07, 0.0, d) * 0.55;
gl_FragColor = vec4(uBrace * alone, alone);
return;
}
float tremolio = 0.85 + 0.15 * sin(uT * 18.0 + n * 12.0);
float brace = smoothstep(0.05, 0.0, d) * tremolio;
float nucleo = smoothstep(0.014, 0.0, d);
float bruciatura = smoothstep(0.16, 0.035, d);
vec3 col = uVelo * (0.95 + 0.05 * caso(floor(gl_FragCoord.xy)));
col = mix(col, vec3(0.1, 0.045, 0.02), bruciatura * 0.88);
col = mix(col, uBrace, brace);
col = mix(col, vec3(1.0, 0.93, 0.72), nucleo);
/* bordo morbido di un pixel o due, niente scalini */
float a = smoothstep(0.0, 0.004, d);
gl_FragColor = vec4(col * a, a);
}`;
/* ---------------------------------------------------------------- WebGL, se c'è */
function creaVelo() {
const gl = tela.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false });
if (!gl) return null;
const compila = (tipo, src) => {
const s = gl.createShader(tipo);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
};
const prog = gl.createProgram();
gl.attachShader(prog, compila(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, compila(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(prog);
gl.useProgram(prog);
/* un triangolo più grande dello schermo: copre tutto con tre vertici */
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const p = gl.getAttribLocation(prog, 'p');
gl.enableVertexAttribArray(p);
gl.vertexAttribPointer(p, 2, gl.FLOAT, false, 0, 0);
const U = Object.fromEntries(['uRis', 'uT', 'uFronte', 'uOnde', 'uVelo', 'uBrace'].map((n) => [n, gl.getUniformLocation(prog, n)]));
gl.uniform3fv(U.uVelo, VELO);
gl.uniform3fv(U.uBrace, BRACE);
gl.uniform1f(U.uOnde, ONDE);
const dimensiona = () => {
const k = Math.min(devicePixelRatio || 1, 1.5);
tela.width = Math.round(innerWidth * k);
tela.height = Math.round(innerHeight * k);
gl.viewport(0, 0, tela.width, tela.height);
gl.uniform2f(U.uRis, tela.width, tela.height);
};
dimensiona();
addEventListener('resize', dimensiona);
return (fronte, t) => {
gl.uniform1f(U.uFronte, fronte);
gl.uniform1f(U.uT, t);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
}
let disegna = null;
try { disegna = creaVelo(); } catch (e) { disegna = null; }
if (disegna) document.documentElement.classList.add('webgl');
/* ---------------------------------------------------------------- la sequenza */
const stato = { fronte: 0 };
let tl = null;
let orologio = 0;
const passo = (_, dt) => {
orologio += dt / 1000;
if (disegna) disegna(stato.fronte, orologio);
};
function azzera() {
if (tl) tl.kill();
stato.fronte = 0;
cifre.textContent = '0';
velo.style.visibility = 'visible';
gsap.set(conta, { autoAlpha: 1, scale: 1 });
gsap.set(piano, { clipPath: 'inset(0% 0% 0% 0%)' });
gsap.set(righe, { yPercent: 105 });
gsap.set(altri, { autoAlpha: 0, y: 16 });
rivedi.hidden = true;
rivedi.removeAttribute('data-autoclic');
}
function sequenza() {
azzera();
gsap.ticker.add(passo);
const s = { n: 0 };
const fuoco = CONTEGGIO + 0.35;
tl = gsap.timeline();
tl.fromTo(cifre, { yPercent: 100 }, { yPercent: 0, duration: 0.8, ease: 'expo.out' }, 0)
.to(s, { n: 100, duration: CONTEGGIO, ease: 'power2.inOut', onUpdate: () => { cifre.textContent = Math.round(s.n); } }, 0)
.to(conta, { autoAlpha: 0, scale: 0.96, duration: 0.5, ease: 'power2.in' }, CONTEGGIO + 0.1)
.to(stato, { fronte: 1, duration: FUOCO, ease: 'power1.inOut' }, fuoco);
/* senza WebGL il piano si ritira verso l'alto con lo stesso tempo */
if (!disegna) tl.to(piano, { clipPath: 'inset(0% 0% 100% 0%)', duration: FUOCO, ease: 'power1.inOut' }, fuoco);
tl.to(righe, { yPercent: 0, duration: 1.1, ease: 'expo.out', stagger: 0.09 }, fuoco + FUOCO * 0.5)
.to(altri, { autoAlpha: 1, y: 0, duration: 0.9, ease: 'expo.out', stagger: 0.1 }, fuoco + FUOCO * 0.6)
.add(() => {
/* finito il fuoco il velo si toglie di mezzo e la tela smette di disegnare */
velo.style.visibility = 'hidden';
gsap.ticker.remove(passo);
rivedi.hidden = false;
gsap.fromTo(rivedi, { autoAlpha: 0, y: 12 }, { autoAlpha: 1, y: 0, duration: 0.5, ease: 'expo.out' });
rivedi.setAttribute('data-autoclic', '');
}, fuoco + FUOCO + 0.1);
}
if (RIDOTTO) {
document.documentElement.classList.add('fermo');
} else {
rivedi.addEventListener('click', sequenza);
sequenza();
}Il velo è una tela WebGL con un solo triangolo più grande dello schermo (vertici -1,-1 / 3,-1 / -1,3): tre vertici e nessuna libreria. Tutto il lavoro lo fa il fragment shader, un pixel alla volta.
Un rumore frattale (cinque ottave di value noise) sposta la linea del fronte: d = y + (rumore - 0.5) * 0.55 - fronte. Dove d è negativo il velo è bruciato e il pixel è trasparente. Il rumore scorre verso l'alto col tempo: le fiammelle salgono.
Tre smoothstep sulla stessa distanza d: una fascia larga che scurisce il velo, una più stretta del colore della brace che trema col tempo, una sottilissima quasi bianca. Sotto il bordo un alone arancio si posa sulla pagina.
Una timeline porta il numero a cento e poi uFronte da 0 a 1; il ticker di GSAP disegna la tela solo finché serve. Senza WebGL lo stesso tempo ritira un div con clip-path.
| Nome | File | Di partenza | Cosa cambia |
|---|---|---|---|
FUOCO | script.js | 2.6 | Secondi perché il fronte attraversi lo schermo. Sotto i due secondi sembra un taglio, non un fuoco. |
ONDE | script.js | 3.0 | Quanto è frastagliato il fronte: più alto, più fiammelle piccole. |
VELO / BRACE | script.js | arancio / rosso fuoco | I colori del velo e della brace, in RGB da 0 a 1. Il velo va uguale a --velo nel CSS, che serve senza WebGL. |
CONTEGGIO | script.js | 2.2 | Secondi del numero che sale a cento. |
Per l'ingresso di un sito che vuole un momento teatrale alla prima visita: un evento, un'etichetta, un portfolio. Una volta sola, poi ricordati che l'hai già fatto vedere.
Funziona uguale; la tela si disegna al massimo a 1,5 volte i pixel dello schermo, così anche i telefoni meno potenti tengono il passo.
Niente velo e niente numero: la pagina è subito lì.
GSAP 3.15.0, Mona Sans (font variabile) 5.3.0.
Peso: 11 KB dei nostri file.
Provata il 26 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.