
Il caricamento che diventa marchio
Transizioni, SVG, Testo
NuovaGratis
Una scrivania da computer di una volta dietro un vetro da televisore: righe, banda di luce, sfarfallio, testi che si scrivono a scatti e un interruttore che spegne il tubo in una riga e lo riaccende con un altro fosforo.
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 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)));Un canvas WebGL sopra tutto, che non riceve i clic, disegna il vetro trenta volte al secondo. Dentro lo schermo l’alfa scurisce (righe ogni tre pixel con sin(y × 2π/3), griglia ogni quattro, vignetta) e il colore aggiunge luce (la banda che scende ogni nove secondi, la grana, un riflesso in alto a sinistra); fuori dal rettangolo dagli angoli tondi c’è la cornice, opaca.
I testi non scorrono: saltano. Un orologio unico (requestAnimationFrame con un accumulatore) fa uno scatto ogni 33 millesimi e fa girare i compiti del momento. Il titolo scopre ⌈f/d × n⌉ lettere per scatto e tiene accese per due scatti le ultime arrivate; le righe si scoprono con due strati di mask-image, le righe finite e la riga in corso fino al cursore.
Aprendo un file, tre rettangoli vuoti vanno dal centro dell’icona alla misura della finestra in cinque scatti ciascuno (easeOutQuad), uno scatto dietro l’altro. Poi la finestra compare attraverso quattro maschere di Bayer, al 20, 40, 60 e 80%, due scatti ciascuna.
Una timeline GSAP schiaccia lo schermo a un’altezza dello 0,6% e poi lo stringe a zero di larghezza, sempre più luminoso, in 0,44 secondi. Al buio cambia il fosforo (le variabili CSS e i colori dello shader); poi la riga si allarga, il vetro si apre al 103% e torna al 100% in 0,78 secondi, mentre una lama di luce svanisce.
| Nome | File | Di partenza | Cosa cambia |
|---|---|---|---|
SCATTI | script.js | 30 | Quante volte al secondo si muovono i testi: più basso, più scatti si vedono. |
SPEGNE | script.js | 0.44 | Secondi dello spegnimento. |
ACCENDE | script.js | 0.78 | Secondi dell’accensione. |
--carta, --inchiostro | style.css | tre temi | I colori di ogni fosforo: il vetro li legge da qui, quindi basta cambiarli nel CSS. |
Per un portfolio o un prodotto con un’anima di computer d’altri tempi, o per una pagina di gioco. Il vetro si mette sopra qualunque contenuto senza toccarlo.
Uguale, con la cornice più stretta, la barra senza i menù e il vetro disegnato a densità 1 per risparmiare.
Il vetro resta con le righe e la cornice ma senza banda, grana e sfarfallio; i testi compaiono interi, niente rettangoli né retino, e il cambio di fosforo è una dissolvenza di 150 millesimi.
GSAP 3.15.0, VT323 (font) 5.2.7.
Peso: 29 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.