← AE Lab
▶
Explicado paso a paso en el episodio
5 efectos web que venden (el nº1 se monta con miles de píxeles)
0:50Titular que reacciona como una ola
Titular que reacciona como una ola
Un titular gigante de portada en el que cada letra engorda, se estira y se inclina al pasar el cursor, el dedo o el scroll, como una ola que lo recorre, y deja una estela verde que se apaga. Parece una fuente variable animada, pero solo mueve opacidad y transform.
// CÓMO ESTÁ HECHO
- El JS parte el titular en letras y cada letra lleva tres copias apiladas en la misma celda de grid: fina (200), gruesa (900) y verde; todo aria-hidden, con el texto completo en el aria-label del h1.
- Cada fotograma calcula la fuerza de la ola en cada letra con una campana de Gauss según su distancia al cursor, a un barrido diagonal (scroll, demo o botón) o a un anillo que crece desde el clic.
- En vez de animar font-weight (que recalcula el maquetado), se cruza la opacidad de la copia fina y la gruesa y se deforma la letra con scale, translateY y skewX.
- La estela es un valor que sube al instante y baja multiplicándose por 0,965 en cada fotograma: así el verde se apaga solo.
- Modo demostración con una ola cada 3,2 s que se para al interactuar, y con prefers-reduced-motion solo cambian grosor y color.
// EL CÓDIGO
<div class="recorrido">
<section class="portada">
<header class="cabecera">
<div class="marca">Taller <b>Nómada</b></div>
<nav aria-label="Principal">
<a href="#">Mujer</a><a href="#">Hombre</a><a href="#">Tienda</a>
</nav>
</header>
<div class="centro">
<p class="etiqueta"><i>●</i> Colección otoño · Lino y lana cruda</p>
<!-- Para un lector de pantalla esto es UNA frase: aria-label en el
contenedor y todas las letras sueltas con aria-hidden (las pone el JS). -->
<h1 class="titular" id="titular" aria-label="Ropa que se mueve contigo">Ropa que se mueve contigo</h1>
<div class="pie">
<a class="boton" href="#">Ver colección</a>
<button class="boton secundario" id="lanzar" type="button"><span>≈</span> Lanzar ola</button>
</div>
</div>
<aside class="ficha" aria-label="Producto destacado">
<div class="tejido"></div>
<p>Chaqueta de lana cruda <b>89 €</b></p>
<small>Nuevo · 4 colores</small>
</aside>
<div class="pista" aria-hidden="true">
<span id="ayuda">Mueve el cursor · Desplaza ↓</span>
<span class="progreso"><i id="barra"></i></span>
</div>
</section>
</div>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--fondo); }
body {
background: var(--fondo);
color: var(--blanco);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
overflow-x: hidden;
-webkit-tap-highlight-color: transparent;
}
/* El recorrido de scroll: 3 pantallas de alto. La portada se queda
pegada (sticky) mientras bajas y el scroll empuja la ola. */
.recorrido { height: 300dvh; }
.portada {
position: sticky; top: 0;
height: 100dvh;
display: grid; grid-template-rows: auto 1fr auto; grid-template-columns: minmax(0, 1fr);
padding: 20px clamp(16px, 4vw, 56px) calc(20px + env(safe-area-inset-bottom));
overflow: hidden;
/* Una luz verde muy tenue de fondo para dar profundidad */
background:
radial-gradient(120% 70% at 50% 110%, rgba(0, 255, 127, .07), transparent 60%),
var(--fondo);
}
/* Cabecera de la marca (inventada) */
.cabecera { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.marca { font: 700 13px/1 ui-monospace, "JetBrains Mono", monospace; letter-spacing: .18em; text-transform: uppercase; }
.marca b { color: var(--verde); }
.cabecera nav { display: flex; gap: clamp(12px, 3vw, 32px); font-size: 13px; color: var(--gris); }
.cabecera nav a { color: inherit; text-decoration: none; }
.cabecera nav a:hover { color: var(--blanco); }
.cabecera nav a:focus-visible, .boton:focus-visible { outline: 2px solid var(--verde); outline-offset: 4px; border-radius: 6px; }
.centro { display: grid; align-content: center; gap: clamp(14px, 2.4vh, 28px); min-height: 0; }
.etiqueta { font: 600 12px/1 ui-monospace, monospace; letter-spacing: .2em; text-transform: uppercase; color: var(--gris); }
.etiqueta i { font-style: normal; color: var(--verde); }
/* ===== EL TITULAR =====
Por qué NO animamos font-weight ni font-variation-settings:
- cambiar el grosor de una letra cambia su ancho → el navegador recalcula
el maquetado de toda la línea en cada fotograma (adiós 60 fps);
- system-ui no es variable en todos los equipos (en unos es Segoe UI,
en otros San Francisco o Roboto), así que el resultado sería distinto.
Lo que hacemos: cada letra lleva TRES copias apiladas en la misma celda
(fina, gruesa y verde). Cruzamos su opacidad y deformamos la letra con
transform. Solo se tocan opacity y transform → todo va por la GPU. */
.titular {
font-size: clamp(52px, min(18vw, 19vh), 200px);
max-width: 8.4em; /* fuerza el corte: 3 líneas en escritorio, 4 en móvil */
text-wrap: balance; /* líneas de largo parecido */
line-height: .98;
letter-spacing: -.02em;
text-transform: uppercase;
user-select: none;
touch-action: pan-y; /* en móvil: el dedo en horizontal mueve la ola, en vertical hace scroll */
cursor: crosshair;
}
.palabra { display: inline-block; white-space: nowrap; }
.letra {
display: inline-grid; /* las tres copias ocupan la misma celda */
transform-origin: 50% 85%;
will-change: transform;
}
.letra > span { grid-area: 1 / 1; text-align: center; will-change: opacity; }
.fina { font-weight: 200; transform: scaleX(1.06); }
.gruesa { font-weight: 900; opacity: 0; }
.estela { font-weight: 900; opacity: 0; color: var(--verde); text-shadow: 0 0 .25em rgba(0, 255, 127, .45); }
.pie { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.boton {
appearance: none; border: 1px solid var(--borde); cursor: pointer;
font: 600 15px/1 system-ui, sans-serif;
padding: 14px 20px; border-radius: 999px;
background: var(--verde); color: var(--fondo);
transition: transform .2s;
}
.boton:active { transform: scale(.96); }
.boton.secundario { background: var(--superficie); color: var(--blanco); }
.boton.secundario span { color: var(--verde); }
.pista {
display: flex; justify-content: space-between; align-items: end; gap: 12px;
font: 500 11px/1.4 ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--gris);
}
/* La barra de progreso del scroll (solo transform) */
.progreso { flex: 1; max-width: 220px; height: 2px; background: var(--borde); border-radius: 2px; overflow: hidden; }
.progreso i { display: block; height: 100%; background: var(--verde); transform-origin: 0 50%; transform: scaleX(0); }
@media (max-width: 600px) {
.cabecera nav a:not(:last-child) { display: none; }
.boton { padding: 13px 16px; font-size: 14px; }
}
/* Ficha de producto lateral (solo en pantallas anchas). El "tejido"
es un degradado repetido: ni una imagen. */
.ficha {
position: absolute; right: clamp(16px, 4vw, 56px); bottom: 18vh;
width: 230px; padding: 12px; border-radius: 18px;
background: var(--superficie); border: 1px solid var(--borde);
display: none;
}
.tejido {
height: 210px; border-radius: 10px;
background:
repeating-linear-gradient(45deg, rgba(232, 245, 238, .06) 0 2px, transparent 2px 7px),
repeating-linear-gradient(-45deg, rgba(0, 0, 0, .25) 0 2px, transparent 2px 7px),
linear-gradient(160deg, #3a4a3f, #1b2620 70%);
}
.ficha p { display: flex; justify-content: space-between; margin-top: 12px; font-size: 14px; }
.ficha small { display: block; margin-top: 4px; color: var(--gris); font: 500 11px/1 ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }
.ficha b { color: var(--verde); }
/* Con la ficha a la derecha, el titular debe caber en el hueco que deja: la letra se mide por el ancho */
@media (min-width: 1100px) and (min-height: 640px) { .ficha { display: block; } .titular { font-size: clamp(52px, min(7.4vw, 17vh), 170px); } }
(() => {
const titular = document.getElementById('titular');
const quieto = matchMedia('(prefers-reduced-motion: reduce)').matches;
const tactil = matchMedia('(pointer: coarse)').matches;
if (tactil) document.getElementById('ayuda').textContent = 'Arrastra el dedo · Desliza ↓';
/* ---------- 1. Partir el titular en letras ----------
Cada letra: <span class="letra"> con 3 copias (fina, gruesa, estela).
Todo aria-hidden: el texto accesible es el aria-label del <h1>. */
const letras = [];
const texto = titular.textContent.trim();
titular.textContent = '';
// Las palabras van enteras (no se parten), y entre ellas un espacio normal
// para que el navegador corte las líneas solo según el ancho de pantalla.
texto.split(' ').forEach((palabra, n) => {
if (n) titular.append(' ');
const p = document.createElement('span');
p.className = 'palabra';
p.setAttribute('aria-hidden', 'true');
for (const c of palabra) {
const el = document.createElement('span');
el.className = 'letra';
el.innerHTML = `<span class="fina">${c}</span><span class="gruesa">${c}</span><span class="estela">${c}</span>`;
p.append(el);
letras.push({
el, linea: 0, // la línea se calcula al medir (depende del ancho)
fina: el.children[0], gruesa: el.children[1], estela: el.children[2],
x: 0, y: 0, // centro de la letra (relativo al titular)
v: 0, // fuerza de la ola ahora (0..1), suavizada
t: 0, // estela verde (0..1), se apaga poco a poco
ultimo: '' // último transform escrito (para no repetir escrituras)
});
}
titular.append(p);
});
/* ---------- 2. Medir dónde está cada letra ----------
Solo al cargar y al cambiar el tamaño: nunca dentro del bucle. */
let ancho = 1, alto = 1, radio = 100;
function medir() {
const r = titular.getBoundingClientRect();
ancho = r.width; alto = r.height;
radio = parseFloat(getComputedStyle(titular).fontSize) * 1.15; // tamaño de la ola ~ una letra y pico
for (const l of letras) {
// offsetLeft/Top ignoran los transform: medimos la letra "en reposo"
l.x = l.el.offsetLeft + l.el.offsetWidth / 2 - titular.offsetLeft;
l.y = l.el.offsetTop + l.el.offsetHeight / 2 - titular.offsetTop;
}
// Número de línea de cada letra (0, 1, 2…) según su altura
const alturas = [...new Set(letras.map(l => Math.round(l.y)))].sort((a, b) => a - b);
for (const l of letras) l.linea = alturas.indexOf(Math.round(l.y));
}
medir();
addEventListener('resize', medir);
document.fonts && document.fonts.ready.then(medir);
/* ---------- 3. Las fuentes de la ola ----------
a) el cursor / el dedo (una burbuja alrededor del punto)
b) un barrido de izquierda a derecha (scroll, demo o botón)
c) un anillo que se expande desde donde haces clic o tocas */
const cursor = { x: 0, y: 0, fuerza: 0, activo: false };
let barrido = null; // { inicio, dur } — posición calculada con el tiempo
let scrollOla = { x: -1, fuerza: 0 };
const anillos = [];
const campana = d => Math.exp(-(d * d)); // curva suave: 1 en el centro, 0 lejos
/* ---------- 4. Modo demostración ----------
Si nadie toca nada, pasa una ola cada 3,2 s. Se para con cualquier
interacción y vuelve tras 4 s de calma. */
let ultimaInteraccion = -1e9;
let proximaDemo = 300;
function interaccion() { ultimaInteraccion = performance.now(); }
const enDemo = ahora => !quieto && ahora - ultimaInteraccion > 4000;
function lanzarBarrido(dur = 1500) { barrido = { inicio: performance.now(), dur }; }
/* ---------- 5. Eventos ---------- */
function aLocal(e) {
const r = titular.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
addEventListener('pointermove', e => {
const p = aLocal(e);
cursor.x = p.x; cursor.y = p.y; cursor.activo = true;
interaccion();
}, { passive: true });
document.addEventListener('pointerleave', () => { cursor.activo = false; });
addEventListener('pointerup', e => { if (e.pointerType !== 'mouse') cursor.activo = false; });
addEventListener('pointercancel', () => { cursor.activo = false; }); // el dedo pasó a hacer scroll
titular.addEventListener('pointerdown', e => {
const p = aLocal(e);
cursor.x = p.x; cursor.y = p.y; cursor.activo = true;
anillos.push({ x: p.x, y: p.y, inicio: performance.now() });
interaccion();
});
document.getElementById('lanzar').addEventListener('click', () => {
interaccion();
lanzarBarrido(1300);
});
addEventListener('keydown', interaccion);
// Scroll: el progreso dentro del recorrido (0..1) es la posición de la ola
const recorrido = document.querySelector('.recorrido');
const barra = document.getElementById('barra');
let ultimoScroll = -1e9;
addEventListener('scroll', () => {
const total = recorrido.offsetHeight - innerHeight;
const p = Math.min(1, Math.max(0, scrollY / total));
scrollOla.x = p;
ultimoScroll = performance.now();
barra.style.transform = `scaleX(${p})`;
interaccion();
}, { passive: true });
/* ---------- 6. El bucle: calcular la fuerza de cada letra y pintarla ---------- */
function fotograma(ahora) {
// La demo lanza barridos sola
if (enDemo(ahora) && ahora > proximaDemo) {
lanzarBarrido(2300);
proximaDemo = ahora + 3200;
}
if (!enDemo(ahora)) proximaDemo = ahora + 600;
// El cursor aparece y se apaga suave
cursor.fuerza += ((cursor.activo ? 1 : 0) - cursor.fuerza) * 0.12;
// La ola del scroll se desvanece 0,6 s después de dejar de girar la rueda
scrollOla.fuerza += ((ahora - ultimoScroll < 600 ? 1 : 0) - scrollOla.fuerza) * 0.1;
// Posición del barrido (va de fuera por la izquierda a fuera por la derecha)
let bx = null;
if (barrido) {
const k = (ahora - barrido.inicio) / barrido.dur;
if (k >= 1) barrido = null;
else bx = -radio * 2 + (ancho + radio * 4) * (k < .5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2);
}
const sx = -radio + (ancho + radio * 2) * scrollOla.x;
for (let i = anillos.length - 1; i >= 0; i--) if (ahora - anillos[i].inicio > 1400) anillos.splice(i, 1);
for (const l of letras) {
let f = 0;
// a) cursor: burbuja (un poco más ancha que alta, como una ola)
if (cursor.fuerza > .01) {
const dx = (l.x - cursor.x) / (radio * 1.3), dy = (l.y - cursor.y) / (radio * 1.1);
f = Math.max(f, cursor.fuerza * Math.exp(-(dx * dx + dy * dy)));
}
// b) barrido: cada línea va un poco retrasada → la ola entra en diagonal
const desfase = l.linea * radio * .55;
if (bx !== null) f = Math.max(f, campana((l.x + desfase - bx) / radio));
if (scrollOla.fuerza > .01) f = Math.max(f, scrollOla.fuerza * campana((l.x + desfase * .6 - sx) / radio));
// c) anillos: la cresta es un círculo que crece y se debilita
for (const a of anillos) {
const edad = (ahora - a.inicio) / 1400;
const d = Math.hypot(l.x - a.x, l.y - a.y);
f = Math.max(f, (1 - edad) * campana((d - edad * ancho * .9) / (radio * .7)));
}
// Suavizado: la letra persigue su objetivo (aspecto de líquido)
l.v += (f - l.v) * 0.22;
// Estela: sube al instante y baja despacio (se "apaga")
l.t = Math.max(l.t * 0.965, l.v);
pintar(l);
}
requestAnimationFrame(fotograma);
}
// Escribir solo si el valor cambia (redondeado) → menos trabajo para el navegador
function pintar(l) {
const v = l.v;
const r = n => Math.round(n * 1000) / 1000;
const tr = quieto
? '' // sin movimiento: solo cambian grosor y color
: `translateY(${r(-v * .1)}em) scale(${r(1 + v * .14)}, ${r(1 + v * .26)}) skewX(${r(-v * 7)}deg)`;
if (tr !== l.ultimo) { l.el.style.transform = tr; l.ultimo = tr; }
// Cruce de grosor "rápido": la mezcla fina+gruesa a medias se ve borrosa,
// así que el cambio ocurre en un tramo corto (v entre 0,25 y 0,55).
const g = Math.min(1, Math.max(0, (v - .25) / .3));
const op = r(g * g * (3 - 2 * g)); // smoothstep
const est = r(Math.min(1, l.t * 1.15) * (1 - op)); // la estela verde asoma cuando la gruesa se va
if (op !== l.op) {
l.gruesa.style.opacity = op;
l.fina.style.opacity = r(1 - op);
l.op = op;
}
if (est !== l.est) { l.estela.style.opacity = est; l.est = est; }
}
requestAnimationFrame(fotograma);
})();
▶
Explicado paso a paso en el episodio
5 efectos web que venden (el nº1 se monta con miles de píxeles)
0:50Titular que reacciona como una ola