How I built animated SlotNumber component

Kamil Pyszkowski's pictureKamil Pyszkowski
7 mins

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 display
  • formatFunction - 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.

0123456789
.
0123456789
0123456789
Value
12.3

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 formatFunction if 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: number
3 formatFunction?: (value: number) => string
4}
5
6function SlotNumber(props: SlotNumberProps) {
7 const { children: value, formatFunction, ...restProps } = props
8
9 const characters = useMemo(() => {
10 const formattedValue = formatFunction
11 ? formatFunction(value)
12 : value.toString()
13
14 return formattedValue
15 .split('')
16 .map((character) => (/^[0-9]$/.test(character) ? +character : character))
17 .reverse()
18 }, [formatFunction, value])
19
20 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: number
3 formatFunction?: (value: number) => string
4}
5
6function SlotNumber(props: SlotNumberProps) {
7 const { children: value, formatFunction, ...restProps } = props
8
9 // ...
10
11 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})
6
7type SlotNumberProps = {
8 className?: string
9 children: number
10 formatFunction?: (value: number) => string
11}
12
13function SlotNumber(props: SlotNumberProps) {
14 const { className, children: value, formatFunction } = props
15
16 const styles = getStyles()
17
18 // ...
19
20 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})
8
9type SlotNumberDigitProps = {
10 className?: string
11 children: number
12}
13
14const SlotNumberDigit = (props: SlotNumberDigitProps) => {
15 const { className, children, ...restProps } = props
16
17 const styles = getStyles()
18
19 const digits = [...Array(10).keys()]
20
21 return (
22 <div
23 className={cn(styles.digitContainer(), className)}
24 {...restProps}
25 >
26 <span className={styles.digitPlaceholder()}>{children}</span>
27
28 <div
29 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:

(value / 10) * -100

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?: string
3 children: number
4 formatFunction?: (value: number) => string
5}
6
7const 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})
16
17type SlotNumberDigitProps = {
18 className?: string
19 children: number
20}
21
22const SlotNumberDigit = (props: SlotNumberDigitProps) => {
23 const { className, children: value } = props
24
25 const styles = getStyles()
26
27 const digits = [...Array(10).keys()]
28
29 return (
30 <AnimatePresence initial={false}>
31 <motion.div
32 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>
42
43 <motion.div
44 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}
56
57function SlotNumber(props: SlotNumberProps) {
58 const { className, children: value, formatFunction } = props
59
60 const styles = getStyles()
61
62 const characters = useMemo(() => {
63 const formattedValue = formatFunction
64 ? formatFunction(value)
65 : value.toString()
66
67 return formattedValue
68 .split('')
69 .map((character) => (/^[0-9]$/.test(character) ? +character : character))
70 .reverse()
71 }, [formatFunction, value])
72
73 return (
74 <motion.div
75 layout
76 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.span
85 layout
86 key={`slot-number-character-${index}`}
87 >
88 {character}
89 </motion.span>
90 ),
91 )}
92 </motion.div>
93 )
94}

I hope you found this article helpful and that it added value to your learning journey. I'd love to hear your thoughts, feedback, or questions — feel free to reach out via email at . If you enjoyed this writing, take a moment to explore other articles. Don't forget to check back soon for fresh insights and updates!

See you around!
— Kamil

Fri, Jun 5, 2026, 1:55:51 AM
Genuinely crafted in Poland © 2026