0 litros de agua ahorrados este año

litros de agua ahorrados este año

React + MotionTexto y scroll

Cifra de odómetro

Una cifra enorme cuyos dígitos ruedan con muelle hasta su valor al entrar en pantalla, con los ceros de delante en sombra.

Ajustes

128450

Código

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
import { Plus } from "lucide-react";

interface OdometerCounterProps {
  value?: number;
  label?: string;
  color?: string;
}

const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

function Digit({ d, delay, faded, reduce }: { d: number; delay: number; faded: boolean; reduce: boolean }) {
  return (
    <span
      className="relative inline-block h-[1.1em] w-[0.6em] overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,#000_14%,#000_86%,transparent)] transition-opacity duration-500"
      style={{ opacity: faded ? 0.16 : 1 }}
    >
      <motion.span
        className="absolute inset-x-0 top-0 flex flex-col"
        initial={false}
        animate={{ y: -d * 10 + "%" }}
        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 70, damping: 15, delay }}
      >
        {DIGITS.map((n) => (
          <span key={n} className="flex h-[1.1em] items-center justify-center">
            {n}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

export default function OdometerCounter({
  value = 128450,
  label = "litros de agua ahorrados este año",
  color = "#ffb547",
}: OdometerCounterProps) {
  const ref = useRef<HTMLDivElement>(null);
  const seen = useInView(ref, { once: true, amount: 0.5 });
  const reduce = useReducedMotion() ?? false;
  const [n, setN] = useState(0);
  const [bump, setBump] = useState<{ id: number; amount: number } | null>(null);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);

  useEffect(() => {
    if (seen) setN(Math.max(0, Math.round(value)));
  }, [seen, value]);
  useEffect(() => () => clearTimeout(timer.current), []);

  const add = () => {
    const amount = 100 + Math.floor(Math.random() * 2400);
    setN((x) => x + amount);
    setBump({ id: Date.now(), amount });
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setBump(null), 1400);
  };

  const target = String(Math.max(0, Math.round(value)));
  const padded = String(n).padStart(target.length, "0");
  const lead = padded.search(/[1-9]/);
  const cells: ({ kind: "digit"; d: number; faded: boolean } | { kind: "sep" })[] = [];
  [...padded].forEach((ch, i) => {
    const fromRight = padded.length - i;
    cells.push({ kind: "digit", d: Number(ch), faded: lead === -1 ? fromRight > 1 : i < lead });
    if (fromRight > 1 && (fromRight - 1) % 3 === 0) cells.push({ kind: "sep" });
  });
  let k = 0;

  return (
    <div ref={ref} className="w-fit max-w-full">
      <p className="sr-only" aria-live="polite">
        {n.toLocaleString("es-ES")} {label}
      </p>
      <div className="relative">
        <AnimatePresence>
          {bump ? (
            <motion.span
              key={bump.id}
              initial={{ opacity: 0, y: 8, scale: 0.9 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={{ opacity: 0, y: -10 }}
              transition={{ type: "spring", stiffness: 420, damping: 28 }}
              aria-hidden="true"
              className="absolute -top-8 left-0 rounded-full px-2.5 py-0.5 text-[13px] font-semibold tabular-nums text-[#1d1d1f]"
              style={{ background: color }}
            >
              +{bump.amount.toLocaleString("es-ES")}
            </motion.span>
          ) : null}
        </AnimatePresence>
        <button
          type="button"
          onClick={add}
          aria-label={"Sumar a " + label}
          className="flex cursor-pointer items-center text-[64px] font-semibold leading-none tracking-[-0.04em] tabular-nums text-[#1d1d1f] dark:text-[#f5f5f7] sm:text-[76px]"
        >
          <span aria-hidden="true" className="flex items-center">
            {cells.map((c, i) => {
              if (c.kind === "sep")
                return (
                  <span
                    key={"s" + (cells.length - i)}
                    className="mx-[0.03em] h-[0.12em] w-[0.12em] translate-y-[0.3em] rounded-full"
                    style={{ background: color }}
                  />
                );
              const index = k++;
              return (
                <Digit
                  key={"d" + (cells.length - i)}
                  d={c.d}
                  faded={c.faded}
                  delay={(padded.length - 1 - index) * 0.07}
                  reduce={reduce}
                />
              );
            })}
          </span>
        </button>
      </div>
      <div className="mt-4 flex items-center justify-between gap-4">
        <p className="text-balance text-[15px] leading-snug text-[#86868b]">{label}</p>
        <motion.button
          type="button"
          whileTap={{ scale: 0.88 }}
          onClick={add}
          aria-label="Sumar"
          className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-black/5 text-[#1d1d1f] transition-colors hover:bg-black/10 dark:bg-white/10 dark:text-[#f5f5f7] dark:hover:bg-white/15"
        >
          <Plus strokeWidth={1.5} className="h-4 w-4" />
        </motion.button>
      </div>
    </div>
  );
}

Prompt para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Texto y scroll

Dónde usarlo

Un contador animado hace que una cifra se sienta ganada: los litros de agua que ha ahorrado una ONG, los pedidos que ha enviado una tienda pequeña, los miembros de una comunidad, lo recaudado en un crowdfunding. Los rodillos mecánicos giran cuando la sección entra en pantalla, y los ceros en sombra insinúan cuánto margen queda.

Úsalo para una cifra, dos como mucho, no para una rejilla de estadísticas. Los números redondos se leen antes que los exactos. Al tocarlo suma y vuelve a rodar, algo divertido en una demo; en una memoria anual seria quizá prefieras quitarlo.