← AE Lab
▶
Explicado paso a paso en el episodio
5 efectos web que venden (el nº1 se monta con miles de píxeles)
6:07Precios con cuentakilómetros
Precios con cuentakilómetros
Tabla de tres planes con interruptor Mensual / Anual: cada cifra del precio rueda como un cuentakilómetros mecánico con rebote, cae una etiqueta «-20 %» con muelle y el plan recomendado lleva un brillo que recorre su borde. En el móvil, las tarjetas son un carrusel con scroll-snap.
// CÓMO ESTÁ HECHO
- Cada dígito es una ventana de una cifra de alto con overflow oculto, y detrás una tira con 0-9 repetido tres veces que se desplaza con translateY.
- Al cambiar de periodo la tira gira una vuelta entera hasta su cifra con una curva cubic-bezier que se pasa y vuelve (el rebote), con retraso escalonado por dígito y por tarjeta; al acabar salta sin animación a la vuelta del medio.
- La etiqueta «-20 %» cae con una animación de keyframes que imita un muelle (cae, rebota y se asienta ladeada).
- El brillo del recomendado es un cuadrado con degradado cónico que gira detrás de la tarjeta y una capa interior que lo tapa dejando sólo 2 px de borde.
- En móvil la rejilla pasa a flex con overflow-x y scroll-snap-type, con puntos sincronizados; un interruptor role=switch accesible por teclado y prefers-reduced-motion sin giros.
// EL CÓDIGO
<main class="app" id="app">
<header class="cabecera">
<span class="marca mono">Forja · club de entrenamiento</span>
<h1>Entrena a tu ritmo</h1>
<p>Sin matrícula. Cambia de plan cuando quieras.</p>
<div class="periodo">
<!-- Un solo botón con role="switch": Espacio / Intro lo cambian -->
<button class="interruptor" id="interruptor" role="switch" aria-checked="false" aria-label="Pago anual">
<span class="pildora" aria-hidden="true"></span>
<span class="opcion mes">Mensual</span>
<span class="opcion ano">Anual</span>
</button>
<span class="chapa" aria-hidden="true">-20 %</span>
</div>
</header>
<ul class="planes" id="planes" aria-label="Planes"></ul>
<nav class="puntos" id="puntos" aria-label="Ir al plan"></nav>
<p class="oculto" id="aviso" aria-live="polite"></p>
</main>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
--muelle: cubic-bezier(.25, 1.35, .45, 1); /* curva con «rebote»: se pasa un poco y vuelve */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background:
radial-gradient(60% 45% at 50% 0%, rgba(0, 255, 127, .10), transparent 70%),
var(--fondo);
color: var(--blanco);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
overflow-x: hidden;
-webkit-tap-highlight-color: transparent;
}
.mono { font-family: ui-monospace, "JetBrains Mono", "Cascadia Code", monospace; }
.app {
min-height: 100dvh;
display: flex; flex-direction: column; justify-content: center;
gap: clamp(18px, 3.2vh, 34px);
padding: clamp(20px, 4vh, 48px) 0;
}
/* ---------- Cabecera ---------- */
.cabecera { text-align: center; padding: 0 16px; display: grid; justify-items: center; gap: 12px; }
.marca { font-size: 12px; letter-spacing: .22em; text-transform: uppercase; color: var(--verde); }
.cabecera h1 { font-size: clamp(28px, 5vw, 48px); line-height: 1.05; letter-spacing: -.03em; font-weight: 800; }
.cabecera p { color: var(--gris); font-size: 15px; }
/* ---------- Interruptor Mensual / Anual ---------- */
.periodo { position: relative; margin-top: 6px; }
.interruptor {
position: relative;
display: grid; grid-template-columns: 1fr 1fr;
width: 236px; height: 48px; padding: 4px;
border-radius: 999px;
background: var(--superficie);
border: 1px solid var(--borde);
font: inherit; color: inherit; cursor: pointer;
}
.interruptor:focus-visible { outline: 2px solid var(--verde); outline-offset: 4px; }
/* La píldora verde se desliza con transform (nada de animar left) */
.pildora {
position: absolute; top: 4px; left: 4px;
width: calc(50% - 4px); height: 38px;
border-radius: 999px; background: var(--verde);
box-shadow: 0 0 24px rgba(0, 255, 127, .35);
transition: transform .5s var(--muelle);
}
.interruptor[aria-checked="true"] .pildora { transform: translateX(100%); }
.opcion { position: relative; z-index: 1; display: grid; place-items: center; font-weight: 700; font-size: 15px; color: var(--gris); transition: color .3s; }
.interruptor[aria-checked="false"] .opcion.mes,
.interruptor[aria-checked="true"] .opcion.ano { color: var(--fondo); }
/* ---------- Etiqueta «-20 %» que cae con un muelle ---------- */
.chapa {
position: absolute;
font: 800 13px/1 ui-monospace, monospace;
color: var(--fondo); background: var(--verde);
padding: 6px 9px; border-radius: 8px;
box-shadow: 0 6px 18px rgba(0, 255, 127, .35);
opacity: 0; transform: translateY(-40px) rotate(-18deg);
transition: opacity .25s, transform .3s;
pointer-events: none; white-space: nowrap;
}
.periodo .chapa { right: -30px; top: -14px; }
.anual .chapa {
opacity: 1;
animation: caer .9s both;
animation-delay: var(--retraso, 0ms);
}
@keyframes caer { /* cae, rebota y se asienta un poco ladeada */
0% { opacity: 0; transform: translateY(-46px) rotate(-22deg); animation-timing-function: cubic-bezier(.5, 0, .9, .5); }
40% { opacity: 1; transform: translateY(5px) rotate(4deg); }
62% { transform: translateY(-7px) rotate(-11deg); }
80% { transform: translateY(2px) rotate(-5deg); }
100% { opacity: 1; transform: translateY(0) rotate(-8deg); }
}
/* ---------- Tarjetas ---------- */
.planes {
display: grid; grid-template-columns: repeat(3, minmax(0, 320px));
justify-content: center; align-items: center;
gap: 22px; padding: 18px 24px; list-style: none;
}
.plan {
position: relative; isolation: isolate;
display: flex; flex-direction: column; gap: 16px;
padding: 26px 24px 24px;
border-radius: 22px;
background: var(--superficie);
border: 1px solid var(--borde);
}
.plan h2 { font-size: 20px; letter-spacing: -.01em; }
.plan .lema { color: var(--gris); font-size: 14px; margin-top: 4px; }
.plan .chapa { top: 18px; right: 18px; }
/* El recomendado: un poco más grande y con un brillo que recorre su borde.
Truco: un cuadrado enorme con degradado cónico GIRA (transform) detrás,
y una capa interior del color de la tarjeta lo tapa dejando 2 px de borde. */
.plan.recomendado { transform: scale(1.05); border-color: transparent; overflow: hidden; z-index: 1; box-shadow: 0 30px 70px rgba(0, 0, 0, .55), 0 0 60px rgba(0, 255, 127, .08); }
.plan.recomendado::before {
content: ""; position: absolute; z-index: -2;
left: 50%; top: 50%; width: 180%; aspect-ratio: 1; margin: -90% 0 0 -90%;
background: conic-gradient(from 0deg, transparent 0 55%, rgba(0, 255, 127, .3) 70%, var(--verde) 85%, #fff 88%, var(--verde) 90%, transparent 95%);
animation: girar 4.5s linear infinite;
}
.plan.recomendado::after {
content: ""; position: absolute; z-index: -1; inset: 2px;
border-radius: 20px;
background: linear-gradient(180deg, #0F1A14, var(--superficie) 55%);
}
@keyframes girar { to { transform: rotate(1turn); } }
.insignia {
align-self: flex-start;
font: 700 11px/1 ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase;
color: var(--verde); border: 1px solid rgba(0, 255, 127, .4); border-radius: 999px; padding: 6px 10px;
}
/* ---------- El cuentakilómetros ---------- */
.precio { display: flex; align-items: flex-end; gap: 6px; }
.cifras {
display: flex; align-items: flex-start;
font: 800 64px/1 ui-monospace, "JetBrains Mono", monospace;
letter-spacing: -.04em;
font-variant-numeric: tabular-nums;
}
/* Cada dígito es una ventanita de 1 cifra de alto... */
.rueda {
height: 1.2em; width: .62em; overflow: hidden; position: relative;
/* difumina arriba y abajo: parece un tambor que gira */
-webkit-mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);
mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);
}
/* ...detrás de la cual se desliza una tira con 0-9 repetido tres veces */
.tira { display: flex; flex-direction: column; will-change: transform; }
.tira span { height: 1.2em; display: grid; place-items: center; }
/* Céntimos pequeños y arriba, con su coma: «29,⁹⁰» */
.cifras .decimal { font-size: .42em; display: flex; margin-top: .25em; margin-left: .05em; }
.cifras .coma { width: .45em; height: 1.2em; display: grid; place-items: center; }
.moneda { font-size: 24px; font-weight: 700; padding-bottom: 14px; }
.por { color: var(--gris); font-size: 14px; padding-bottom: 16px; margin-left: -2px; }
/* Dos textos que se cruzan con opacidad según el periodo */
.nota { position: relative; height: 18px; font-size: 13px; }
.nota span { position: absolute; inset: 0; transition: opacity .4s, transform .4s; white-space: nowrap; }
.nota .n-ano { opacity: 0; transform: translateY(6px); color: var(--verde); }
.anual .nota .n-mes { opacity: 0; transform: translateY(-6px); }
.anual .nota .n-ano { opacity: 1; transform: none; }
.nota .n-mes { color: var(--gris); }
.ventajas { list-style: none; display: grid; gap: 10px; font-size: 14px; border-top: 1px solid var(--borde); padding-top: 16px; }
.ventajas li { display: flex; gap: 10px; align-items: flex-start; }
.ventajas li::before { content: ""; flex: none; width: 14px; height: 8px; margin-top: 4px; border: solid var(--verde); border-width: 0 0 2px 2px; transform: rotate(-45deg); }
.ventajas li.no { color: var(--gris); }
.ventajas li.no::before { border-color: var(--borde); }
.boton {
margin-top: 4px; height: 48px; border-radius: 14px; font: 700 15px/1 inherit; font-family: inherit;
background: transparent; color: var(--blanco); border: 1px solid var(--borde); cursor: pointer;
transition: background .25s, border-color .25s, transform .2s;
}
.boton:hover { border-color: var(--verde); }
.boton:active { transform: scale(.97); }
.boton:focus-visible { outline: 2px solid var(--verde); outline-offset: 3px; }
.recomendado .boton { background: var(--verde); color: var(--fondo); border-color: var(--verde); }
/* Puntos del carrusel (sólo en móvil) */
.puntos { display: none; justify-content: center; gap: 8px; }
.puntos button { width: 28px; height: 28px; border: 0; background: none; cursor: pointer; display: grid; place-items: center; }
.puntos button::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--borde); transition: transform .3s, background .3s; }
.puntos button[aria-current="true"]::before { background: var(--verde); transform: scale(1.4); }
.puntos button:focus-visible { outline: 2px solid var(--verde); border-radius: 50%; }
/* ---------- Móvil: carrusel con scroll-snap ---------- */
@media (max-width: 860px) {
.planes {
display: flex; overflow-x: auto; overscroll-behavior-x: contain;
scroll-snap-type: x mandatory; scrollbar-width: none;
gap: 14px; padding: 22px 11vw;
}
.planes::-webkit-scrollbar { display: none; }
.plan { flex: 0 0 78vw; max-width: 330px; scroll-snap-align: center; }
.plan.recomendado { transform: none; }
.puntos { display: flex; }
.cifras { font-size: 54px; }
}
@media (max-height: 760px) and (max-width: 860px) {
.ventajas li:nth-child(n+4) { display: none; }
}
/* ---------- Sin movimiento brusco ---------- */
@media (prefers-reduced-motion: reduce) {
.tira, .pildora { transition: none !important; }
.plan.recomendado::before { animation: none; transform: rotate(140deg); }
.anual .chapa { animation: none; transform: rotate(-8deg); transition: opacity .3s; }
.planes { scroll-behavior: auto; }
}
.oculto { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
(() => {
/* ---------- 1. Datos de ejemplo ---------- */
const DESCUENTO = 0.20;
const PLANES = [
{ nombre: 'Básico', lema: 'Sala de máquinas, sin agobios', mes: 29.90,
ventajas: ['Sala de musculación y cardio', 'Vestuarios y taquilla', 'App con tus rutinas', ['Clases dirigidas', false]] },
{ nombre: 'Pro', lema: 'El que elige casi todo el mundo', mes: 49.90, recomendado: true,
ventajas: ['Todo lo del Básico', 'Clases dirigidas ilimitadas', '1 sesión de entrenador al mes', 'Invita a un amigo cada mes'] },
{ nombre: 'Élite', lema: 'Entrenador personal y spa', mes: 89.90,
ventajas: ['Todo lo del Pro', '4 sesiones de entrenador al mes', 'Zona spa y sauna', 'Plan de nutrición'] },
];
const euros = n => n.toFixed(2).replace('.', ',');
const quieto = matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ---------- 2. Pintar las tarjetas ---------- */
const lista = document.getElementById('planes');
const puntos = document.getElementById('puntos');
const ruedas = []; // [{tira, tarjeta, posicion}] — una por cada dígito
// Una rueda: ventana + tira con 0-9 tres veces (30 casillas).
// Reposa en la vuelta del medio; para girar va a la de arriba o a la de abajo.
function crearRueda(tarjeta, posicion) {
const rueda = document.createElement('span');
rueda.className = 'rueda';
const tira = document.createElement('span');
tira.className = 'tira';
for (let i = 0; i < 30; i++) tira.insertAdjacentHTML('beforeend', `<span>${i % 10}</span>`);
rueda.append(tira);
const r = { tira, tarjeta, posicion, digito: 0 };
colocar(r, 10, false);
// Al acabar el giro, salta sin animación a la vuelta del medio (se ve igual)
tira.addEventListener('transitionend', () => colocar(r, 10 + r.digito, false));
ruedas.push(r);
return rueda;
}
// Mueve la tira a la casilla «indice» (cada casilla mide 1,2em)
function colocar(r, indice, animado, retraso = 0) {
if (!animado) {
r.tira.style.transition = 'none';
r.tira.style.transform = `translateY(${-indice * 1.2}em)`;
r.tira.offsetHeight; // fuerza a que se aplique antes de reactivar la transición
r.tira.style.transition = '';
return;
}
r.tira.style.transition = `transform 1.25s cubic-bezier(.2, 1.2, .35, 1) ${retraso}ms`;
r.tira.style.transform = `translateY(${-indice * 1.2}em)`;
}
PLANES.forEach((p, t) => {
const li = document.createElement('li');
li.className = 'plan' + (p.recomendado ? ' recomendado' : '');
li.id = 'plan-' + t;
li.innerHTML = `
${p.recomendado ? '<span class="insignia">Recomendado</span>' : ''}
<span class="chapa" aria-hidden="true" style="--retraso:${250 + t * 140}ms">-20 %</span>
<div><h2>${p.nombre}</h2><p class="lema">${p.lema}</p></div>
<div>
<div class="precio">
<span class="cifras" aria-hidden="true"></span>
<span class="moneda">€</span><span class="por">/mes</span>
<span class="oculto precio-texto"></span>
</div>
<p class="nota">
<span class="n-mes">Sin permanencia</span>
<span class="n-ano">${euros(p.mes * (1 - DESCUENTO) * 12)} € al año · ahorras ${euros(p.mes * DESCUENTO * 12)} €</span>
</p>
</div>
<ul class="ventajas">${p.ventajas.map(v => Array.isArray(v)
? `<li class="no"><span>${v[0]}</span></li>` : `<li><span>${v}</span></li>`).join('')}</ul>
<button class="boton" type="button">Empezar con ${p.nombre}</button>`;
// Cifras: 2 enteros, coma, 2 decimales → 4 ruedas
const cifras = li.querySelector('.cifras');
cifras.append(crearRueda(t, 0), crearRueda(t, 1));
const dec = document.createElement('span');
dec.className = 'decimal';
dec.innerHTML = '<span class="coma">,</span>';
dec.append(crearRueda(t, 2), crearRueda(t, 3));
cifras.append(dec);
lista.append(li);
const punto = document.createElement('button');
punto.type = 'button';
punto.setAttribute('aria-label', 'Plan ' + p.nombre);
punto.addEventListener('click', () => irA(t));
puntos.append(punto);
});
/* ---------- 3. Cambiar de periodo: las ruedas giran ---------- */
const app = document.getElementById('app');
const interruptor = document.getElementById('interruptor');
const aviso = document.getElementById('aviso');
let anual = false;
function mostrar(esAnual) {
anual = esAnual;
interruptor.setAttribute('aria-checked', esAnual);
app.classList.toggle('anual', esAnual);
PLANES.forEach((p, t) => {
const precio = esAnual ? p.mes * (1 - DESCUENTO) : p.mes;
const digitos = euros(precio).replace(',', '').padStart(4, '0').split('').map(Number);
ruedas.filter(r => r.tarjeta === t).forEach(r => {
r.digito = digitos[r.posicion];
// Anual: el precio baja → la tira cae a la vuelta de arriba. Mensual: sube a la de abajo.
const destino = esAnual ? r.digito : 20 + r.digito;
// Retraso escalonado: por tarjeta y, dentro de ella, por dígito
colocar(r, destino, !quieto, t * 120 + r.posicion * 70);
if (quieto) colocar(r, 10 + r.digito, false);
});
lista.children[t].querySelector('.precio-texto').textContent = `${euros(precio)} euros al mes`;
});
aviso.textContent = esAnual ? 'Pago anual: 20 % de descuento' : 'Pago mensual';
}
interruptor.addEventListener('click', () => mostrar(!anual));
/* ---------- 4. Carrusel en móvil: puntos sincronizados ---------- */
const tarjetas = [...lista.children];
function irA(t, suave = true) {
const c = tarjetas[t];
lista.scrollTo({ left: c.offsetLeft - (lista.clientWidth - c.offsetWidth) / 2, behavior: suave && !quieto ? 'smooth' : 'auto' });
}
function marcarPunto() {
const centro = lista.scrollLeft + lista.clientWidth / 2;
let cerca = 0, mejor = Infinity;
tarjetas.forEach((c, i) => {
const d = Math.abs(c.offsetLeft + c.offsetWidth / 2 - centro);
if (d < mejor) { mejor = d; cerca = i; }
});
[...puntos.children].forEach((b, i) => b.setAttribute('aria-current', i === cerca));
return cerca;
}
lista.addEventListener('scroll', () => requestAnimationFrame(marcarPunto), { passive: true });
irA(1, false); // empieza centrado en el recomendado
marcarPunto();
/* ---------- 5. Arranque: de 00,00 a su precio ---------- */
requestAnimationFrame(() => mostrar(false));
/* ---------- 6. Modo demostración: cambia solo hasta que alguien toca ---------- */
let paso = 0, demo;
function tic() {
paso++;
mostrar(!anual);
// En móvil, cada dos cambios pasa a la tarjeta siguiente para enseñarlas todas
if (paso % 2 === 0 && getComputedStyle(puntos).display !== 'none') irA((marcarPunto() + 1) % 3);
demo = setTimeout(tic, 2600);
}
demo = setTimeout(tic, 1500); // el primer cambio llega pronto: se ve vivo enseguida
const parar = () => {
clearTimeout(demo);
['pointerdown', 'keydown', 'wheel', 'touchstart'].forEach(e => removeEventListener(e, parar, true));
};
['pointerdown', 'keydown', 'wheel', 'touchstart'].forEach(e => addEventListener(e, parar, { capture: true, passive: true }));
})();
▶
Explicado paso a paso en el episodio
5 efectos web que venden (el nº1 se monta con miles de píxeles)
6:07Precios con cuentakilómetros