import { motion, useSpring, useTransform } from 'motion/react';
import { useEffect } from 'react';
function Number({ mv, number, height }) {
const y = useTransform(mv, latest => {
const placeValue = latest % 10;
const offset = (10 + number - placeValue) % 10;
let memo = offset * height;
if (offset > 5) {
memo -= 10 * height;
}
return memo;
});
const baseStyle = {
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
};
return <motion.span style={{ ...baseStyle, y }}>{number}</motion.span>;
}
function Digit({ place, value, height, digitStyle }) {
// Decimal point
if (place === '.') {
return (
<span
className="relative inline-flex items-center justify-center"
style={{ height, width: 'fit-content', ...digitStyle }}
>
.
</span>
);
}
// Numeric digit
const valueRoundedToPlace = Math.floor(value / place);
const animatedValue = useSpring(valueRoundedToPlace);
useEffect(() => {
animatedValue.set(valueRoundedToPlace);
}, [animatedValue, valueRoundedToPlace]);
const defaultStyle = {
height,
position: 'relative',
width: '1ch',
fontVariantNumeric: 'tabular-nums'
};
return (
<span className="relative inline-flex overflow-hidden" style={{ ...defaultStyle, ...digitStyle }}>
{Array.from({ length: 10 }, (_, i) => (
<Number key={i} mv={animatedValue} number={i} height={height} />
))}
</span>
);
}
export default function Counter({
value,
fontSize = 100,
padding = 0,
// same refactored default as your CSS version
places = [...value.toString()].map((ch, i, a) => {
if (ch === '.') return '.';
return (
10 **
(a.indexOf('.') === -1 ? a.length - i - 1 : i < a.indexOf('.') ? a.indexOf('.') - i - 1 : -(i - a.indexOf('.')))
);
}),
gap = 8,
borderRadius = 4,
horizontalPadding = 8,
textColor = 'white',
fontWeight = 'bold',
containerStyle,
counterStyle,
digitStyle,
gradientHeight = 16,
gradientFrom = 'black',
gradientTo = 'transparent',
topGradientStyle,
bottomGradientStyle
}) {
const height = fontSize + padding;
const defaultContainerStyle = {
position: 'relative',
display: 'inline-block'
};
const defaultCounterStyle = {
fontSize,
display: 'flex',
gap,
overflow: 'hidden',
borderRadius,
paddingLeft: horizontalPadding,
paddingRight: horizontalPadding,
lineHeight: 1,
color: textColor,
fontWeight
};
const gradientContainerStyle = {
pointerEvents: 'none',
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between'
};
const defaultTopGradientStyle = {
height: gradientHeight,
background: `linear-gradient(to bottom, ${gradientFrom}, ${gradientTo})`
};
const defaultBottomGradientStyle = {
height: gradientHeight,
background: `linear-gradient(to top, ${gradientFrom}, ${gradientTo})`
};
return (
<span style={{ ...defaultContainerStyle, ...containerStyle }}>
<span style={{ ...defaultCounterStyle, ...counterStyle }}>
{places.map(place => (
<Digit key={place} place={place} value={value} height={height} digitStyle={digitStyle} />
))}
</span>
<span style={gradientContainerStyle}>
<span style={topGradientStyle ?? defaultTopGradientStyle} />
<span style={bottomGradientStyle ?? defaultBottomGradientStyle} />
</span>
</span>
);
}