diff --git a/src/components/CountUp.tsx b/src/components/CountUp.tsx new file mode 100644 index 0000000..deaa9cb --- /dev/null +++ b/src/components/CountUp.tsx @@ -0,0 +1,128 @@ +import { useInView, useMotionValue, useSpring } from "motion/react"; +import { useCallback, useEffect, useRef } from "react"; + +interface CountUpProps { + to: number; + from?: number; + direction?: "up" | "down"; + delay?: number; + duration?: number; + className?: string; + startWhen?: boolean; + separator?: string; + suffix?: string; + prefix?: string; + padMinLength?: number; + onStart?: () => void; + onEnd?: () => void; +} + +export default function CountUp({ + to, + from = 0, + direction = "up", + delay = 0, + duration = 2, + className = "", + startWhen = true, + separator = "", + suffix = "", + prefix = "", + padMinLength, + onStart, + onEnd, +}: CountUpProps) { + const ref = useRef(null); + const containerRef = useRef(null); + const motionValue = useMotionValue(direction === "down" ? to : from); + + const damping = 20 + 40 * (1 / duration); + const stiffness = 100 * (1 / duration); + + const springValue = useSpring(motionValue, { + damping, + stiffness, + }); + + const isInView = useInView(containerRef, { once: true, margin: "0px" }); + + const getDecimalPlaces = (num: number) => { + const str = num.toString(); + if (str.includes(".")) { + const decimals = str.split(".")[1]; + if (decimals && parseInt(decimals, 10) !== 0) { + return decimals.length; + } + } + return 0; + }; + + const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to)); + + const formatValue = useCallback( + (latest: number) => { + if (padMinLength != null) { + const n = Math.round(latest); + return n.toString().padStart(padMinLength, "0"); + } + const hasDecimals = maxDecimals > 0; + const options: Intl.NumberFormatOptions = { + useGrouping: !!separator, + minimumFractionDigits: hasDecimals ? maxDecimals : 0, + maximumFractionDigits: hasDecimals ? maxDecimals : 0, + }; + let formattedNumber = Intl.NumberFormat("de-DE", options).format(latest); + if (separator) { + formattedNumber = formattedNumber.replace(/\s/g, separator); + } + return formattedNumber; + }, + [maxDecimals, separator, padMinLength] + ); + + useEffect(() => { + if (ref.current) { + ref.current.textContent = formatValue(direction === "down" ? to : from); + } + }, [from, to, direction, formatValue]); + + useEffect(() => { + if (isInView && startWhen) { + if (typeof onStart === "function") onStart(); + + const timeoutId = setTimeout(() => { + motionValue.set(direction === "down" ? from : to); + }, delay * 1000); + + const durationTimeoutId = setTimeout( + () => { + if (typeof onEnd === "function") onEnd(); + }, + delay * 1000 + duration * 1000 + ); + + return () => { + clearTimeout(timeoutId); + clearTimeout(durationTimeoutId); + }; + } + }, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]); + + useEffect(() => { + const unsubscribe = springValue.on("change", (latest) => { + if (ref.current) { + ref.current.textContent = formatValue(latest); + } + }); + + return () => unsubscribe(); + }, [springValue, formatValue]); + + return ( + + {prefix && {prefix}} + + {suffix && {suffix}} + + ); +} diff --git a/src/components/DifferentiationSection.tsx b/src/components/DifferentiationSection.tsx index 7f62be1..b945e31 100644 --- a/src/components/DifferentiationSection.tsx +++ b/src/components/DifferentiationSection.tsx @@ -1,6 +1,7 @@ import { Link } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { ArrowRight } from "lucide-react"; +import CountUp from "@/components/CountUp"; const DifferentiationSection = () => { return ( @@ -15,7 +16,9 @@ const DifferentiationSection = () => {
-
01
+
+ +

Alles aus einer Hand

@@ -25,7 +28,9 @@ const DifferentiationSection = () => {
-
02
+
+ +

Systeme statt Inseln

@@ -35,7 +40,9 @@ const DifferentiationSection = () => {
-
03
+
+ +

Langfristige Partnerschaft

diff --git a/src/components/Header.tsx b/src/components/Header.tsx index b7f53ae..2faef1f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -41,7 +41,7 @@ const Header = () => { Kontakt @@ -84,7 +84,7 @@ const Header = () => { setIsMobileMenuOpen(false)}> Kontakt diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 0826f17..2001984 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom"; import { ArrowRight } from "lucide-react"; import { useState, useEffect } from "react"; import Silk from "@/components/Silk"; +import CountUp from "@/components/CountUp"; const SPARKLE_SVG = ( @@ -88,11 +89,11 @@ const Hero = () => {

{/* CTA Buttons */} -
-
+
+
-
+