
The cathode-ray tube
WebGL, Text, Transitions
NewFree
An orange veil covers the page while the number climbs to a hundred, then it catches fire from the bottom: a ragged front with embers on the edge, drawn by a shader.
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 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();
}The veil is a WebGL canvas with a single triangle bigger than the screen (vertices -1,-1 / 3,-1 / -1,3): three vertices and no library. All the work is done by the fragment shader, one pixel at a time.
A fractal noise (five octaves of value noise) moves the front line: d = y + (noise - 0.5) * 0.55 - front. Where d is negative the veil is burnt and the pixel is transparent. The noise scrolls upwards over time: the little flames rise.
Three smoothsteps on the same distance d: a wide band that darkens the veil, a narrower one in the ember colour that flickers over time, a very thin almost white one. Below the edge an orange glow settles on the page.
A timeline takes the number to a hundred and then uFronte from 0 to 1; GSAP's ticker draws the canvas only while needed. Without WebGL the same timing pulls back a div with clip-path.
| Name | File | Default | What it changes |
|---|---|---|---|
FUOCO | script.js | 2.6 | Seconds for the front to cross the screen. Under two seconds it looks like a cut, not a fire. |
ONDE | script.js | 3.0 | How ragged the front is: higher, more small flames. |
VELO / BRACE | script.js | arancio / rosso fuoco | The colours of the veil and the ember, in RGB from 0 to 1. The veil must match --velo in the CSS, used without WebGL. |
CONTEGGIO | script.js | 2.2 | Seconds for the number to climb to a hundred. |
For the entrance of a site that wants a theatrical moment on the first visit: an event, a label, a portfolio. Once only, then remember you have already shown it.
It works the same; the canvas is drawn at most at 1.5 times the screen's pixels, so even weaker phones keep up.
No veil and no number: the page is there straight away.
GSAP 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.