I've been following stock markets for a while now. Also crypto wallets and apps.
What binds them together is the presence of numbers that are constantly
changing - it's usually the amount of certain asset - crypto or fiat. I came up
to conclusion that it would be a neat detail to visually indicate the change to
the user. Users like animations, right? It makes them feel taken care of. It's a
small detail, but it can make a difference. Also makes the product more
memorable. That's why I decided to implement a SlotNumber component. It's
simple component that takes a number and animates it's change. The animation is
inspired by the slot machines. The number is displayed as a sequence of digits
that are spinning.
Overview
The API of the component is simple. It accepts two props:
children- the number to displayformatFunction- an optional function that will format the number before displaying it. It will be useful if you want to add a currency symbol or format the number to add delimiters. It takes a number and returns a string.
For animations I used Motion. Styles were written with Tailwind but you can use literally anything.
There is an interactive example below. You can play with the component and see how it works. I added the toggle for you to see it in action with more meaningful context.
Generally speaking the component will take the value, format it, split it into
characters. From the collection of characters we will distinguish the ones that
are digits and the ones that are not. Digits will be passed to the
SlotNumberDigit component that encapsulates the spinning animation. Any other
characters will be rendered as is.
Implementation
Preparing value for rendering
First I prepared the value for rendering. The preparation consists of three stages:
- formatting the value with the
formatFunctionif it's provided, - splitting the value into characters,
- distinguishing digits and casting then to numbers accordingly.
I memoized the characters array to avoid unnecessary recalculations.
Pay attention to the reverse method. It's there to make the animation more
natural. Characters will be reverted back later with flexbox. It will prevent
unnecessary content shifting.
1type SlotNumberProps = {2 children: number3 formatFunction?: (value: number) => string4}56function SlotNumber(props: SlotNumberProps) {7 const { children: value, formatFunction, ...restProps } = props89 const characters = useMemo(() => {10 const formattedValue = formatFunction11 ? formatFunction(value)12 : value.toString()1314 return formattedValue15 .split('')16 .map((character) => (/^[0-9]$/.test(character) ? +character : character))17 .reverse()18 }, [formatFunction, value])1920 return <div {...restProps} />21}Rendering characters
Next I rendered characters. If the character is type of number it's passed to
the SlotNumberDigit component I will introduce in the following chapters.
1type SlotNumberProps = {2 children: number3 formatFunction?: (value: number) => string4}56function SlotNumber(props: SlotNumberProps) {7 const { children: value, formatFunction, ...restProps } = props89 // ...1011 return (12 <div {...restProps}>13 {characters.map((character, index) =>14 typeof character === 'number' ? (15 <SlotNumberDigit key={`slot-number-character-${index}`}>16 {character}17 </SlotNumberDigit>18 ) : (19 <span key={`slot-number-character-${index}`}>{character}</span>20 ),21 )}22 </div>23 )24}Styling the SlotNumber component
As I mentioned eariler digits are reversed back with the use of flexbox. Please note that I justified the content to the end. Together with reversed flexbox direction it will make the digits appear from the left side of the container. It's also important to hide the overflow. It will hide stacked digits that are out of the container area.
1const getStyles = tv({2 slots: {3 container: 'flex flex-row-reverse justify-end overflow-hidden select-none',4 },5})67type SlotNumberProps = {8 className?: string9 children: number10 formatFunction?: (value: number) => string11}1213function SlotNumber(props: SlotNumberProps) {14 const { className, children: value, formatFunction } = props1516 const styles = getStyles()1718 // ...1920 return <div className={cn(styles.container(), className)} />21}Implementing SlotNumberDigit component
The SlotNumberDigit component is responsible for animating the digit change.
It consists of two main parts:
- the placeholder that occupies the space in DOM for the digit to be visible,
- the stack of digits that spins vertically accordingly to the value change.
1const getStyles = tv({2 slots: {3 digitContainer: 'relative',4 digitsWrapper: 'absolute inset-0 flex h-fit flex-col',5 digitPlaceholder: 'invisible block',6 },7})89type SlotNumberDigitProps = {10 className?: string11 children: number12}1314const SlotNumberDigit = (props: SlotNumberDigitProps) => {15 const { className, children, ...restProps } = props1617 const styles = getStyles()1819 const digits = [...Array(10).keys()]2021 return (22 <div23 className={cn(styles.digitContainer(), className)}24 {...restProps}25 >26 <span className={styles.digitPlaceholder()}>{children}</span>2728 <div29 className={styles.digitsWrapper()}30 style={{ transform: `translateY(${(children / 10) * -100}%)` }}31 >32 {digits.map((digit) => (33 <span key={digit}>{digit}</span>34 ))}35 </div>36 </div>37 )38}To make things move I calculated the vertical offset of the stack basing on the current value. Please note the number of digits is constant and equals 10. It's because we are dealing with decimal system. It allowed me to use percentage values for the offset with the given formula:
Now I don't have to explicitly measure the height of the digit. It will be always one tenth of the container height.
Making things smooth
It's decent but not perfect. The animation is not smooth. That's where the Motion comes into play.
Primarily I replaced digits wrapper div with the motion.div component. It
allowed me to define MotionProps that will animate the transition between
offsets. I applied the offset using the y shorthand property within the
animate prop. I also set the initial property to false to prevent the
initial animation. Now digits update smoothly. The default spring options are
good enough for this case. You can tweak it with transition property.
It can look even better with use of motion layout components. It will automatically calculate position of each character in flexbox and move it around smoothly.
To do so I replaced the container div with the motion.div component and
wrapped whole SlotNumberDigit component with the AnimatePresence component.
It will handle the presence of characters in the DOM and animate it as it
mounts. I added proper initial and animate transitions and disabled the
initial animation using AnimatePresence's initial property. It's important
to mention the layout property. It's set to "position" to enable the layout
animations scoped to the position of characters. It prevents characters from
unexpectedly stretching.
Finally I tweaked the structure of SlotNumber component to utilize motion
layout components as well. I wrapped non-digit characters with motion.span
with the layout property. The same for the root div element.
Conclusion
The SlotNumber component is ready. It's quite simple but effective and
performant. It can be used in various contexts. With formatFunction property
it's flexible and extensible.
1type SlotNumberProps = {2 className?: string3 children: number4 formatFunction?: (value: number) => string5}67const getStyles = tv({8 slots: {9 container:10 'flex flex-row-reverse justify-end overflow-hidden text-4xl leading-none font-semibold select-none',11 digitContainer: 'relative',12 digitsWrapper: 'absolute inset-0 flex h-fit flex-col',13 digitPlaceholder: 'invisible block',14 },15})1617type SlotNumberDigitProps = {18 className?: string19 children: number20}2122const SlotNumberDigit = (props: SlotNumberDigitProps) => {23 const { className, children: value } = props2425 const styles = getStyles()2627 const digits = [...Array(10).keys()]2829 return (30 <AnimatePresence initial={false}>31 <motion.div32 initial={{33 y: '100%',34 }}35 animate={{36 y: '0%',37 }}38 layout="position"39 className={cn(styles.digitContainer(), className)}40 >41 <span className={styles.digitPlaceholder()}>{value}</span>4243 <motion.div44 className={styles.digitsWrapper()}45 initial={false}46 animate={{ y: `${(value / 10) * -100}%` }}47 >48 {digits.map((digit) => (49 <span key={digit}>{digit}</span>50 ))}51 </motion.div>52 </motion.div>53 </AnimatePresence>54 )55}5657function SlotNumber(props: SlotNumberProps) {58 const { className, children: value, formatFunction } = props5960 const styles = getStyles()6162 const characters = useMemo(() => {63 const formattedValue = formatFunction64 ? formatFunction(value)65 : value.toString()6667 return formattedValue68 .split('')69 .map((character) => (/^[0-9]$/.test(character) ? +character : character))70 .reverse()71 }, [formatFunction, value])7273 return (74 <motion.div75 layout76 className={cn(styles.container(), className)}77 >78 {characters.map((character, index) =>79 typeof character === 'number' ? (80 <SlotNumberDigit key={`slot-number-character-${index}`}>81 {character}82 </SlotNumberDigit>83 ) : (84 <motion.span85 layout86 key={`slot-number-character-${index}`}87 >88 {character}89 </motion.span>90 ),91 )}92 </motion.div>93 )94}