Skip to content

Commit 12cf80f

Browse files
committed
Replace countup with CSS animations
Set up to render final values statically, then reset to 1 when hydrating, then animate to the final result.
1 parent 9c5dea9 commit 12cf80f

3 files changed

Lines changed: 58 additions & 44 deletions

File tree

package-lock.json

Lines changed: 0 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@
5353
"posthog-js": "^1.359.1",
5454
"prismjs": "^1.27.0",
5555
"react": "^18.2.0",
56-
"react-countup": "^6.5.0",
5756
"react-dom": "^18.2.0",
5857
"react-fast-marquee": "^1.6.4",
5958
"react-intersection-observer": "^9.8.2",
Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,67 @@
11
'use client';
22

3-
import CountUp from 'react-countup';
4-
5-
import { useMounted } from '@/lib/hooks/use-mounted';
3+
import { useRef, useEffect, useState, useCallback } from 'react';
64

75
interface NumberIncreaserProps {
86
maxValue: number;
97
suffix?: string;
108
}
119

12-
// t: current time, b: beginning value, c: change in value, d: duration
13-
const linearEasing = (t: number, b: number, c: number, d: number): number =>
14-
c * t / d + b;
15-
16-
export const NumberIncreaser = ({ maxValue, suffix }: NumberIncreaserProps) => {
17-
const isMounted = useMounted();
18-
19-
if (!isMounted) {
20-
return <>{maxValue}</>;
21-
}
22-
23-
return <CountUp
24-
scrollSpyOnce
25-
scrollSpyDelay={250}
26-
enableScrollSpy
27-
duration={3}
28-
start={1}
29-
end={maxValue}
30-
suffix={suffix}
31-
easingFn={linearEasing}
32-
>
33-
{({ countUpRef }) => <span ref={countUpRef} />}
34-
</CountUp>
10+
const DURATION = 3000; // 3 seconds
11+
const SCROLL_SPY_DELAY = 250; // ms
12+
13+
export const NumberIncreaser = ({ maxValue, suffix = '' }: NumberIncreaserProps) => {
14+
const ref = useRef<HTMLSpanElement>(null);
15+
const [hasAnimated, setHasAnimated] = useState(false);
16+
17+
const animate = useCallback(() => {
18+
const el = ref.current;
19+
if (!el) return;
20+
21+
el.textContent = 1 + suffix;
22+
const start = performance.now();
23+
24+
const tick = (now: number) => {
25+
const elapsed = now - start;
26+
const progress = Math.min(elapsed / DURATION, 1);
27+
// Linear easing from 1 to maxValue
28+
const value = Math.round(1 + (maxValue - 1) * progress);
29+
el.textContent = value + suffix;
30+
31+
if (progress < 1) {
32+
requestAnimationFrame(tick);
33+
}
34+
};
35+
36+
requestAnimationFrame(tick);
37+
}, [maxValue, suffix]);
38+
39+
// Reset to 1 immediately on hydration, before the user scrolls to it
40+
useEffect(() => {
41+
const el = ref.current;
42+
if (el) el.textContent = 1 + suffix;
43+
}, [suffix]);
44+
45+
useEffect(() => {
46+
if (hasAnimated) return;
47+
48+
const el = ref.current;
49+
if (!el) return;
50+
51+
const observer = new IntersectionObserver(
52+
(entries) => {
53+
if (entries[0].isIntersecting) {
54+
observer.disconnect();
55+
setHasAnimated(true);
56+
setTimeout(animate, SCROLL_SPY_DELAY);
57+
}
58+
},
59+
{ threshold: 0 }
60+
);
61+
62+
observer.observe(el);
63+
return () => observer.disconnect();
64+
}, [hasAnimated, animate]);
65+
66+
return <span ref={ref}>{maxValue}{suffix}</span>;
3567
};

0 commit comments

Comments
 (0)