Spellbook by Startidea
← Catálogo
Catalogue dificultad S ·

Precio fluido entre tiers

El total no salta de 4,89 € a 3,12 €. Interpola con ease-out cúbico durante 350 ms. El usuario ve el descuento llegando.

El problema

En un configurador B2B típico, al cambiar la cantidad de 50 a 100 unidades, el precio total salta de un valor a otro. Es información, no experiencia. El usuario no percibe que está “ganando” algo.

El spell

Componente React <AnimatedPrice/> que interpola entre el valor anterior y el nuevo cuando cambia la prop cents. 350 ms ease-out cubic — rápido arranque, llegada suave, sensación de “el número aterrizando”.

Código

"use client";
import { useEffect, useRef, useState } from "react";

const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);

export function AnimatedPrice({ cents, format, duration = 350 }) {
  const [display, setDisplay] = useState(
    typeof cents === "number" ? cents : null,
  );
  const fromRef = useRef(typeof cents === "number" ? cents : 0);
  const rafRef = useRef<number | null>(null);

  useEffect(() => {
    if (typeof cents !== "number") { setDisplay(null); fromRef.current = 0; return; }
    if (display === null) { setDisplay(cents); fromRef.current = cents; return; }
    if (Math.abs(cents - fromRef.current) < 1) {
      setDisplay(cents); fromRef.current = cents; return;
    }
    if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
      setDisplay(cents); fromRef.current = cents; return;
    }

    const from = display, to = cents, start = performance.now();
    if (rafRef.current != null) cancelAnimationFrame(rafRef.current);

    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / duration);
      const v = Math.round(from + (to - from) * easeOutCubic(t));
      setDisplay(v);
      if (t < 1) rafRef.current = requestAnimationFrame(tick);
      else { fromRef.current = to; rafRef.current = null; }
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => { if (rafRef.current != null) cancelAnimationFrame(rafRef.current); };
  }, [cents]);

  return <span>{display === null ? "—" : format(display)}</span>;
}

Uso

<AnimatedPrice
  cents={totalCents}
  format={(c) => `${(c / 100).toFixed(2)} €`}
/>

Decisiones

  • Sin animar la primera aparición: si el precio entra desde null, aparece directo. Animar de “nada” a “algo” se siente artificial.
  • Sin animar cambios <1 céntimo: la animación distrae cuando el cambio es trivial (ej. recalcular IVA).
  • Math.round dentro del loop: el ojo no percibe decimales mientras el número se mueve. Solo cuando termina.
  • Reduced-motion respect: salto directo si el usuario lo prefiere.
Licencia MIT. Atribución no requerida pero apreciada. Encontraste un bug o tienes una mejora? Abre un issue.