← AE LabBORRADOR · no está en el listado
▶
Explicado paso a paso en el episodio
5 efectos web que tus clientes no han visto nunca (el nº1 enciende la luz)
0:37Titular que se monta con el scroll
Titular que se monta con el scroll
Las letras llegan volando desde todas partes y encajan mientras bajas; después las palabras se iluminan una a una. Todo lo mueve una sola variable de CSS.
// CÓMO ESTÁ HECHO
- Cada letra es un span con su sitio de partida aleatorio (--x, --y, --r) y su turno (--d).
- Una sola variable, --p, va de 0 a 1 mientras recorres la sección; cada letra calcula con clamp() su propio momento.
- Con animation-timeline: view(), --p avanza solo con CSS; donde no existe, cuatro líneas de JS la calculan.
- Al final, color-mix() enciende las palabras en verde una detrás de otra.
// EL CÓDIGO
<section class="aviso">
<p>Desliza hacia abajo</p>
<div class="flecha" aria-hidden="true"></div>
</section>
<section class="montaje" id="montaje">
<div class="escenario">
<h1 class="titular" id="titular" aria-label="Construimos lo que tu negocio necesita">Construimos lo que tu negocio necesita</h1>
<div class="pie">scroll → --p</div>
<div class="barra-progreso" aria-hidden="true"></div>
</div>
</section>
<section class="final">
<p>Sube y baja: el titular se desmonta y se vuelve a montar.</p>
</section>
:root {
--fondo: #05070A;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
}
* { box-sizing: border-box; margin: 0; }
body {
background: var(--fondo);
color: var(--blanco);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.aviso {
height: 100dvh; display: grid; place-content: center; justify-items: center; gap: 14px; text-align: center; padding: 24px;
}
.aviso p { color: var(--gris); font: 14px ui-monospace, "JetBrains Mono", monospace; letter-spacing: .2em; text-transform: uppercase; }
.aviso .flecha { width: 2px; height: 56px; background: linear-gradient(var(--verde), transparent); animation: bajar 1.6s ease-in-out infinite; }
@keyframes bajar { 0% { transform: scaleY(0); transform-origin: top; } 50% { transform: scaleY(1); transform-origin: top; } 51% { transform-origin: bottom; } 100% { transform: scaleY(0); transform-origin: bottom; } }
/* Una sola variable manda en todo: --p va de 0 a 1 mientras recorres la sección.
La registramos con @property para que el navegador pueda animarla. */
@property --p { syntax: "<number>"; inherits: true; initial-value: 0; }
.montaje { height: 320vh; position: relative; }
.escenario {
position: sticky; top: 0; height: 100dvh; overflow: hidden;
display: grid; place-content: center; padding: 0 6vw;
}
.titular {
font: 800 clamp(38px, 9.5vw, 132px)/1.02 ui-monospace, "JetBrains Mono", monospace;
letter-spacing: -.05em; max-width: 13ch;
}
.palabra { display: inline-block; white-space: nowrap; margin-right: .32em; }
/* Cada palabra se ilumina en verde cuando el titular ya está montado, una detrás de otra */
.palabra {
--luz: clamp(0, (var(--p) - .62 - var(--w) * .055) * 18, 1);
color: color-mix(in srgb, var(--verde) calc(var(--luz) * 100%), var(--blanco));
}
/* Cada letra calcula su propio momento (--t) a partir de --p y de su retraso (--d):
llega desde su sitio aleatorio (--x, --y, --r) y encaja. */
.letra {
--t: clamp(0, (var(--p) - var(--d) * .38) / .24, 1);
--f: calc(1 - var(--t));
display: inline-block;
transform:
translate3d(calc(var(--x) * var(--f)), calc(var(--y) * var(--f)), 0)
rotate(calc(var(--r) * var(--f)))
scale(calc(1 + var(--f) * 1.4));
opacity: calc(.12 + var(--t) * .88);
filter: blur(calc(var(--f) * 6px));
}
.cursor {
display: inline-block; width: .55em; height: .1em; background: var(--verde); margin-left: .05em;
opacity: clamp(0, (var(--p) - .9) * 20, 1);
animation: parpadeo 1s steps(1) infinite;
}
@keyframes parpadeo { 50% { visibility: hidden; } }
.barra-progreso {
position: absolute; left: 6vw; right: 6vw; bottom: 34px; height: 2px; background: #16201A;
}
.barra-progreso::after {
content: ""; position: absolute; inset: 0; background: var(--verde);
transform-origin: left; transform: scaleX(var(--p));
}
.pie {
position: absolute; left: 6vw; bottom: 46px;
font: 13px ui-monospace, "JetBrains Mono", monospace; color: var(--gris);
}
/* Donde el navegador lo sabe hacer, --p avanza SOLO con CSS, sin una línea de JavaScript */
@supports (animation-timeline: view()) {
.montaje { animation: progreso linear both; animation-timeline: view(); animation-range: contain 0% contain 100%; }
@keyframes progreso { from { --p: 0; } to { --p: 1; } }
}
.final { height: 80dvh; display: grid; place-content: center; text-align: center; color: var(--gris); padding: 24px; }
@media (prefers-reduced-motion: reduce) {
.montaje { --p: 1 !important; animation: none !important; height: auto; }
.escenario { position: static; }
.aviso .flecha, .cursor { animation: none; }
}
// 1) Partir el titular en palabras y letras, cada letra con su sitio de partida.
const titular = document.getElementById('titular');
const texto = titular.textContent.trim();
titular.textContent = '';
let semilla = 7;
const azar = () => (semilla = (semilla * 9301 + 49297) % 233280) / 233280; // siempre igual en cada visita
const letras = texto.replace(/\s+/g, '').length;
let n = 0;
texto.split(' ').forEach((pal, w) => {
const sp = document.createElement('span');
sp.className = 'palabra';
sp.style.setProperty('--w', w);
sp.setAttribute('aria-hidden', 'true');
[...pal].forEach(c => {
const l = document.createElement('span');
l.className = 'letra';
l.textContent = c;
l.style.setProperty('--x', ((azar() - .5) * 90).toFixed(1) + 'vw');
l.style.setProperty('--y', ((azar() - .5) * 110).toFixed(1) + 'vh');
l.style.setProperty('--r', ((azar() - .5) * 340).toFixed(0) + 'deg');
l.style.setProperty('--d', (n++ / letras).toFixed(3));
sp.appendChild(l);
});
titular.appendChild(sp);
});
const cur = document.createElement('span');
cur.className = 'cursor';
cur.setAttribute('aria-hidden', 'true');
titular.lastChild.appendChild(cur);
// 2) Solo si el navegador no tiene animaciones guiadas por scroll, --p se calcula aquí.
if (!CSS.supports('animation-timeline: view()') && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
const sec = document.getElementById('montaje');
let pendiente = false;
const medir = () => {
pendiente = false;
const r = sec.getBoundingClientRect();
const p = Math.min(Math.max(-r.top / (r.height - innerHeight), 0), 1);
sec.style.setProperty('--p', p.toFixed(4));
};
addEventListener('scroll', () => { if (!pendiente) { pendiente = true; requestAnimationFrame(medir); } }, { passive: true });
addEventListener('resize', medir);
medir();
}
▶
Explicado paso a paso en el episodio
5 efectos web que tus clientes no han visto nunca (el nº1 enciende la luz)
0:37Titular que se monta con el scroll