A sliding-number component from Motion Primitives.
1'use client';
2import { useEffect, useId } from 'react';
3import {
4 MotionValue,
5 motion,
6 useSpring,
7 useTransform,
8 motionValue,
9} from 'motion/react';
10import useMeasure from 'react-use-measure';
11
12const TRANSITION = {
13 type: 'spring',
14 stiffness: 280,
15 damping: 18,
16 mass: 0.3,
17};
18
19function Digit({ value, place }: { value: number; place: number }) {
20 const valueRoundedToPlace = Math.floor(value / place) % 10;
21 const initial = motionValue(valueRoundedToPlace);
22 const animatedValue = useSpring(initial, TRANSITION);
23
24 useEffect(() => {
25 animatedValue.set(valueRoundedToPlace);
26 }, [animatedValue, valueRoundedToPlace]);
27
28 return (
29 <div className='relative inline-block w-[1ch] overflow-x-visible overflow-y-clip leading-none tabular-nums'>
30 <div className='invisible'>0</div>
31 {Array.from({ length: 10 }, (_, i) => (
32 <Number key={i} mv={animatedValue} number={i} />
33 ))}
34 </div>
35 );
36}
37
38function Number({ mv, number }: { mv: MotionValue<number>; number: number }) {
39 const uniqueId = useId();
40 const [ref, bounds] = useMeasure();
41
42 const y = useTransform(mv, (latest) => {
43 if (!bounds.height) return 0;
44 const placeValue = latest % 10;
45 const offset = (10 + number - placeValue) % 10;
46 let memo = offset * bounds.height;
47
48 if (offset > 5) {
49 memo -= 10 * bounds.height;
50 }
51
52 return memo;
53 });
54
55 // don't render the animated number until we know the height
56 if (!bounds.height) {
57 return (
58 <span ref={ref} className='invisible absolute'>
59 {number}
60 </span>
61 );
62 }
63
64 return (
65 <motion.span
66 style={{ y }}
67 layoutId={`${uniqueId}-${number}`}
68 className='absolute inset-0 flex items-center justify-center'
69 transition={TRANSITION}
70 ref={ref}
71 >
72 {number}
73 </motion.span>
74 );
75}
76
77type SlidingNumberProps = {
78 value: number;
79 padStart?: boolean;
80 decimalSeparator?: string;
81};
82
83export function SlidingNumber({
84 value,
85 padStart = false,
86 decimalSeparator = '.',
87}: SlidingNumberProps) {
88 const absValue = Math.abs(value);
89 const [integerPart, decimalPart] = absValue.toString().split('.');
90 const integerValue = parseInt(integerPart, 10);
91 const paddedInteger =
92 padStart && integerValue < 10 ? `0${integerPart}` : integerPart;
93 const integerDigits = paddedInteger.split('');
94 const integerPlaces = integerDigits.map((_, i) =>
95 Math.pow(10, integerDigits.length - i - 1)
96 );
97
98 return (
99 <div className='flex items-center'>
100 {value < 0 && '-'}
101 {integerDigits.map((_, index) => (
102 <Digit
103 key={`pos-${integerPlaces[index]}`}
104 value={integerValue}
105 place={integerPlaces[index]}
106 />
107 ))}
108 {decimalPart && (
109 <>
110 <span>{decimalSeparator}</span>
111 {decimalPart.split('').map((_, index) => (
112 <Digit
113 key={`decimal-${index}`}
114 value={parseInt(decimalPart, 10)}
115 place={Math.pow(10, decimalPart.length - index - 1)}
116 />
117 ))}
118 </>
119 )}
120 </div>
121 );
122}
123