
The loader that becomes a logo
Transitions, SVG, Text
NewFree
An old computer desktop behind a television glass: scanlines, a band of light, flicker, text that types in steps and a switch that turns the tube off into a line and back on with another phosphor.
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 tubo catodico</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/vt323@5.2.7/index.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="tubo" data-tubo data-tema="verde">
<!-- tutto quello che sta sul vetro: si spegne e si riaccende come una televisione vecchia -->
<div class="schermo" data-schermo>
<header class="barra">
<span class="barra__marchio">Bottega</span>
<span class="barra__voci" aria-hidden="true"><span>Archivio</span><span>Vista</span><span>Aiuto</span></span>
<span class="barra__ora" data-ora></span>
</header>
<section class="scrivania">
<h1 class="titolo" data-titolo>Bottega di luce</h1>
<p class="testo" data-righe>Tre file e un tubo che ronza. Apri un’icona, oppure spegni e riaccendi: il tubo cambia colore.</p>
<div class="icone">
<button class="icona" type="button" data-apri="lettera" data-autoclic>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 1h7l3 3v11H3z" /><path class="icona__segni" d="M5 6h6M5 8h6M5 10h6M5 12h4" /></svg>
<span>lettera.txt</span>
</button>
<button class="icona" type="button" data-apri="ricetta">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3h12v10H2z" /><path class="icona__segni" d="M4 6h3M4 8h8M4 10h8" /></svg>
<span>ricetta.txt</span>
</button>
<button class="icona" type="button" data-apri="diario">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 1h10v14H3z" /><path class="icona__segni" d="M5 1v14M7 5h4M7 7h4" /></svg>
<span>diario.log</span>
</button>
</div>
</section>
<section class="finestra" data-finestra hidden role="dialog" aria-labelledby="finestra-titolo">
<header class="finestra__barra">
<span id="finestra-titolo" data-finestra-titolo></span>
<button class="finestra__chiudi" type="button" data-chiudi aria-label="Chiudi la finestra">
<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" /></svg>
</button>
</header>
<div class="finestra__corpo" data-finestra-corpo></div>
</section>
<footer class="comandi">
<button class="interruttore" type="button" data-spegni>Spegni e riaccendi</button>
<div class="temi" role="group" aria-label="Colore del fosforo">
<button class="tema" type="button" data-scegli="verde" aria-pressed="true" aria-label="Verde"></button>
<button class="tema" type="button" data-scegli="ghiaccio" aria-pressed="false" aria-label="Ghiaccio"></button>
<button class="tema" type="button" data-scegli="rosa" aria-pressed="false" aria-label="Rosa"></button>
</div>
</footer>
<div class="lampo" data-lampo aria-hidden="true"></div>
</div>
<!-- il vetro: righe, griglia, banda che scende, vignetta e cornice, disegnati da uno shader -->
<canvas class="velo" data-velo aria-hidden="true"></canvas>
<!-- i tre rettangoli che annunciano una finestra, come sui computer di una volta -->
<div class="zoom" data-zoom aria-hidden="true"><i></i><i></i><i></i></div>
</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 tubo catodico */
:root {
--alto: 100vh;
}
@supports (height: 100svh) {
:root { --alto: 100svh; }
}
/* tre fosfori: carta (il vetro spento), inchiostro (il testo), tenue, luce (i titoli), caldo (le lettere appena scritte) */
.tubo[data-tema='verde'] { --carta: #041409; --inchiostro: #43FF7F; --tenue: #1F8E47; --luce: #D9FFE5; --caldo: #FFFFFF; }
.tubo[data-tema='ghiaccio'] { --carta: #070B13; --inchiostro: #A9CCFF; --tenue: #4D6893; --luce: #F1F6FF; --caldo: #6FC1FF; }
.tubo[data-tema='rosa'] { --carta: #15060F; --inchiostro: #FF6FBF; --tenue: #93306A; --luce: #FFE3F2; --caldo: #FFD166; }
* { box-sizing: border-box; }
html,
body {
margin: 0;
height: 100%;
}
body {
font-family: 'VT323', ui-monospace, monospace;
background: #030303;
overflow: hidden;
}
button {
font: inherit;
-webkit-tap-highlight-color: transparent;
}
.tubo {
--bordo: clamp(30px, 4.2vw, 58px);
position: relative;
height: var(--alto);
overflow: hidden;
background: #020202;
color: var(--inchiostro);
}
/* ------------------------------------------------------------------ quello che sta sul vetro */
.schermo {
position: absolute;
inset: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
padding: calc(var(--bordo) + 4px) calc(var(--bordo) + 12px);
background: var(--carta);
transform-origin: 50% 50%;
text-shadow: 0 0 0.35em color-mix(in srgb, var(--inchiostro) 55%, transparent);
}
.barra {
display: flex;
align-items: center;
gap: 22px;
padding-bottom: 8px;
border-bottom: 2px solid var(--tenue);
font-size: clamp(1.25rem, 1rem + 0.6vw, 1.6rem);
}
.barra__marchio {
color: var(--luce);
}
.barra__voci {
display: flex;
gap: 18px;
color: var(--tenue);
}
.barra__ora {
margin-left: auto;
}
.scrivania {
align-self: center;
display: grid;
gap: clamp(14px, 2.4vh, 28px);
}
.titolo {
margin: 0;
color: var(--luce);
font-size: clamp(3rem, 1rem + 7vw, 7.6rem);
font-weight: 400;
line-height: 0.9;
}
.titolo .parola {
display: inline-block;
white-space: nowrap;
}
.titolo .c {
display: inline-block;
}
.titolo .c.nascosta {
visibility: hidden;
}
/* la testa che scrive: due lettere più accese */
.titolo .c.calda {
color: var(--caldo);
text-shadow: 0 0 0.3em var(--caldo);
}
.testo {
position: relative;
max-width: 44ch;
margin: 0;
font-size: clamp(1.35rem, 1.1rem + 0.8vw, 1.9rem);
line-height: 1.15;
}
/* mentre scrive: la maschera scopre le righe finite e, nella riga in corso, fino al cursore */
.scrive {
-webkit-mask:
linear-gradient(#000, #000) 0 0 / 100% var(--fatte, 0px) no-repeat,
linear-gradient(#000, #000) 0 var(--fatte, 0px) / var(--x, 0px) var(--riga, 1em) no-repeat;
mask:
linear-gradient(#000, #000) 0 0 / 100% var(--fatte, 0px) no-repeat,
linear-gradient(#000, #000) 0 var(--fatte, 0px) / var(--x, 0px) var(--riga, 1em) no-repeat;
}
.cursore {
position: absolute;
z-index: 2;
display: block;
background: var(--inchiostro);
box-shadow: 0 0 0.4em var(--inchiostro);
}
.icone {
display: flex;
flex-wrap: wrap;
gap: clamp(14px, 3vw, 40px);
}
.icona {
display: grid;
justify-items: center;
gap: 6px;
min-width: 96px;
padding: 8px 4px;
border: 0;
background: none;
color: var(--inchiostro);
font-size: 1.35rem;
text-shadow: inherit;
cursor: pointer;
}
.icona svg {
width: 56px;
height: 56px;
fill: none;
stroke: currentColor;
stroke-width: 1;
shape-rendering: crispEdges;
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--inchiostro) 60%, transparent));
}
.icona span {
padding: 0 4px;
transition: background-color 80ms steps(2), color 80ms steps(2);
}
/* il nome si inverte a scatti, come la selezione di un sistema vecchio */
.icona:focus-visible span,
.icona[aria-expanded='true'] span {
background: var(--inchiostro);
color: var(--carta);
text-shadow: none;
}
@media (hover: hover) and (pointer: fine) {
.icona:hover span {
background: var(--inchiostro);
color: var(--carta);
text-shadow: none;
}
}
.icona:focus-visible {
outline: 2px dashed var(--luce);
outline-offset: 2px;
}
/* ------------------------------------------------------------------ la finestra */
.finestra {
position: absolute;
left: 50%;
top: 52%;
z-index: 3;
width: min(560px, calc(100% - var(--bordo) * 2 - 24px));
translate: -50% -50%;
border: 3px solid var(--inchiostro);
background: var(--carta);
box-shadow: 6px 6px 0 var(--tenue);
}
.finestra[hidden] {
display: none;
}
.finestra__barra {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 4px 6px 4px 12px;
background: var(--inchiostro);
color: var(--carta);
text-shadow: none;
font-size: 1.4rem;
}
.finestra__chiudi {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border: 2px solid var(--carta);
background: none;
color: var(--carta);
cursor: pointer;
}
.finestra__chiudi svg {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 2;
}
.finestra__chiudi:focus-visible {
outline: 2px dashed var(--carta);
outline-offset: 2px;
}
.finestra__corpo {
position: relative;
padding: 14px 18px 18px;
font-size: clamp(1.3rem, 1.05rem + 0.6vw, 1.65rem);
line-height: 1.15;
}
.finestra__corpo p {
margin: 0 0 0.5em;
}
.finestra__corpo p:last-child {
margin: 0;
}
/* ------------------------------------------------------------------ i comandi */
.comandi {
display: flex;
align-items: center;
gap: 18px;
padding-top: 10px;
border-top: 2px solid var(--tenue);
}
.interruttore {
min-height: 44px;
padding: 4px 14px;
border: 2px solid var(--inchiostro);
background: none;
color: var(--inchiostro);
font-size: 1.4rem;
text-shadow: inherit;
box-shadow: 3px 3px 0 var(--tenue);
cursor: pointer;
transition: transform 80ms steps(2), box-shadow 80ms steps(2);
}
.interruttore:active {
transform: translate(3px, 3px);
box-shadow: 0 0 0 var(--tenue);
}
.interruttore:focus-visible {
outline: 2px dashed var(--luce);
outline-offset: 3px;
}
.temi {
display: flex;
gap: 8px;
margin-left: auto;
}
.tema {
width: 44px;
height: 44px;
padding: 8px;
border: 0;
background: none;
cursor: pointer;
}
.tema::before {
content: '';
display: block;
width: 100%;
height: 100%;
border: 2px solid var(--tenue);
background: var(--c);
}
.tema[data-scegli='verde'] { --c: #43FF7F; }
.tema[data-scegli='ghiaccio'] { --c: #A9CCFF; }
.tema[data-scegli='rosa'] { --c: #FF6FBF; }
.tema[aria-pressed='true']::before {
border-color: var(--luce);
box-shadow: 0 0 0 2px var(--carta), 0 0 0 4px var(--luce);
}
.tema:focus-visible {
outline: 2px dashed var(--luce);
}
/* la riga di luce che si allarga quando il tubo si riaccende */
.lampo {
position: absolute;
inset: 0;
z-index: 5;
background: linear-gradient(180deg, var(--inchiostro), var(--luce) 50%, var(--inchiostro));
opacity: 0;
pointer-events: none;
}
/* ------------------------------------------------------------------ il vetro e i rettangoli */
.velo {
position: absolute;
inset: 0;
z-index: 10;
width: 100%;
height: 100%;
pointer-events: none;
}
/* senza WebGL: righe e cornice in CSS */
.senza-velo::after {
content: '';
position: absolute;
inset: 0;
z-index: 10;
border-radius: 24px;
background: repeating-linear-gradient(180deg, transparent 0 2px, rgba(0, 0, 0, 0.2) 2px 3px);
box-shadow: inset 0 0 0 14px #0a0a0a, inset 0 0 60px rgba(0, 0, 0, 0.6);
pointer-events: none;
}
.zoom {
position: absolute;
inset: 0;
z-index: 6;
pointer-events: none;
}
.zoom i {
position: absolute;
display: none;
border: 2px solid var(--luce);
}
.zoom i:nth-child(2) {
border-color: var(--inchiostro);
}
.zoom i:nth-child(3) {
border-color: var(--tenue);
}
@media (max-width: 640px) {
.tubo {
--bordo: 22px;
}
.barra__voci {
display: none;
}
.titolo {
font-size: clamp(2.8rem, 1rem + 9vw, 4rem);
}
.icona {
min-width: 80px;
}
.interruttore {
font-size: 1.25rem;
}
}/* Il tubo catodico
Una piccola scrivania da computer di una volta vista dentro un televisore. Sopra c'è un vetro disegnato
da uno shader: righe orizzontali ogni tre pixel, una griglia verticale ogni quattro, una banda di
luce che scende ogni nove secondi, grana, sfarfallio, vignetta e la cornice con gli angoli tondi.
Sotto, i testi si muovono a scatti, trenta volte al secondo: il titolo si scrive con la testa più
accesa, le righe si scoprono dietro un cursore a blocco, le finestre si annunciano con tre rettangoli
e compaiono attraverso un retino. Il tasto spegne il tubo come una televisione vecchia (una riga,
poi un punto) e lo riaccende con un altro fosforo. */
/* ---- valori da cambiare ---- */
const TEMI = ['verde', 'ghiaccio', 'rosa'];
const SCATTI = 30; /* i testi si muovono a scatti, tante volte al secondo */
const SPEGNE = 0.44; /* secondi dello spegnimento */
const ACCENDE = 0.78; /* secondi dell'accensione */
const FILE = {
lettera: ['lettera.txt', ['Cara bottega,', 'ho trovato il tuo indirizzo in fondo a un cassetto, scritto a matita.', 'Passo domani, se il tubo è ancora acceso.']],
ricetta: ['ricetta.txt', ['Pane di fosforo.', 'Farina, acqua e un pizzico di luce verde.', 'Cuoce a trenta fotogrammi al secondo.']],
diario: ['diario.log', ['Mattina: il tubo si scalda in otto secondi.', 'Pomeriggio: una riga di luce scende piano.', 'Sera: spento. Resta un punto, poi più niente.']],
};
const RIDOTTO = matchMedia('(prefers-reduced-motion: reduce)').matches;
const TOCCO = matchMedia('(hover: none), (pointer: coarse)').matches;
const tubo = document.querySelector('[data-tubo]');
const schermo = document.querySelector('[data-schermo]');
const velo = document.querySelector('[data-velo]');
const lampo = document.querySelector('[data-lampo]');
const titolo = document.querySelector('[data-titolo]');
const righe = document.querySelector('[data-righe]');
const finestra = document.querySelector('[data-finestra]');
const titoloFinestra = document.querySelector('[data-finestra-titolo]');
const corpo = document.querySelector('[data-finestra-corpo]');
const rettangoli = [...document.querySelectorAll('[data-zoom] i')];
const orologio = document.querySelector('[data-ora]');
/* ---------------------------------------------------------------- l'orologio a scatti (sul tempo delle animazioni) */
const compiti = new Set();
let accumulo = 0;
let primaScatto = 0;
let scattiVivi = false;
function scatta(ora) {
accumulo = Math.min(accumulo + (ora - primaScatto), 200);
primaScatto = ora;
while (accumulo >= 1000 / SCATTI) {
accumulo -= 1000 / SCATTI;
for (const f of [...compiti]) if (f() === false) compiti.delete(f);
}
if (compiti.size) requestAnimationFrame(scatta);
else { scattiVivi = false; accumulo = 0; }
}
/* un compito gira a ogni scatto finché non risponde false */
function ogniScatto(f) {
compiti.add(f);
if (scattiVivi) return;
scattiVivi = true;
primaScatto = performance.now();
requestAnimationFrame(scatta);
}
/* ---------------------------------------------------------------- il titolo che si scrive con la testa accesa */
function preparaTitolo() {
const testo = titolo.textContent.trim();
titolo.setAttribute('aria-label', testo);
titolo.innerHTML = testo.split(' ').map((p) => `<span class="parola" aria-hidden="true">${[...p].map((c) => `<span class="c">${c}</span>`).join('')}</span>`).join(' ');
/* nascosti fin da subito, così non si vedono interi prima di scriversi */
if (!RIDOTTO) {
titolo.querySelectorAll('.c').forEach((x) => x.classList.add('nascosta'));
righe.classList.add('scrive');
}
}
function scriviTitolo(poi) {
const c = [...titolo.querySelectorAll('.c')];
if (RIDOTTO) { c.forEach((x) => x.classList.remove('nascosta', 'calda')); if (poi) poi(); return; }
c.forEach((x) => { x.classList.add('nascosta'); x.classList.remove('calda'); });
const passi = Math.max(2, Math.round(Math.min(620, Math.max(180, c.length * 22)) / (1000 / SCATTI)));
let f = 0;
ogniScatto(() => {
f += 1;
const k = Math.ceil((f / passi) * c.length);
c.forEach((x, i) => {
if (i >= k) return;
if (x.classList.contains('nascosta')) {
x.classList.replace('nascosta', 'calda');
x.dataset.scatto = f;
} else if (x.classList.contains('calda') && f - x.dataset.scatto >= 2) {
x.classList.remove('calda');
}
});
if (f < passi + 2) return true;
c.forEach((x) => x.classList.remove('calda'));
if (poi) poi();
return false;
});
}
/* ---------------------------------------------------------------- le righe che si scoprono dietro il cursore */
function misuraRighe(el) {
const box = el.getBoundingClientRect();
const r = document.createRange();
r.selectNodeContents(el);
const lh = parseFloat(getComputedStyle(el).lineHeight) || 20;
const trovate = [];
for (const q of r.getClientRects()) {
if (q.width < 1) continue;
const top = q.top - box.top;
const riga = trovate.find((x) => Math.abs(x.top - top) < lh * 0.5);
if (riga) { riga.left = Math.min(riga.left, q.left - box.left); riga.right = Math.max(riga.right, q.right - box.left); } else trovate.push({ top, left: q.left - box.left, right: q.right - box.left });
}
return { righe: trovate.sort((a, b) => a.top - b.top), lh };
}
function scriviRighe(el, poi) {
if (RIDOTTO) { if (poi) poi(); return; }
const { righe: rr, lh } = misuraRighe(el);
if (!rr.length) { if (poi) poi(); return; }
const cursore = document.createElement('i');
cursore.className = 'cursore';
cursore.setAttribute('aria-hidden', 'true');
el.appendChild(cursore);
el.classList.add('scrive');
const totale = rr.reduce((s, x) => s + (x.right - x.left), 0);
const passi = Math.max(2, Math.round(Math.min(720, 110 + rr.length * 95) / (1000 / SCATTI)));
let f = 0;
const metti = (p) => {
let resto = p * totale;
let i = 0;
while (i < rr.length - 1 && resto > rr[i].right - rr[i].left) { resto -= rr[i].right - rr[i].left; i += 1; }
const x = Math.min(rr[i].right, rr[i].left + resto);
el.style.setProperty('--fatte', `${rr[i].top}px`);
el.style.setProperty('--x', `${x}px`);
el.style.setProperty('--riga', `${lh}px`);
Object.assign(cursore.style, { left: `${x}px`, top: `${rr[i].top + lh * 0.14}px`, width: `${lh * 0.42}px`, height: `${lh * 0.72}px` });
};
metti(0);
ogniScatto(() => {
f += 1;
metti(Math.min(1, f / passi));
if (f < passi + 3) return true;
el.classList.remove('scrive');
cursore.remove();
if (poi) poi();
return false;
});
}
/* ---------------------------------------------------------------- il retino: la finestra compare attraverso un dither */
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
const RETINI = [0.2, 0.4, 0.6, 0.8].map((q) => {
const c = document.createElement('canvas');
c.width = 12;
c.height = 12;
const x = c.getContext('2d');
x.fillStyle = '#000';
for (let j = 0; j < 4; j++) for (let i = 0; i < 4; i++) if ((BAYER[j * 4 + i] + 0.5) / 16 < q) x.fillRect(i * 3, j * 3, 3, 3);
return `url(${c.toDataURL()})`;
});
function retino(el, poi) {
if (RIDOTTO) { if (poi) poi(); return; }
let f = 0;
const metti = (m) => { el.style.webkitMaskImage = m; el.style.maskImage = m; el.style.webkitMaskSize = '12px 12px'; el.style.maskSize = '12px 12px'; };
metti(RETINI[0]);
ogniScatto(() => {
f += 1;
const livello = Math.floor(f / 2);
if (livello < RETINI.length) { metti(RETINI[livello]); return true; }
metti('');
if (poi) poi();
return false;
});
}
/* ---------------------------------------------------------------- i tre rettangoli che annunciano la finestra */
function zoom(da, a, poi) {
if (RIDOTTO) { poi(); return; }
const base = tubo.getBoundingClientRect();
const c0 = [da.left + da.width / 2 - base.left, da.top + da.height / 2 - base.top];
const c1 = [a.left + a.width / 2 - base.left, a.top + a.height / 2 - base.top];
const PASSI = 5;
let f = 0;
ogniScatto(() => {
f += 1;
rettangoli.forEach((r, k) => {
const s = f - k;
if (s < 1 || s > PASSI) { r.style.display = 'none'; return; }
const t = 1 - (1 - s / PASSI) ** 2;
const w = a.width * (0.12 + 0.88 * t);
const h = a.height * (0.12 + 0.88 * t);
const x = c0[0] + (c1[0] - c0[0]) * t;
const y = c0[1] + (c1[1] - c0[1]) * t;
Object.assign(r.style, { display: 'block', left: `${x - w / 2}px`, top: `${y - h / 2}px`, width: `${w}px`, height: `${h}px` });
});
if (f < PASSI + rettangoli.length - 1) return true;
rettangoli.forEach((r) => { r.style.display = 'none'; });
poi();
return false;
});
}
/* ---------------------------------------------------------------- aprire e chiudere i file */
let aperto = null;
let occupato = false;
function apri(nome, icona) {
if (occupato) return;
if (aperto === nome) { chiudi(); return; }
const [nomeFile, testo] = FILE[nome];
titoloFinestra.textContent = nomeFile;
corpo.innerHTML = testo.map((r) => `<p>${r}</p>`).join('');
finestra.hidden = false;
finestra.style.visibility = 'hidden';
const a = finestra.getBoundingClientRect();
finestra.style.visibility = '';
finestra.hidden = true;
document.querySelectorAll('[data-apri]').forEach((b) => b.setAttribute('aria-expanded', String(b === icona)));
aperto = nome;
occupato = true;
zoom(icona.getBoundingClientRect(), a, () => {
finestra.hidden = false;
retino(finestra, () => scriviRighe(corpo, () => { occupato = false; }));
});
}
function chiudi() {
finestra.hidden = true;
aperto = null;
occupato = false;
document.querySelectorAll('[data-apri]').forEach((b) => b.setAttribute('aria-expanded', 'false'));
}
document.querySelectorAll('[data-apri]').forEach((b) => {
b.setAttribute('aria-expanded', 'false');
b.addEventListener('click', () => apri(b.dataset.apri, b));
});
document.querySelector('[data-chiudi]').addEventListener('click', chiudi);
addEventListener('keydown', (e) => { if (e.key === 'Escape' && aperto) chiudi(); });
/* ---------------------------------------------------------------- spegnere e riaccendere */
let spento = false;
function applica(tema) {
tubo.dataset.tema = tema;
document.querySelectorAll('[data-scegli]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.scegli === tema)));
coloriVelo();
}
function riaccendi(tema) {
if (spento) return;
const nuovo = tema || TEMI[(TEMI.indexOf(tubo.dataset.tema) + 1) % TEMI.length];
if (RIDOTTO) {
gsap.timeline()
.to(schermo, { opacity: 0, duration: 0.075, ease: 'none', onComplete: () => applica(nuovo) })
.to(schermo, { opacity: 1, duration: 0.075, ease: 'none' });
return;
}
spento = true;
gsap.set(schermo, { filter: 'brightness(1) saturate(1)' });
gsap.timeline({ onComplete: () => { spento = false; gsap.set(schermo, { clearProps: 'transform,filter' }); scriviTitolo(); } })
/* si spegne: prima una riga, poi un punto, sempre più chiaro */
.to(schermo, { scaleY: 0.006, filter: 'brightness(2.2) saturate(1)', duration: SPEGNE * 0.55, ease: 'power2.in' })
.to(schermo, { scaleX: 0, filter: 'brightness(3) saturate(1)', duration: SPEGNE * 0.45, ease: 'power2.in' })
.add(() => applica(nuovo))
/* buio per un attimo, poi la riga si allarga, il vetro si apre oltre il bordo e torna a posto */
.set(schermo, { scaleX: 0.02, scaleY: 0.006, filter: 'brightness(2.4) saturate(0.4)' }, '+=0.12')
.set(lampo, { opacity: 1 })
.to(schermo, { scaleX: 1, duration: ACCENDE * 0.28, ease: 'power3.out' })
.to(schermo, { scaleY: 1.03, filter: 'brightness(1.4) saturate(1.5)', duration: ACCENDE * 0.34, ease: 'power2.out' }, `+=${ACCENDE * 0.12}`)
.to(lampo, { opacity: 0, duration: ACCENDE * 0.5, ease: 'power1.out' }, '<')
.to(schermo, { scaleY: 1, filter: 'brightness(1) saturate(1)', duration: ACCENDE * 0.26, ease: 'power2.out' });
}
document.querySelector('[data-spegni]').addEventListener('click', () => riaccendi());
document.querySelectorAll('[data-scegli]').forEach((b) => {
b.addEventListener('click', () => { if (b.dataset.scegli !== tubo.dataset.tema) riaccendi(b.dataset.scegli); });
});
/* ---------------------------------------------------------------- il vetro, in WebGL */
const gl = velo.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false });
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 = `precision mediump float;
uniform vec2 uRis;
uniform float uTempo, uMoto, uSfarfallio;
uniform vec3 uCarta, uInchiostro;
varying vec2 vUv;
float caso(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main() {
vec2 uv = vUv;
vec2 px = uv * uRis;
/* lo schermo: un rettangolo dagli angoli tondi dentro la cornice */
float raggio = clamp(uRis.y * 0.028, 16.0, 32.0);
float margine = clamp(min(uRis.x, uRis.y) * 0.022, 8.0, 20.0);
vec2 q = abs(px - uRis * 0.5) - (uRis * 0.5 - vec2(margine + raggio));
float fuori = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - raggio;
float vetro = 1.0 - smoothstep(-1.25, 1.25, fuori);
float righe = 0.5 + 0.5 * sin(px.y * 2.0944);
float griglia = 0.5 + 0.5 * sin(px.x * 1.5708);
float d = ((1.0 - uv.y) - fract(uTempo * 0.11)) * 14.0;
float banda = uMoto * 0.05 * exp(-d * d);
float grana = uMoto * 0.07 * (caso(px + fract(uTempo * 61.7) * vec2(391.0, 827.0)) - 0.5);
float vig = pow(max(0.0, 16.0 * uv.x * uv.y * (1.0 - uv.x) * (1.0 - uv.y)), 0.22);
float spalla = (1.0 - smoothstep(0.0, 0.1, min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y)))) * 0.13;
float ombra = clamp((righe * 0.17 + griglia * 0.085 + (1.0 - vig) * 0.18 + spalla) * uSfarfallio + min(grana, 0.0), 0.0, 0.85);
float luce = (banda * 1.8 + max(grana, 0.0) * 0.8) * uSfarfallio;
float riflesso = 0.05 * pow(max(0.0, 1.0 - length((uv - vec2(0.32, 0.86)) * vec2(1.5, 2.4))), 2.0);
/* dentro il vetro l'alfa scurisce (righe, griglia, vignetta) e il colore aggiunge luce; fuori è la cornice */
vec3 cornice = uCarta * 0.3 + vec3(0.018);
vec3 col = mix(cornice, uInchiostro * luce + vec3(riflesso), vetro);
float alfa = mix(1.0, clamp(ombra + luce + riflesso, 0.0, 1.0), vetro);
gl_FragColor = vec4(col, alfa);
}`;
const U = {};
let pronto = false;
function avviaVelo() {
if (!gl) return false;
const compila = (tipo, fonte) => {
const s = gl.createShader(tipo);
gl.shaderSource(s, fonte);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
};
const pr = gl.createProgram();
gl.attachShader(pr, compila(gl.VERTEX_SHADER, VERT));
gl.attachShader(pr, compila(gl.FRAGMENT_SHADER, FRAG));
gl.bindAttribLocation(pr, 0, 'p');
gl.linkProgram(pr);
if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(pr));
gl.useProgram(pr);
['uRis', 'uTempo', 'uMoto', 'uSfarfallio', 'uCarta', 'uInchiostro'].forEach((n) => { U[n] = gl.getUniformLocation(pr, n); });
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
return true;
}
const rgb = (v) => {
const h = v.trim().replace('#', '');
return [0, 2, 4].map((k) => parseInt(h.slice(k, k + 2), 16) / 255);
};
function coloriVelo() {
if (!pronto) return;
const s = getComputedStyle(tubo);
gl.uniform3fv(U.uCarta, rgb(s.getPropertyValue('--carta')));
gl.uniform3fv(U.uInchiostro, rgb(s.getPropertyValue('--inchiostro')));
if (RIDOTTO) disegnaVelo(performance.now());
}
function misuraVelo() {
if (!pronto) return;
const dpr = Math.min(window.devicePixelRatio || 1, TOCCO ? 1 : 1.25);
velo.width = Math.round(tubo.clientWidth * dpr);
velo.height = Math.round(tubo.clientHeight * dpr);
gl.viewport(0, 0, velo.width, velo.height);
gl.uniform2f(U.uRis, tubo.clientWidth, tubo.clientHeight);
if (RIDOTTO) disegnaVelo(performance.now());
}
function disegnaVelo(ora) {
const t = ora / 1000;
const moto = RIDOTTO ? 0 : 1;
gl.uniform1f(U.uTempo, t);
gl.uniform1f(U.uMoto, moto);
gl.uniform1f(U.uSfarfallio, 1 + moto * 0.05 * Math.sin(t * 43) * (0.55 + 0.45 * Math.sin(t * 13.7)));
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
/* il vetro si ridisegna trenta volte al secondo, e solo quando si vede */
let visibile = true;
let giraVelo = false;
let ultimoVelo = 0;
function cicloVelo(ora) {
if (!visibile || document.hidden) { giraVelo = false; return; }
if (ora - ultimoVelo >= 1000 / 30) { ultimoVelo = ora; disegnaVelo(ora); }
requestAnimationFrame(cicloVelo);
}
function svegliaVelo() {
if (giraVelo || RIDOTTO || !pronto || !visibile || document.hidden) return;
giraVelo = true;
requestAnimationFrame(cicloVelo);
}
/* ---------------------------------------------------------------- l'avvio */
function segnaOra() {
const d = new Date();
const giorno = d.toLocaleDateString('it-IT', { weekday: 'short' }).replace('.', '');
orologio.textContent = `${giorno} ${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
segnaOra();
setInterval(segnaOra, 15000);
preparaTitolo();
try { pronto = avviaVelo(); } catch (e) { pronto = false; }
if (!pronto) tubo.classList.add('senza-velo');
misuraVelo();
coloriVelo();
if (pronto) {
new IntersectionObserver(([v]) => { visibile = v.isIntersecting; if (visibile) svegliaVelo(); }).observe(tubo);
document.addEventListener('visibilitychange', () => { if (!document.hidden) svegliaVelo(); });
svegliaVelo();
}
let attesa = null;
addEventListener('resize', () => { clearTimeout(attesa); attesa = setTimeout(misuraVelo, 150); });
document.fonts.ready.then(() => scriviTitolo(() => scriviRighe(righe)));A WebGL canvas on top of everything, which ignores clicks, draws the glass thirty times a second. Inside the screen the alpha darkens (scanlines every three pixels with sin(y × 2π/3), a grille every four, a vignette) and the colour adds light (the band descending every nine seconds, grain, a reflection at the top left); outside the round-cornered rectangle sits the opaque frame.
The text does not glide: it jumps. A single clock (requestAnimationFrame with an accumulator) ticks every 33 milliseconds and runs the tasks of the moment. The title reveals ⌈f/d × n⌉ letters per tick and keeps the newest two ticks brighter; lines appear through two mask-image layers, the finished lines and the current line up to the cursor.
Opening a file, three empty rectangles go from the centre of the icon to the size of the window in five ticks each (easeOutQuad), one tick behind the other. Then the window appears through four Bayer masks, at 20, 40, 60 and 80%, two ticks each.
A GSAP timeline squashes the screen to 0.6% of its height and then narrows it to zero width, brighter and brighter, in 0.44 seconds. In the dark the phosphor changes (the CSS variables and the shader colours); then the line widens, the glass opens to 103% and settles at 100% in 0.78 seconds, while a blade of light fades.
| Name | File | Default | What it changes |
|---|---|---|---|
SCATTI | script.js | 30 | How many times a second the text moves: lower, more visible steps. |
SPEGNE | script.js | 0.44 | Seconds of switching off. |
ACCENDE | script.js | 0.78 | Seconds of switching on. |
--carta, --inchiostro | style.css | tre temi | The colours of each phosphor: the glass reads them from here, so changing them in the CSS is enough. |
For a portfolio or a product with the soul of an old computer, or for a game page. The glass goes over any content without touching it.
The same, with a narrower frame, the bar without menus and the glass drawn at density 1 to save work.
The glass keeps its lines and frame but loses the band, grain and flicker; texts appear whole, no rectangles or dither, and the phosphor change is a 150-millisecond fade.
GSAP 3.15.0, VT323 (font) 5.2.7.
Weight: 29 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.