0 Liter Wasser in diesem Jahr gespart
Liter Wasser in diesem Jahr gespart
Zahl im Kilometerzähler-Stil
Eine riesige Zahl, deren Ziffern federnd an ihren Platz rollen, sobald sie ins Bild kommt, mit blassen führenden Nullen.
Einstellungen
Code
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 = "Liter Wasser in diesem Jahr gespart",
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={"Erhöhen: " + 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="Hinzufügen"
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 für KI
Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.
Mehr aus Text & Scroll
Wofür du ihn nutzen kannst
Ein animierter Zahlenzähler lässt eine einzelne Zahl verdient wirken: gesparte Liter Wasser einer NGO, verschickte Bestellungen eines kleinen Shops, Mitglieder einer Community, gesammeltes Geld beim Crowdfunding. Die mechanischen Rollen drehen sich, sobald der Abschnitt sichtbar wird, und die blassen Nullen davor deuten an, wie viel Luft nach oben ist.
Nimm ihn für eine Zahl, höchstens zwei, nicht für ein ganzes Raster voller Statistiken. Runde Zahlen liest man schneller als exakte. Ein Tipp zählt hoch und lässt die Rollen erneut laufen, nett in einer Demo, im seriösen Jahresbericht eher überflüssig.