← AE Lab
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
0:49Tarjetas que se apilan al hacer scroll
Tarjetas que se apilan al hacer scroll
Página de servicios de una clínica dental donde cada tarjeta se queda pegada arriba y la siguiente sube y se coloca encima, mientras las de debajo se encogen, se oscurecen y se inclinan como una baraja. Todo lo mueve el propio scroll, sin librerías.
// CÓMO ESTÁ HECHO
- Cada tarjeta es position: sticky con un top que crece 14 px por índice, así las de debajo asoman por arriba como una baraja.
- Antes de cada tarjeta hay una marca invisible con view-timeline-name, y timeline-scope en el contenedor deja que la tarjeta anterior lea la línea de tiempo de la siguiente.
- La tarjeta de debajo se anima con animation-timeline y animation-range: cover 0% → cover (100% − top): se encoge desde arriba, gira ±1,6° y un velo ::after sube su opacidad.
- Si el navegador no soporta animation-timeline, un respaldo con scroll + requestAnimationFrame hace la misma cuenta (0 → 1) y escribe el transform a mano; la barra de lectura usa scroll(root).
- El modo demostración baja con scrollTo frame a frame, vuelve arriba con una curva suave y se para al tocar, mover la rueda o pulsar una tecla.
// EL CÓDIGO
<header class="cabecera">
<a class="marca-logo" href="#inicio" aria-label="Brisa, clínica dental de ejemplo: inicio" style="color:inherit;text-decoration:none">
<svg viewBox="0 0 32 32" aria-hidden="true"><path d="M10 5c-4 0-6 3-6 7 0 3 1 5 2 8 1 4 1 9 4 9 2 0 2-6 4-6h4c2 0 2 6 4 6 3 0 3-5 4-9 1-3 2-5 2-8 0-4-2-7-6-7-2 0-4 1-6 1s-4-1-6-1z" fill="none" stroke="#00FF7F" stroke-width="2.2" stroke-linejoin="round"/></svg>
Brisa <small>· clínica dental</small>
</a>
<nav aria-label="Servicios">
<ol class="indice" id="indice"></ol>
</nav>
<div class="progreso" id="progreso" role="progressbar" aria-label="Progreso de lectura" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"></div>
</header>
<main id="inicio">
<section class="portada">
<span class="etiqueta mono">Clínica de ejemplo · 5 tratamientos</span>
<h1>Sonríe <em>sin prisas.</em></h1>
<p>Desliza: cada tratamiento se queda arriba y el siguiente se coloca encima, como una baraja.</p>
</section>
<!-- La pila. Cada tarjeta lleva: --i (posición), --lado (+1/-1 para inclinar) y --linea (marca de la siguiente) -->
<section class="pila" id="pila" aria-label="Tratamientos">
<div class="marca" data-i="0"></div>
<article class="tarjeta" style="--i:0; --lado:-1; --linea:--m1" aria-labelledby="t0">
<div class="lamina">
<div class="dibujo" aria-hidden="true">
<svg viewBox="0 0 120 120"><path d="M38 24c-11 0-18 8-18 20 0 9 3 14 5 22 2 11 3 30 11 30 6 0 6-17 12-17h4c6 0 6 17 12 17 8 0 9-19 11-30 2-8 5-13 5-22 0-12-7-20-18-20-6 0-9 3-13 3s-7-3-11-3z"/><circle cx="84" cy="80" r="14"/><path d="M94 90l12 12"/></svg>
</div>
<div class="texto">
<span class="num mono">01 / 05 · PREVENCIÓN</span>
<h2 id="t0">Revisión e higiene</h2>
<p>Limpieza profunda, revisión completa y un plan claro. Sin sustos ni letra pequeña.</p>
<ul><li>Diagnóstico digital en 20 minutos</li><li>Limpieza con ultrasonidos</li><li>Informe en tu móvil</li></ul>
<div class="pie"><span class="precio">desde<b>45 €</b></span><button class="btn" data-servicio="Revisión e higiene">Reservar</button></div>
</div>
</div>
</article>
<div class="marca" data-i="1"></div>
<article class="tarjeta" style="--i:1; --lado:1; --linea:--m2" aria-labelledby="t1">
<div class="lamina">
<div class="dibujo" aria-hidden="true">
<svg viewBox="0 0 120 120"><path d="M18 70c0-26 19-44 42-44s42 18 42 44"/><path d="M28 70c0-20 14-34 32-34s32 14 32 34" stroke-dasharray="4 7"/><rect x="22" y="70" width="12" height="16" rx="5"/><rect x="38" y="72" width="12" height="16" rx="5"/><rect x="54" y="73" width="12" height="16" rx="5"/><rect x="70" y="72" width="12" height="16" rx="5"/><rect x="86" y="70" width="12" height="16" rx="5"/></svg>
</div>
<div class="texto">
<span class="num mono">02 / 05 · ORTODONCIA</span>
<h2 id="t1">Alineadores invisibles</h2>
<p>Ves el resultado final en 3D antes de empezar. Férulas que nadie nota.</p>
<ul><li>Simulación 3D en la primera cita</li><li>Revisiones cada 8 semanas</li><li>Retenedor incluido</li></ul>
<div class="pie"><span class="precio">desde<b>95 €/mes</b></span><button class="btn" data-servicio="Alineadores invisibles">Reservar</button></div>
</div>
</div>
</article>
<div class="marca" data-i="2"></div>
<article class="tarjeta inversa" style="--i:2; --lado:-1; --linea:--m3" aria-labelledby="t2">
<div class="lamina">
<div class="dibujo" aria-hidden="true">
<svg viewBox="0 0 120 120"><path d="M40 14c-8 0-14 6-14 15 0 8 4 13 6 17h56c2-4 6-9 6-17 0-9-6-15-14-15-6 0-9 3-20 3s-14-3-20-3z"/><path d="M48 46v8h24v-8"/><path d="M50 54l-2 40 12 12 12-12-2-40"/><path d="M47 64h26M47 74h26M48 84h24"/></svg>
</div>
<div class="texto">
<span class="num mono">03 / 05 · IMPLANTES</span>
<h2 id="t2">Dientes fijos en un día</h2>
<p>Cirugía guiada por ordenador: menos tiempo en el sillón y menos molestias.</p>
<ul><li>Escáner 3D sin moldes</li><li>Diente provisional el mismo día</li><li>Garantía de 10 años</li></ul>
<div class="pie"><span class="precio">desde<b>690 €</b></span><button class="btn" data-servicio="Implantes">Reservar</button></div>
</div>
</div>
</article>
<div class="marca" data-i="3"></div>
<article class="tarjeta" style="--i:3; --lado:1; --linea:--m4" aria-labelledby="t3">
<div class="lamina">
<div class="dibujo" aria-hidden="true">
<svg viewBox="0 0 120 120"><path d="M34 34c-10 0-16 7-16 18 0 8 3 12 5 19 2 10 3 27 10 27 5 0 5-15 10-15h4c5 0 5 15 10 15 7 0 8-17 10-27 2-7 5-11 5-19 0-11-6-18-16-18-5 0-8 3-11 3s-6-3-11-3z"/><path d="M92 14v20M82 24h20M100 46v12M94 52h12M84 64v8M80 68h8"/></svg>
</div>
<div class="texto">
<span class="num mono">04 / 05 · ESTÉTICA</span>
<h2 id="t3">Blanqueamiento LED</h2>
<p>Hasta seis tonos más claro en una sesión, con gel que cuida el esmalte.</p>
<ul><li>Sesión de 60 minutos</li><li>Sin sensibilidad posterior</li><li>Kit de mantenimiento en casa</li></ul>
<div class="pie"><span class="precio">desde<b>220 €</b></span><button class="btn" data-servicio="Blanqueamiento">Reservar</button></div>
</div>
</div>
</article>
<div class="marca" data-i="4"></div>
<article class="tarjeta" style="--i:4; --lado:-1" aria-labelledby="t4">
<div class="lamina">
<div class="dibujo" aria-hidden="true">
<svg viewBox="0 0 120 120"><circle cx="56" cy="62" r="38"/><path d="M56 38v24l16 10"/><path d="M96 14v20M86 24h20"/></svg>
</div>
<div class="texto">
<span class="num mono">05 / 05 · URGENCIAS</span>
<h2 id="t4">Urgencias 24 h</h2>
<p>Dolor, golpe o una pieza rota: te atendemos hoy, también fines de semana.</p>
<ul><li>Respuesta en menos de 15 minutos</li><li>Hueco reservado cada día</li><li>Primera valoración sin coste</li></ul>
<div class="pie"><span class="precio">valoración<b>0 €</b></span><button class="btn" data-servicio="Urgencias">Reservar</button></div>
</div>
</div>
</article>
</section>
<section class="cierre">
<h2>Tu primera revisión, <em>sin coste.</em></h2>
<p>Datos de ejemplo para la demostración. Ningún paciente real, ninguna clínica real.</p>
<button class="btn" data-servicio="Primera revisión">Pedir cita</button>
</section>
</main>
<div class="aviso" id="aviso" role="status" aria-live="polite"></div>
<div class="chapa-demo" id="chapa">▶ DEMO · toca para tomar el control</div>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
/* Geometría de la baraja */
--cabecera: 60px; /* alto de la cabecera fija */
--arriba: 76px; /* dónde se pega la primera tarjeta */
--paso: 14px; /* cuánto asoma cada tarjeta por encima de la siguiente */
--n: 5; /* número de tarjetas */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: auto; }
body {
background: var(--fondo);
color: var(--blanco);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
overflow-x: clip; /* «clip» y no «hidden»: no rompe position: sticky */
-webkit-tap-highlight-color: transparent;
}
.mono { font-family: ui-monospace, "JetBrains Mono", Consolas, monospace; }
/* ───────── Cabecera fija con la barra de progreso de lectura ───────── */
.cabecera {
position: fixed; inset: 0 0 auto; z-index: 10;
height: var(--cabecera);
display: flex; align-items: center; gap: 12px;
padding: 0 16px;
background: rgba(5, 7, 10, .82);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid var(--borde);
}
.marca-logo { display: flex; align-items: center; gap: 8px; font-weight: 800; letter-spacing: -.02em; white-space: nowrap; }
.marca-logo svg { width: 26px; height: 26px; }
.marca-logo small { color: var(--gris); font-weight: 500; display: none; }
.cabecera nav { margin-left: auto; }
.indice { display: flex; gap: 4px; list-style: none; }
.indice button {
appearance: none; border: 1px solid var(--borde); background: var(--superficie);
color: var(--gris); font: 600 12px/1 ui-monospace, Consolas, monospace;
width: 34px; height: 34px; border-radius: 10px; cursor: pointer;
transition: color .3s, border-color .3s, background-color .3s;
}
.indice button[aria-current="true"] { color: var(--fondo); background: var(--verde); border-color: var(--verde); }
.indice button:focus-visible, .btn:focus-visible { outline: 2px solid var(--verde); outline-offset: 3px; }
/* La barra de progreso: una línea que crece con transform: scaleX */
.progreso {
position: absolute; left: 0; right: 0; bottom: -1px; height: 3px;
background: var(--verde);
box-shadow: 0 0 12px var(--verde);
transform-origin: 0 50%;
transform: scaleX(0);
will-change: transform;
}
/* ───────── Portada ───────── */
.portada {
padding: calc(var(--cabecera) + 36px) 20px 40px;
max-width: 1080px; margin: 0 auto;
}
.etiqueta { display: inline-flex; gap: 8px; align-items: center; color: var(--verde); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; }
.etiqueta::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--verde); box-shadow: 0 0 10px var(--verde); }
.portada h1 { font: 800 clamp(40px, 11vw, 96px)/.95 system-ui, sans-serif; letter-spacing: -.045em; margin: 16px 0 14px; }
.portada h1 em { font-style: normal; color: var(--verde); }
.portada p { color: var(--gris); max-width: 46ch; line-height: 1.5; font-size: 16px; }
/* ───────── La pila de tarjetas ───────── */
.pila {
max-width: 1080px; margin: 0 auto;
padding: 0 14px 34vh;
/* Las tarjetas leen las líneas de tiempo de las marcas, que son hermanas: las «subimos» aquí */
timeline-scope: --m1, --m2, --m3, --m4;
}
/* Marca invisible en el flujo justo antes de cada tarjeta: indica dónde está su sitio «natural» */
.marca { height: 1px; margin-bottom: -1px; }
.marca[data-i="1"] { view-timeline-name: --m1; }
.marca[data-i="2"] { view-timeline-name: --m2; }
.marca[data-i="3"] { view-timeline-name: --m3; }
.marca[data-i="4"] { view-timeline-name: --m4; }
/* La tarjeta se pega arriba; cada una un poco más abajo que la anterior para que asomen como una baraja */
.tarjeta {
position: sticky;
top: calc(var(--arriba) + var(--i) * var(--paso));
margin-bottom: 26vh;
}
.tarjeta:last-of-type { margin-bottom: 0; }
/* La lámina es lo que se encoge, se oscurece y se inclina (el sticky no se toca) */
.lamina {
position: relative;
min-height: min(560px, calc(100dvh - var(--arriba) - 90px));
border-radius: 26px;
border: 1px solid var(--borde);
background:
radial-gradient(120% 90% at 100% 0%, rgba(0, 255, 127, .10), transparent 55%),
linear-gradient(180deg, #101a14, var(--superficie));
padding: 20px;
display: grid; grid-template-rows: auto 1fr; gap: 16px;
overflow: hidden;
transform-origin: 50% 0; /* encoge desde el borde de arriba: así sigue asomando */
box-shadow: 0 -18px 40px -20px rgba(0, 0, 0, .9);
will-change: transform;
}
/* Velo negro que oscurece la tarjeta cuando queda debajo (se anima su opacidad) */
.lamina::after {
content: ""; position: absolute; inset: 0; border-radius: inherit;
background: #000; opacity: 0; pointer-events: none;
}
/* Una tarjeta en verde para dar golpe de color a mitad de la baraja */
.tarjeta.inversa .lamina { background: linear-gradient(160deg, #19ff8f, #00d96c); color: #04140b; border-color: transparent; }
.tarjeta.inversa .lamina .texto p, .tarjeta.inversa .lamina li { color: #0b3a22; }
.tarjeta.inversa .num, .tarjeta.inversa .precio b { color: #04140b; }
.tarjeta.inversa .dibujo { background: rgba(4, 20, 11, .08); border-color: rgba(4, 20, 11, .15); }
.tarjeta.inversa .dibujo svg { stroke: #04140b; }
.tarjeta.inversa .btn { background: #04140b; color: var(--verde); }
.dibujo {
border-radius: 18px; border: 1px solid var(--borde);
background:
linear-gradient(rgba(0, 255, 127, .06) 1px, transparent 1px) 0 0 / 22px 22px,
linear-gradient(90deg, rgba(0, 255, 127, .06) 1px, transparent 1px) 0 0 / 22px 22px,
rgba(0, 0, 0, .25);
display: grid; place-items: center;
height: clamp(132px, 24dvh, 210px);
}
.dibujo svg { width: 104px; height: 104px; fill: none; stroke: var(--verde); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.texto { display: flex; flex-direction: column; gap: 10px; }
.num { color: var(--verde); font-size: 13px; letter-spacing: .1em; }
.texto h2 { font-size: clamp(26px, 6.6vw, 44px); line-height: 1; letter-spacing: -.035em; }
.texto p { color: var(--gris); line-height: 1.5; font-size: 15px; }
.texto ul { list-style: none; display: grid; gap: 6px; font-size: 14px; }
.texto li { color: var(--blanco); padding-left: 20px; position: relative; }
.texto li::before { content: ""; position: absolute; left: 2px; top: .45em; width: 9px; height: 5px; border: solid currentColor; border-width: 0 0 2px 2px; transform: rotate(-45deg); color: var(--verde); }
.tarjeta.inversa .texto li::before { color: #04140b; }
.pie { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 6px; }
.precio { font-size: 13px; color: inherit; opacity: .85; }
.precio b { display: block; font-size: 24px; color: var(--blanco); letter-spacing: -.02em; }
.btn {
appearance: none; border: 0; cursor: pointer;
font: 700 15px/1 system-ui, sans-serif;
padding: 14px 18px; border-radius: 14px;
background: var(--verde); color: var(--fondo);
transition: transform .2s;
}
.btn:active { transform: scale(.96); }
/* ───────── Cierre ───────── */
.cierre { max-width: 1080px; margin: 0 auto; padding: 40px 20px 120px; text-align: center; }
.cierre h2 { font-size: clamp(30px, 7vw, 64px); letter-spacing: -.04em; line-height: 1; }
.cierre h2 em { font-style: normal; color: var(--verde); }
.cierre p { color: var(--gris); margin: 14px auto 24px; max-width: 40ch; line-height: 1.5; }
/* Aviso al reservar */
.aviso {
position: fixed; left: 50%; bottom: 22px; z-index: 20;
transform: translate(-50%, 140%); opacity: 0;
background: var(--superficie); border: 1px solid var(--verde); color: var(--blanco);
padding: 12px 16px; border-radius: 14px; font-size: 14px;
width: max-content; max-width: calc(100vw - 32px);
transition: transform .45s cubic-bezier(.2, .9, .2, 1), opacity .3s;
}
.aviso.visible { transform: translate(-50%, 0); opacity: 1; }
.chapa-demo {
position: fixed; right: 14px; bottom: 16px; z-index: 15;
font: 600 11px/1 ui-monospace, Consolas, monospace; letter-spacing: .08em;
color: var(--verde); background: rgba(13, 20, 16, .9); border: 1px solid var(--borde);
padding: 8px 10px; border-radius: 999px; transition: opacity .4s;
}
.chapa-demo[hidden] { display: block; opacity: 0; pointer-events: none; }
/* ───────── Escritorio: tarjeta horizontal, texto a la izquierda y dibujo a la derecha ───────── */
@media (min-width: 820px) {
:root { --arriba: 92px; --paso: 18px; }
.marca-logo small { display: inline; }
.indice button { width: 38px; }
.portada { padding: calc(var(--cabecera) + 70px) 32px 60px; }
.portada p { font-size: 18px; }
.pila { padding-left: 32px; padding-right: 32px; }
.lamina {
min-height: min(500px, calc(100dvh - var(--arriba) - 110px));
grid-template-rows: none; grid-template-columns: 1.15fr 1fr;
padding: 36px; gap: 36px;
}
.dibujo { order: 2; height: auto; }
.dibujo svg { width: 190px; height: 190px; stroke-width: 1.8; }
.texto { gap: 14px; }
.texto p { font-size: 17px; max-width: 42ch; }
.texto ul { font-size: 15px; }
}
/* ═════════ LA ANIMACIÓN: scroll-driven (CSS puro) ═════════
Cada tarjeta i escucha la línea de tiempo de la marca de la tarjeta SIGUIENTE (--linea).
El tramo va desde que esa marca asoma por abajo (cover 0%) hasta que la siguiente
tarjeta llega a su sitio pegado (cover 100% menos su «top»). */
@keyframes apilar {
to {
transform: scale(var(--escala-fin)) rotate(var(--giro));
}
}
@keyframes velo { to { opacity: .6; } }
@keyframes leer { to { transform: scaleX(1); } }
.tarjeta {
/* Cuanto más al fondo de la baraja, más pequeña acaba (como Bramus en sus stacking cards) */
--escala-fin: calc(1 - (var(--n) - var(--i)) * .035);
--giro: calc(var(--lado) * 1.6deg);
--fin-tramo: calc(100% - var(--arriba) - (var(--i) + 1) * var(--paso));
}
.con-sd .tarjeta:not(:last-of-type) .lamina {
animation: apilar linear both;
animation-timeline: var(--linea);
animation-range: cover 0% cover var(--fin-tramo);
}
.con-sd .tarjeta:not(:last-of-type) .lamina::after {
animation: velo linear both;
animation-timeline: var(--linea);
animation-range: cover 0% cover var(--fin-tramo);
}
.con-sd .progreso {
animation: leer linear both;
animation-timeline: scroll(root block);
}
/* Sin movimiento: nada de inclinar ni encoger, solo un oscurecido suave */
@media (prefers-reduced-motion: reduce) {
@keyframes apilar { to { transform: none; } }
.tarjeta { --giro: 0deg; --escala-fin: 1; }
.btn, .aviso { transition: none; }
}
(() => {
const raiz = document.documentElement;
const quieto = matchMedia('(prefers-reduced-motion: reduce)').matches;
const tarjetas = [...document.querySelectorAll('.tarjeta')];
const marcas = [...document.querySelectorAll('.marca')];
const progreso = document.getElementById('progreso');
/* 1 · ¿Soporta el navegador animaciones guiadas por scroll? (?modo=js fuerza el respaldo) */
const forzarJS = new URLSearchParams(location.search).get('modo') === 'js';
const conSD = !forzarJS && CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: --a');
raiz.classList.add(conSD ? 'con-sd' : 'sin-sd');
/* Lee el «top» real (en px) donde se pega la tarjeta i */
const topDe = i => parseFloat(getComputedStyle(tarjetas[i]).top);
/* 2 · Respaldo en JS: el mismo cálculo que hace el CSS, frame a frame */
function pintarRespaldo() {
const alto = innerHeight;
for (let i = 0; i < tarjetas.length - 1; i++) {
// Dónde está ahora la marca de la tarjeta siguiente, y dónde se pegará
const y = marcas[i + 1].getBoundingClientRect().top;
const fin = topDe(i + 1);
const p = Math.min(1, Math.max(0, (alto - y) / (alto - fin))); // 0 → 1
const c = getComputedStyle(tarjetas[i]);
const escalaFin = quieto ? 1 : 1 - (tarjetas.length - i) * .035;
const giro = quieto ? 0 : parseFloat(c.getPropertyValue('--lado')) * 1.6;
const lamina = tarjetas[i].firstElementChild;
lamina.style.transform = `scale(${1 + (escalaFin - 1) * p}) rotate(${giro * p}deg)`;
lamina.style.setProperty('--velo', (p * .6).toFixed(3));
}
}
if (!conSD) {
// El velo usa una variable en el modo JS
const s = document.createElement('style');
s.textContent = '.sin-sd .lamina::after{opacity:var(--velo,0)}';
document.head.appendChild(s);
}
/* 3 · Barra de progreso (accesible) + índice activo: siempre en JS ligero */
const indice = document.getElementById('indice');
const botonesIndice = tarjetas.map((t, i) => {
const li = document.createElement('li');
const b = document.createElement('button');
b.textContent = String(i + 1).padStart(2, '0');
b.setAttribute('aria-label', 'Ir a ' + t.querySelector('h2').textContent);
b.addEventListener('click', () => { pararDemo(); irA(i); });
li.appendChild(b); indice.appendChild(li);
return b;
});
let pendiente = false;
function alHacerScroll() {
if (pendiente) return;
pendiente = true;
requestAnimationFrame(() => {
pendiente = false;
const max = raiz.scrollHeight - innerHeight;
const p = max > 0 ? scrollY / max : 0;
progreso.setAttribute('aria-valuenow', Math.round(p * 100));
if (!conSD) { progreso.style.transform = `scaleX(${p})`; pintarRespaldo(); }
// Tarjeta activa = la última cuya marca ya pasó por encima de la mitad de la pantalla
let activa = 0;
marcas.forEach((m, i) => { if (m.getBoundingClientRect().top < innerHeight * .5) activa = i; });
botonesIndice.forEach((b, i) => b.setAttribute('aria-current', i === activa ? 'true' : 'false'));
});
}
addEventListener('scroll', alHacerScroll, { passive: true });
addEventListener('resize', alHacerScroll);
alHacerScroll();
/* Posición de scroll en la que la tarjeta i queda justo en su sitio */
const destinoDe = i => marcas[i].getBoundingClientRect().top + scrollY - topDe(i);
function irA(i) {
scrollTo({ top: destinoDe(i), behavior: quieto ? 'auto' : 'smooth' });
tarjetas[i].querySelector('.btn').focus({ preventScroll: true });
}
/* 4 · Reservar: aviso flotante */
const aviso = document.getElementById('aviso');
let tAviso;
document.querySelectorAll('[data-servicio]').forEach(b => b.addEventListener('click', () => {
aviso.textContent = '✓ Cita de ejemplo solicitada: ' + b.dataset.servicio;
aviso.classList.add('visible');
clearTimeout(tAviso);
tAviso = setTimeout(() => aviso.classList.remove('visible'), 2400);
}));
/* 5 · Modo demostración: baja solo, sube y vuelve a empezar.
Se para al tocar, hacer clic, mover la rueda o pulsar una tecla; vuelve tras 12 s quieto. */
const chapa = document.getElementById('chapa');
let demoActiva = false, ultimo = 0, fase = 'bajar', espera = 0, subida = null, tReanudar;
const facil = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
function pasoDemo(ahora) {
if (!demoActiva) return;
const dt = Math.min(50, ahora - (ultimo || ahora)); ultimo = ahora;
const max = raiz.scrollHeight - innerHeight;
if (fase === 'bajar') {
const velocidad = innerHeight * .42; // px por segundo
const y = Math.min(max, scrollY + velocidad * dt / 1000);
scrollTo(0, y);
if (y >= max - 1) { fase = 'pausa'; espera = 1400; }
} else if (fase === 'pausa') {
espera -= dt;
if (espera <= 0) { fase = 'subir'; subida = { desde: scrollY, t: 0 }; }
} else if (fase === 'subir') {
subida.t = Math.min(1, subida.t + dt / 1800);
scrollTo(0, subida.desde * (1 - facil(subida.t)));
if (subida.t >= 1) { fase = 'reposo'; espera = 700; }
} else if (fase === 'reposo') {
espera -= dt;
if (espera <= 0) fase = 'bajar';
}
requestAnimationFrame(pasoDemo);
}
function arrancarDemo() {
if (quieto || demoActiva) return;
demoActiva = true; ultimo = 0; chapa.hidden = false;
requestAnimationFrame(pasoDemo);
}
function pararDemo() {
demoActiva = false; chapa.hidden = true;
clearTimeout(tReanudar);
tReanudar = setTimeout(() => { fase = 'bajar'; arrancarDemo(); }, 12000);
}
['pointerdown', 'wheel', 'touchstart', 'keydown'].forEach(ev =>
addEventListener(ev, pararDemo, { passive: true }));
if (quieto) chapa.hidden = true; else arrancarDemo();
})();
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
0:49Tarjetas que se apilan al hacer scroll