0 litros de agua ahorrados este año
litros de agua ahorrados este año
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
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.