← AE Lab
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
5:55El precio que se regatea
El precio que se regatea
Una ficha de producto con un deslizador para regatear: al bajar el precio, las cifras ruedan, los extras se caen de la lista y la cara del vendedor pasa de feliz a sudar y echar humo. Si te pasas, «¡Ni hablar!» y el deslizador rebota con un muelle hasta su mínimo.
// CÓMO ESTÁ HECHO
- Un input type=range real e invisible encima de un riel dibujado: teclado, lector de pantalla y táctil vienen gratis.
- El precio se convierte en una «tensión» de 0 a 1,45 y ese único número lo controla todo: cara, frases, extras y colores.
- Cada rasgo de la cara SVG tiene 5 valores clave (cejas, boca, ojos, rubor, sudor, vapor) y se interpola de forma continua con transform y opacity.
- Al soltar por debajo del mínimo, un muelle con rozamiento (fuerza = −k·x − c·v) lleva el precio de vuelta pasándose un poco, y luego «¡Trato hecho!» con confeti.
- Un guion por tiempo mueve un dedo fantasma en bucle con requestAnimationFrame y se detiene al primer toque o tecla.
// EL CÓDIGO
<main class="escena">
<!-- El vendedor: su cara es un SVG que se interpola con la «tensión» -->
<section class="vendedor" aria-live="polite">
<div class="cara-caja">
<div class="aura verde"></div>
<div class="aura roja" id="auraRoja"></div>
<svg class="cara" viewBox="0 0 200 200" aria-hidden="true">
<g id="vapor" opacity="0">
<g class="pieza humo"><circle cx="26" cy="96" r="9" fill="#F2F2F2"/><circle cx="18" cy="84" r="7" fill="#F2F2F2"/><circle cx="30" cy="80" r="6" fill="#F2F2F2"/></g>
<g class="pieza humo"><circle cx="174" cy="96" r="9" fill="#F2F2F2"/><circle cx="182" cy="84" r="7" fill="#F2F2F2"/><circle cx="170" cy="80" r="6" fill="#F2F2F2"/></g>
</g>
<g id="cabeza" class="pieza">
<circle cx="34" cy="122" r="14" fill="#E3AF84"/>
<circle cx="166" cy="122" r="14" fill="#E3AF84"/>
<ellipse cx="100" cy="120" rx="68" ry="74" fill="#EFC49A"/>
<ellipse id="rojo" cx="100" cy="120" rx="68" ry="74" fill="#FF3B30" opacity="0"/>
<!-- gorra del puesto -->
<path d="M32 98 C30 30 170 30 168 98 Z" fill="#00C866"/>
<path d="M32 98 C30 30 100 34 100 50 C100 34 170 30 168 98 Z" fill="#00FF7F" opacity=".35"/>
<ellipse cx="118" cy="98" rx="66" ry="9" fill="#009E50"/>
<text x="100" y="80" text-anchor="middle" font-family="ui-monospace,monospace" font-weight="800" font-size="22" fill="#05140B">14</text>
<rect id="cejaI" class="pieza" x="56" y="108" width="32" height="8" rx="4" fill="#3A2A1E"/>
<rect id="cejaD" class="pieza" x="112" y="108" width="32" height="8" rx="4" fill="#3A2A1E"/>
<g id="ojos">
<g class="pieza ojo"><ellipse cx="74" cy="132" rx="7.5" ry="9.5" fill="#1A1410"/><circle cx="76.5" cy="128.5" r="2.4" fill="#fff"/></g>
<g class="pieza ojo"><ellipse cx="126" cy="132" rx="7.5" ry="9.5" fill="#1A1410"/><circle cx="128.5" cy="128.5" r="2.4" fill="#fff"/></g>
</g>
<ellipse class="mejilla" cx="58" cy="154" rx="12" ry="7" fill="#FF6B6B" opacity=".15"/>
<ellipse class="mejilla" cx="142" cy="154" rx="12" ry="7" fill="#FF6B6B" opacity=".15"/>
<ellipse cx="100" cy="150" rx="10" ry="8" fill="#DE9F74"/>
<path id="boca" class="pieza boca" d="M82 176 Q100 192 118 176" stroke="#3A1A12" stroke-width="5" fill="none" stroke-linecap="round"/>
<ellipse id="grito" class="pieza" cx="100" cy="180" rx="13" ry="10" fill="#3A1A12" opacity="0"/>
<path id="bigote" class="pieza" d="M72 166 q14 -14 28 -5 q14 -9 28 5 q-14 5 -28 -1 q-14 6 -28 1z" fill="#3A2A1E"/>
<path id="sudor" class="pieza" d="M160 100 q8 11 0 18 q-8 -7 0 -18z" fill="#7FD8FF" opacity="0"/>
</g>
</svg>
</div>
<div class="habla">
<p class="bocadillo" id="bocadillo">¡Está como nueva!</p>
<p class="quien">Ramón · puesto 14</p>
</div>
</section>
<!-- La tarjeta del producto -->
<section class="tarjeta" id="tarjeta">
<p class="etiqueta">Mercadillo del domingo · segunda mano</p>
<div class="cabecera">
<svg class="bici" viewBox="0 0 160 100" aria-hidden="true">
<g class="pieza luz" id="biciLuz"><path d="M121 46 L160 36 L160 58 Z" fill="#FFE58A" opacity=".35"/><circle cx="120" cy="46" r="3.5" fill="#FFE58A"/></g>
<g fill="none" stroke="#E8F5EE" stroke-width="3"><circle cx="34" cy="72" r="23"/><circle cx="126" cy="72" r="23"/></g>
<g fill="none" stroke="#00FF7F" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
<path d="M34 72 L72 72 L58 38 Z M72 72 L106 38 M58 38 L104 38 M106 30 L126 72"/>
<path d="M58 38 L56 30 M48 29 L64 29 M106 30 L108 22 L120 20" stroke="#E8F5EE"/>
</g>
<circle cx="72" cy="72" r="5" fill="#E8F5EE"/>
<g class="pieza" id="biciCesta"><rect x="110" y="24" width="28" height="17" rx="3" fill="#C98B45"/><path d="M116 24v17M123 24v17M130 24v17" stroke="#8A5A26" stroke-width="2"/></g>
<g class="pieza" id="biciCasco"><path d="M44 27 a12 11 0 0 1 24 0 z" fill="#FF5E7E"/><path d="M50 20 h12" stroke="#fff" stroke-width="2" opacity=".6"/></g>
</svg>
<div>
<h1>Bici de paseo «Gacela»</h1>
<p>Revisada, cuadro de acero y 3 marchas.</p>
</div>
</div>
<div class="precio-fila sin-regateo" id="precioFila">
<div class="precio" aria-hidden="true"><span class="cifra"></span><span class="cifra"></span><span class="cifra"></span><span class="moneda">€</span></div>
<div class="antes"><s>240 €</s><span class="ahorro" id="ahorro">−0 €</span></div>
</div>
<ul class="extras" id="extras" aria-label="Lo que incluye">
<li data-hasta="232"><svg viewBox="0 0 24 24"><path d="M4 15a8 8 0 0 1 16 0z M4 15h17 M12 7v8"/></svg><span class="texto">Casco de regalo</span><span class="valor">+15 €</span></li>
<li data-hasta="220"><svg viewBox="0 0 24 24"><path d="M4 10h16l-2 9H6z M8 10l4-5 4 5 M9 13v3 M15 13v3"/></svg><span class="texto">Cesta de mimbre</span><span class="valor">+12 €</span></li>
<li data-hasta="208"><svg viewBox="0 0 24 24"><path d="M9 18h6 M10 21h4 M12 3a6 6 0 0 0-4 10.5c1 1 1.5 2 1.5 3.5h5c0-1.5.5-2.5 1.5-3.5A6 6 0 0 0 12 3z"/></svg><span class="texto">Luces delante y detrás</span><span class="valor">+10 €</span></li>
<li data-hasta="196"><svg viewBox="0 0 24 24"><path d="M2 7h11v9H2z M13 10h5l3 3v3h-8 M6 19a2 2 0 1 0 0-.1 M17 19a2 2 0 1 0 0-.1"/></svg><span class="texto">Te la llevo a casa</span><span class="valor">+15 €</span></li>
<li data-hasta="184"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z M8.5 12l2.5 2.5 4.5-5"/></svg><span class="texto">Garantía de 6 meses</span><span class="valor">+20 €</span></li>
</ul>
<div class="pista-txt"><span><span class="flecha">←</span> <b>Desliza para regatear</b></span><span>Tu oferta</span></div>
<div class="deslizador" id="deslizador">
<div class="riel"><div class="relleno" id="relleno"></div><div class="zona-roja" id="zonaRoja"></div></div>
<div class="tirador" id="tirador">‹›</div>
<input type="range" id="oferta" min="140" max="240" step="1" value="240"
aria-label="Tu oferta por la bici" aria-valuetext="240 euros">
</div>
<button class="cta" id="cta" type="button">Lo quiero por 240 €</button>
<div class="sello" id="sello" role="status"><span class="manos">🤝</span><b>¡Trato hecho!</b><small id="selloPrecio">por 175 €</small></div>
</section>
</main>
<div class="dedo" id="dedo" aria-hidden="true"></div>
<div class="confeti" id="confeti" aria-hidden="true"></div>
<p class="aviso" id="aviso" role="status"></p>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
--rojo: #FF4D4D;
--mono: ui-monospace, "JetBrains Mono", Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { min-height: 100%; }
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;
}
/* Escena: en el móvil, una columna; en el ordenador, la cara grande a un lado */
.escena {
min-height: 100dvh;
display: flex; flex-direction: column; justify-content: center;
gap: 14px; padding: 16px;
max-width: 440px; margin: 0 auto;
position: relative;
}
/* ---------- El vendedor ---------- */
.vendedor { display: flex; align-items: center; gap: 12px; position: relative; }
.cara-caja { position: relative; flex: 0 0 auto; width: 136px; aspect-ratio: 1; }
.aura { position: absolute; inset: -18%; border-radius: 50%; pointer-events: none; }
.aura.verde { background: radial-gradient(closest-side, rgba(0,255,127,.28), transparent); }
.aura.roja { background: radial-gradient(closest-side, rgba(255,60,50,.55), transparent); opacity: 0; }
.cara { position: relative; width: 100%; height: 100%; overflow: visible; }
/* Todas las piezas de la cara giran/escalan sobre su propio centro */
.cara .pieza { transform-box: fill-box; transform-origin: center; }
.cara .boca { vector-effect: non-scaling-stroke; }
.habla { flex: 1; min-width: 0; display: grid; gap: 6px; }
.bocadillo {
position: relative; justify-self: start;
background: var(--blanco); color: #0B0F0C;
font: 800 17px/1.2 system-ui, sans-serif;
padding: 11px 15px; border-radius: 16px;
transition: background-color .25s, color .25s;
}
.bocadillo::after { /* piquito hacia la cara */
content: ""; position: absolute; left: -7px; top: 50%; width: 14px; height: 14px; margin-top: -7px;
background: inherit; transform: rotate(45deg); border-radius: 2px;
}
.bocadillo.grito { background: var(--rojo); color: #fff; }
.bocadillo.trato { background: var(--verde); color: #04140B; }
.quien { font: 12px var(--mono); color: var(--gris); }
/* ---------- La tarjeta del producto ---------- */
.tarjeta {
position: relative;
background: var(--superficie); border: 1px solid var(--borde);
border-radius: 24px; padding: 16px 16px 18px;
display: grid; gap: 12px;
box-shadow: 0 30px 60px rgba(0,0,0,.45);
}
.etiqueta { font: 600 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--verde); }
.cabecera { display: grid; grid-template-columns: 44% 1fr; align-items: center; gap: 10px; }
.bici { width: 100%; height: auto; overflow: visible; }
.bici .pieza { transform-box: fill-box; transform-origin: center; transition: transform .7s cubic-bezier(.5,0,.7,.4), opacity .5s; }
.bici .pieza.fuera { opacity: 0; transform: translateY(46px) rotate(35deg); }
.bici .luz.fuera { transform: none; transition: opacity .25s; }
.cabecera h1 { font: 800 19px/1.15 system-ui, sans-serif; letter-spacing: -.02em; }
.cabecera p { color: var(--gris); font-size: 13px; line-height: 1.4; margin-top: 4px; }
/* Precio con cifras que ruedan */
.precio-fila { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; }
.precio { display: flex; align-items: baseline; font: 800 52px/1 var(--mono); letter-spacing: -.04em; }
.cifra { display: inline-block; height: 1em; overflow: hidden;
-webkit-mask-image: linear-gradient(transparent, #000 22%, #000 78%, transparent); mask-image: linear-gradient(transparent, #000 22%, #000 78%, transparent); }
.tira { display: flex; flex-direction: column; transition: transform .32s cubic-bezier(.25,1.2,.4,1); will-change: transform; }
.tira span { height: 1em; }
.moneda { color: var(--verde); font-size: .6em; margin-left: 4px; }
.antes { display: grid; gap: 4px; padding-bottom: 6px; font-size: 13px; color: var(--gris); }
.antes s { transition: opacity .3s; }
.ahorro { font: 700 12px var(--mono); color: #04140B; background: var(--verde); border-radius: 999px; padding: 3px 8px; justify-self: start; transition: opacity .3s, transform .3s; }
.sin-regateo .antes s, .sin-regateo .ahorro { opacity: 0; }
.sin-regateo .ahorro { transform: translateY(6px); }
/* Extras que se van cayendo */
.extras { list-style: none; display: grid; gap: 2px; }
.extras li {
position: relative; display: grid; grid-template-columns: 26px 1fr auto; align-items: center; gap: 10px;
padding: 6px 4px; font-size: 14.5px;
}
.extras svg { width: 22px; height: 22px; fill: none; stroke: var(--verde); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round;
transition: transform .6s cubic-bezier(.5,0,.7,.4), opacity .5s; }
.extras .texto, .extras .valor { transition: opacity .35s; }
.extras .valor { font: 12px var(--mono); color: var(--gris); }
.extras li::after { /* raya que tacha el extra */
content: ""; position: absolute; left: 34px; right: 0; top: 50%; height: 2px; background: var(--rojo);
transform: scaleX(0); transform-origin: left; transition: transform .35s ease-out;
}
.extras li.cae svg { transform: translateY(26px) rotate(-70deg); opacity: 0; }
.extras li.cae .texto, .extras li.cae .valor { opacity: .32; }
.extras li.cae::after { transform: scaleX(1); }
/* Deslizador: un input range de verdad, invisible, encima de un dibujo propio */
.pista-txt { display: flex; justify-content: space-between; align-items: center; font-size: 13px; color: var(--gris); }
.pista-txt b { color: var(--blanco); font-weight: 600; }
.flecha { display: inline-block; color: var(--verde); animation: empuja 1.4s ease-in-out infinite; }
@keyframes empuja { 50% { transform: translateX(-5px); } }
.deslizador { position: relative; height: 52px; }
.riel { position: absolute; left: 22px; right: 22px; top: 50%; height: 10px; margin-top: -5px; border-radius: 99px; background: var(--borde); overflow: hidden; }
.zona-roja { position: absolute; left: 0; top: 0; bottom: 0; background: repeating-linear-gradient(-45deg, rgba(255,77,77,.55) 0 5px, rgba(255,77,77,.18) 5px 10px); opacity: .45; transition: opacity .3s; }
.relleno { position: absolute; inset: 0; background: linear-gradient(90deg, #00b35a, var(--verde)); transform-origin: left; will-change: transform; }
.tirador {
position: absolute; left: 0; top: 50%; width: 44px; height: 44px; margin: -22px 0 0 0;
border-radius: 50%; background: var(--blanco); color: #0B0F0C;
display: grid; place-items: center; font: 800 15px var(--mono);
box-shadow: 0 6px 18px rgba(0,0,0,.5), 0 0 0 6px rgba(0,255,127,.18);
will-change: transform; pointer-events: none;
}
.deslizador:has(input:focus-visible) .tirador { outline: 3px solid var(--verde); outline-offset: 4px; }
.deslizador.peligro .tirador { background: var(--rojo); color: #fff; box-shadow: 0 6px 18px rgba(0,0,0,.5), 0 0 0 6px rgba(255,77,77,.25); }
.deslizador.peligro .zona-roja { opacity: 1; }
.deslizador input {
position: absolute; inset: 0; width: 100%; height: 100%; margin: 0;
-webkit-appearance: none; appearance: none; background: transparent; opacity: 0;
cursor: grab; touch-action: pan-y;
}
.deslizador input::-webkit-slider-thumb { -webkit-appearance: none; width: 44px; height: 44px; }
.deslizador input::-moz-range-thumb { width: 44px; height: 44px; border: 0; }
.cta {
appearance: none; border: 0; cursor: pointer;
font: 800 17px system-ui, sans-serif; color: #04140B; background: var(--verde);
border-radius: 16px; padding: 15px; min-height: 52px;
}
.cta:focus-visible { outline: 3px solid var(--blanco); outline-offset: 3px; }
/* Sello «¡Trato hecho!» */
.sello {
position: absolute; left: 50%; top: 38%; z-index: 3;
display: grid; justify-items: center; gap: 2px;
padding: 14px 22px; border-radius: 20px;
background: rgba(5,7,10,.88); border: 2px solid var(--verde);
box-shadow: 0 20px 50px rgba(0,0,0,.6), 0 0 40px rgba(0,255,127,.25);
transform: translate(-50%, -50%) scale(.6) rotate(-8deg); opacity: 0; pointer-events: none;
transition: transform .5s cubic-bezier(.3,1.7,.5,1), opacity .25s;
white-space: nowrap;
}
.sello.visible { transform: translate(-50%, -50%) scale(1) rotate(-6deg); opacity: 1; }
.sello .manos { font-size: 40px; line-height: 1; }
.sello b { font: 900 24px system-ui, sans-serif; color: var(--verde); letter-spacing: -.02em; }
.sello small { font: 13px var(--mono); color: var(--blanco); }
/* Dedo fantasma del modo demostración */
.dedo {
position: fixed; left: 0; top: 0; width: 46px; height: 46px; z-index: 10; pointer-events: none;
border-radius: 50%; background: rgba(232,245,238,.28); border: 2px solid rgba(232,245,238,.85);
box-shadow: 0 0 0 8px rgba(232,245,238,.12);
opacity: 0; will-change: transform, opacity; transition: opacity .25s;
}
.dedo.visible { opacity: 1; }
.confeti { position: fixed; inset: 0; pointer-events: none; z-index: 9; overflow: hidden; }
.confeti i { position: absolute; left: 0; top: 0; width: 8px; height: 12px; border-radius: 2px; will-change: transform; }
.aviso {
position: fixed; left: 50%; bottom: calc(20px + env(safe-area-inset-bottom)); z-index: 11;
background: var(--blanco); color: #0B0F0C; font-weight: 700; font-size: 15px;
padding: 12px 18px; border-radius: 14px; max-width: calc(100% - 32px); text-align: center;
transform: translate(-50%, 20px); opacity: 0; transition: transform .3s, opacity .3s; pointer-events: none;
}
.aviso.visible { transform: translate(-50%, 0); opacity: 1; }
/* Ordenador: la cara, grande a la izquierda; la tarjeta a la derecha */
@media (min-width: 860px) {
.escena { max-width: none; flex-direction: row; align-items: center; justify-content: center; gap: clamp(40px, 6vw, 96px); padding: 24px 40px; }
.vendedor { flex-direction: column-reverse; gap: 22px; }
.cara-caja { width: clamp(220px, min(30vw, 46vh), 380px); }
.habla { justify-items: center; text-align: center; }
.bocadillo { justify-self: center; font-size: clamp(20px, 2.2vw, 28px); padding: 14px 22px; border-radius: 20px; }
.bocadillo::after { left: 50%; top: auto; bottom: -7px; margin: 0 0 0 -7px; }
.quien { font-size: 13px; order: -1; }
.tarjeta { width: 430px; padding: 20px 22px 22px; }
}
@media (min-width: 860px) and (max-height: 760px) {
.tarjeta { gap: 9px; padding: 16px 20px 18px; }
.precio { font-size: 46px; }
.extras li { padding: 4px; }
}
@media (prefers-reduced-motion: reduce) {
.tira { transition-duration: .12s; transition-timing-function: linear; }
.flecha { animation: none; }
.bici .pieza.fuera, .extras li.cae svg { transform: none; }
.sello, .sello.visible { transition: opacity .3s; transform: translate(-50%, -50%) rotate(-6deg); }
}
(() => {
const $ = (id) => document.getElementById(id);
const sinMovimiento = matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ── 1 · Del precio a la «tensión» ─────────────────────────
Un solo número controla todo: 0 = Ramón feliz,
1 = has llegado a su mínimo, más de 1 = indignado. */
const PRECIO = { pide: 240, minimo: 175, suelo: 140 };
const tension = (p) => (PRECIO.pide - p) / (PRECIO.pide - PRECIO.minimo);
/* ── 2 · La cara se interpola entre «fotogramas clave» ──────
Para cada rasgo damos 5 valores (feliz → dudando → sudando
→ en su mínimo → indignado) y mezclamos de forma continua. */
const T = [0, 0.35, 0.7, 1, 1.45];
const CARA = {
ceja: [-6, -14, -22, 8, 26], // grados (+ = enfadado)
cejaY: [-5, -2, 0, 3, 6],
boca: [1, 0.45, -0.15, -0.55, -0.9], // 1 sonrisa, -1 morros
ojos: [1, 1, 1.15, 0.8, 0.5],
mejillas:[0.15, 0.3, 0.55, 0.65, 0.2],
sudor: [0, 0, 1, 1, 0.4],
rojo: [0, 0, 0.05, 0.28, 0.62],
vapor: [0, 0, 0, 0.2, 1],
temblor: [0, 0, 0.6, 1.6, 3.4],
};
function interpolar(valores, t) {
if (t <= T[0]) return valores[0];
for (let i = 1; i < T.length; i++) {
if (t <= T[i]) {
const k = (t - T[i - 1]) / (T[i] - T[i - 1]);
return valores[i - 1] + (valores[i] - valores[i - 1]) * k;
}
}
return valores[valores.length - 1];
}
// Referencias de la cara
const cara = {
cabeza: $('cabeza'), cejaI: $('cejaI'), cejaD: $('cejaD'), boca: $('boca'), grito: $('grito'),
bigote: $('bigote'), sudor: $('sudor'), rojo: $('rojo'), vapor: $('vapor'), aura: $('auraRoja'),
ojos: document.querySelectorAll('.ojo'), mejillas: document.querySelectorAll('.mejilla'),
humos: document.querySelectorAll('.humo'),
};
function pintarCara(t, ahora) {
const v = (rasgo) => interpolar(CARA[rasgo], t);
const ang = v('ceja'), y = v('cejaY');
cara.cejaI.style.transform = `translateY(${y}px) rotate(${ang}deg)`;
cara.cejaD.style.transform = `translateY(${y}px) rotate(${-ang}deg)`;
// La boca es una sonrisa que se aplana y se da la vuelta (escala Y negativa)
let b = v('boca'); if (Math.abs(b) < 0.08) b = b < 0 ? -0.08 : 0.08;
cara.boca.style.transform = `scale(${1 - Math.max(0, t - 1) * 0.4}, ${b})`;
const grito = Math.min(1, Math.max(0, (t - 1.15) / 0.25));
cara.grito.style.opacity = grito;
cara.boca.style.opacity = 1 - grito;
cara.bigote.style.transform = `translateY(${-b * 2}px) rotate(${Math.sin(ahora / 70) * v('temblor') * 2}deg)`;
cara.ojos.forEach((o) => (o.style.transform = `scaleY(${v('ojos')})`));
cara.mejillas.forEach((m) => (m.style.opacity = v('mejillas')));
cara.rojo.style.opacity = v('rojo');
cara.aura.style.opacity = v('vapor');
// Gotita de sudor que resbala en bucle
const fase = (ahora / 900) % 1;
cara.sudor.style.opacity = v('sudor') * (1 - fase * 0.7);
cara.sudor.style.transform = `translateY(${fase * 18}px)`;
// Vapor por las orejas
cara.vapor.setAttribute('opacity', v('vapor'));
cara.humos.forEach((h, i) => {
const f = (ahora / 700 + i * 0.5) % 1;
h.style.transform = `translateY(${-f * 26}px) scale(${0.6 + f * 0.7})`;
h.style.opacity = 1 - f;
});
// Temblor e inclinación de toda la cabeza
const a = sinMovimiento ? 0 : v('temblor');
cara.cabeza.style.transform =
`translate(${Math.sin(ahora / 23) * a}px, ${Math.cos(ahora / 31) * a * 0.6}px) rotate(${-4 * Math.min(t, 1)}deg)`;
}
/* ── 3 · Cifras que ruedan ──────────────────────────────────
Cada cifra es una tira vertical 0-9; la subimos con transform. */
const cifras = [...document.querySelectorAll('.cifra')].map((c) => {
c.innerHTML = `<span class="tira">${[...'0123456789'].map((n) => `<span>${n}</span>`).join('')}</span>`;
return c.firstChild;
});
function pintarPrecio(euros) {
String(euros).padStart(3, '0').split('').forEach((d, i) => {
cifras[i].style.transform = `translateY(${-d * 10}%)`;
});
}
// Resto de referencias
const input = $('oferta'), tirador = $('tirador'), relleno = $('relleno'), deslizador = $('deslizador');
const bocadillo = $('bocadillo'), sello = $('sello'), cta = $('cta'), dedo = $('dedo');
const extras = [...$('extras').children];
const piezasBici = [$('biciCasco'), $('biciCesta'), $('biciLuz')];
$('zonaRoja').style.width = `${((PRECIO.minimo - PRECIO.suelo) / (PRECIO.pide - PRECIO.suelo)) * 100}%`;
// Estado
const estado = {
precio: PRECIO.pide, // precio «visual» (con decimales mientras rebota)
tVis: 0, // tensión suavizada que ve la cara
muelle: null, // rebote en curso
trato: false,
fraseFija: null, fraseHasta: 0,
ultimoEntero: null,
};
/* ── 4 · El muelle del rebote ──────────────────────────────
Fuerza = −rigidez·(distancia) − rozamiento·(velocidad).
Poco rozamiento → se pasa un poco y vuelve: «¡boing!». */
const RIGIDEZ = 220, ROZAMIENTO = 13;
function pasoMuelle(m, dt) {
const fuerza = -RIGIDEZ * (m.x - m.destino) - ROZAMIENTO * m.v;
m.v += fuerza * dt;
m.x += m.v * dt;
return Math.abs(m.v) < 0.6 && Math.abs(m.x - m.destino) < 0.3; // ¿ya se ha parado?
}
// Frases del vendedor según la tensión
function fraseSegun(t) {
if (t < 0.08) return '¡Está como nueva!';
if (t < 0.4) return 'Hombre…';
if (t < 0.75) return 'Me estás arruinando';
if (t < 1.02) return '¡Que tengo familia!';
return '¿Me tomas el pelo?';
}
let fraseActual = '';
function decir(texto, clase) {
if (texto === fraseActual) return;
fraseActual = texto;
bocadillo.textContent = texto;
bocadillo.className = 'bocadillo' + (clase ? ' ' + clase : '');
if (!sinMovimiento) bocadillo.animate(
[{ transform: 'scale(.8)' }, { transform: 'scale(1)' }],
{ duration: 320, easing: 'cubic-bezier(.3,1.7,.5,1)' });
}
// Dibuja todo lo que depende del precio
function pintar(p) {
const f = (p - PRECIO.suelo) / (PRECIO.pide - PRECIO.suelo);
const ancho = deslizador.clientWidth - 44;
tirador.style.transform = `translateX(${f * ancho}px)`;
relleno.style.transform = `scaleX(${f})`;
deslizador.classList.toggle('peligro', p < PRECIO.minimo - 0.5);
const euros = Math.round(p);
if (euros === estado.ultimoEntero) return;
estado.ultimoEntero = euros;
pintarPrecio(euros);
$('precioFila').classList.toggle('sin-regateo', euros >= PRECIO.pide);
$('ahorro').textContent = `−${PRECIO.pide - euros} €`;
extras.forEach((li, i) => {
const cae = euros < +li.dataset.hasta;
li.classList.toggle('cae', cae);
if (piezasBici[i]) piezasBici[i].classList.toggle('fuera', cae);
});
cta.textContent = `Lo quiero por ${euros} €`;
input.setAttribute('aria-valuetext', `${euros} euros`);
}
// Confeti ligero (piezas DOM movidas con transform)
let confeti = [];
function lanzarConfeti() {
if (sinMovimiento) return;
const capa = $('confeti'), r = sello.getBoundingClientRect();
const colores = ['#00FF7F', '#E8F5EE', '#FFD23F', '#FF5E7E', '#7FD8FF'];
for (let i = 0; i < 46; i++) {
const el = document.createElement('i');
el.style.background = colores[i % colores.length];
capa.appendChild(el);
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 2.4, vel = 380 + Math.random() * 420;
confeti.push({ el, x: r.left + r.width / 2, y: r.top + r.height / 2,
vx: Math.cos(ang) * vel, vy: Math.sin(ang) * vel, giro: Math.random() * 360, vida: 0 });
}
}
function moverConfeti(dt) {
confeti = confeti.filter((c) => {
c.vida += dt; c.vy += 1100 * dt; c.vx *= 0.985;
c.x += c.vx * dt; c.y += c.vy * dt; c.giro += 540 * dt;
c.el.style.transform = `translate(${c.x}px, ${c.y}px) rotate(${c.giro}deg) scaleX(${Math.cos(c.giro / 40)})`;
c.el.style.opacity = Math.max(0, 1 - c.vida / 2);
if (c.vida > 2) { c.el.remove(); return false; }
return true;
});
}
function cerrarTrato(euros) {
estado.trato = true;
$('selloPrecio').textContent = `por ${euros} €`;
sello.classList.add('visible');
decir('¡Trato hecho!', 'trato');
lanzarConfeti();
}
function deshacerTrato() {
estado.trato = false;
sello.classList.remove('visible');
}
// Al soltar: si te has pasado, «¡Ni hablar!» y rebote al mínimo
function soltar() {
const p = estado.precio;
if (p < PRECIO.minimo - 0.5) {
estado.fraseFija = '¡Ni hablar!'; estado.fraseHasta = performance.now() + 1200;
decir('¡Ni hablar!', 'grito');
if (sinMovimiento) { estado.precio = PRECIO.minimo; input.value = PRECIO.minimo; cerrarTrato(PRECIO.minimo); }
else estado.muelle = { x: p, v: 0, destino: PRECIO.minimo };
} else if (Math.round(p) < PRECIO.pide) {
cerrarTrato(Math.round(p));
}
}
// Interacción real del usuario
let porTeclado = false, esperaTeclado = 0;
input.addEventListener('pointerdown', () => { porTeclado = false; });
input.addEventListener('keydown', () => { porTeclado = true; });
input.addEventListener('input', () => {
estado.muelle = null; estado.fraseFija = null;
deshacerTrato();
estado.precio = +input.value;
});
input.addEventListener('change', () => {
clearTimeout(esperaTeclado);
// Con teclado esperamos a que deje de pulsar (salvo si se ha pasado del mínimo)
if (porTeclado && estado.precio >= PRECIO.minimo) esperaTeclado = setTimeout(soltar, 700);
else soltar();
});
cta.addEventListener('click', () => {
const aviso = $('aviso');
aviso.textContent = `¡Apuntado! Ramón te guarda la bici por ${Math.round(estado.precio)} € hasta las 14:00`;
aviso.classList.add('visible');
setTimeout(() => aviso.classList.remove('visible'), 2600);
});
/* ── 5 · El dedo fantasma (modo demostración) ──────────────
Un guion por tiempo: arrastra, se pasa, suelta, rebota,
trato hecho… y vuelve a empezar. Se para al tocar algo. */
const CICLO = 9.6;
const suave = (k) => k * k * (3 - 2 * k);
const demo = { activo: true, inicio: performance.now(), ciclo: -1, soltado: false };
function guion(s) {
if (s < 0.3) return { precio: 240, dedo: true };
if (s < 2.5) return { precio: 240 - 94 * suave((s - 0.3) / 2.2), dedo: true };
if (s < 3.2) return { precio: 146, dedo: true };
if (s < 7.6) return { soltar: true };
if (s < 7.9) return { reiniciar: true, dedo: true };
if (s < 9.0) return { precio: 175 + 65 * suave((s - 7.9) / 1.1), dedo: true };
return { dedo: false };
}
function pasoDemo(ahora) {
const total = (ahora - demo.inicio) / 1000;
const ciclo = Math.floor(total / CICLO), s = total % CICLO;
if (ciclo !== demo.ciclo) { demo.ciclo = ciclo; demo.soltado = false; }
const g = guion(s);
if (g.reiniciar && estado.trato) { deshacerTrato(); estado.muelle = null; estado.precio = PRECIO.minimo; }
if (g.precio !== undefined) { estado.precio = g.precio; input.value = Math.round(g.precio); }
if (g.soltar && !demo.soltado) { demo.soltado = true; soltar(); }
dedo.classList.toggle('visible', !!g.dedo);
const r = tirador.getBoundingClientRect();
const pulsado = g.dedo && s > 0.15 ? 0.82 : 1;
dedo.style.transform = `translate(${r.left + r.width / 2 - 23}px, ${r.top + r.height / 2 - 23}px) scale(${pulsado})`;
}
function pararDemo() {
if (!demo.activo) return;
demo.activo = false;
dedo.classList.remove('visible');
}
['pointerdown', 'keydown', 'touchstart', 'wheel'].forEach((ev) =>
addEventListener(ev, pararDemo, { capture: true, passive: true }));
// Bucle principal: un solo requestAnimationFrame lo mueve todo
let antes = performance.now();
function bucle(ahora) {
const dt = Math.min(0.033, Math.max(0, (ahora - antes) / 1000));
antes = ahora;
if (demo.activo) pasoDemo(ahora);
if (estado.muelle) {
const parado = pasoMuelle(estado.muelle, dt);
estado.precio = estado.muelle.x;
input.value = Math.round(estado.precio);
if (parado) { estado.muelle = null; estado.precio = PRECIO.minimo; cerrarTrato(PRECIO.minimo); }
}
pintar(estado.precio);
// Con trato hecho Ramón sonríe; si no, la tensión manda
const objetivo = estado.trato ? 0.1 : tension(estado.precio);
estado.tVis += (objetivo - estado.tVis) * (1 - Math.exp(-dt * (sinMovimiento ? 30 : 10)));
pintarCara(estado.tVis, ahora);
if (estado.fraseFija && ahora > estado.fraseHasta) estado.fraseFija = null;
if (!estado.trato && !estado.fraseFija) decir(fraseSegun(tension(estado.precio)), tension(estado.precio) > 1.02 ? 'grito' : '');
moverConfeti(dt);
requestAnimationFrame(bucle);
}
pintar(estado.precio);
requestAnimationFrame(bucle);
})();
▶
Explicado paso a paso en el episodio
7 efectos web IMPOSIBLES sin librerías (el nº1 es un portal)
5:55El precio que se regatea