0 Liter Wasser in diesem Jahr gespart

Liter Wasser in diesem Jahr gespart

React + MotionText & Scroll

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

128450

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.