Sliding Number
Demo
00123456789001234567890012345678900123456789
Installation
Shadcn CLI
npx shadcn@latest add https://laviecn.vercel.app/r/sliding-number.json
pnpm dlx shadcn@latest add https://laviecn.vercel.app/r/sliding-number.json
yarn dlx shadcn@latest add https://laviecn.vercel.app/r/sliding-number.json
bun x shadcn@latest add https://laviecn.vercel.app/r/sliding-number.json
Manual
Install the following dependencies.
npm install motion react-use-measure
pnpm add motion react-use-measure
yarn add motion react-use-measure
bun add motion react-use-measure
Copy and paste the following code into your project.
"use client"
import * as React from "react"
import {
motion,
useInView,
useSpring,
useTransform,
type MotionValue,
type SpringOptions,
type UseInViewOptions,
} from "motion/react"
import useMeasure from "react-use-measure"
import { cn } from "@/lib/utils"
type SlidingNumberRollerProps = {
prevValue: number
value: number
place: number
transition: SpringOptions
}
function SlidingNumberRoller({
prevValue,
value,
place,
transition,
}: SlidingNumberRollerProps) {
const startNumber = Math.floor(prevValue / place) % 10
const targetNumber = Math.floor(value / place) % 10
const animatedValue = useSpring(startNumber, transition)
React.useEffect(() => {
animatedValue.set(targetNumber)
}, [targetNumber, animatedValue])
const [measureRef, { height }] = useMeasure()
return (
<span
ref={measureRef}
data-slot="sliding-number-roller"
className="relative inline-block w-[1ch] overflow-x-visible overflow-y-clip leading-none tabular-nums"
>
<span className="invisible">0</span>
{Array.from({ length: 10 }, (_, i) => (
<SlidingNumberDisplay
key={i}
motionValue={animatedValue}
number={i}
height={height}
transition={transition}
/>
))}
</span>
)
}
type SlidingNumberDisplayProps = {
motionValue: MotionValue<number>
number: number
height: number
transition: SpringOptions
}
function SlidingNumberDisplay({
motionValue,
number,
height,
transition,
}: SlidingNumberDisplayProps) {
const y = useTransform(motionValue, (latest) => {
if (!height) return 0
const currentNumber = latest % 10
const offset = (10 + number - currentNumber) % 10
let translateY = offset * height
if (offset > 5) translateY -= 10 * height
return translateY
})
if (!height) {
return <span className="invisible absolute">{number}</span>
}
return (
<motion.span
data-slot="sliding-number-display"
style={{ y }}
className="absolute inset-0 flex items-center justify-center"
transition={{ ...transition, type: "spring" }}
>
{number}
</motion.span>
)
}
type SlidingNumberProps = React.ComponentProps<"span"> & {
number: number | string
inView?: boolean
inViewMargin?: UseInViewOptions["margin"]
inViewOnce?: boolean
padStart?: boolean
decimalSeparator?: string
decimalPlaces?: number
transition?: SpringOptions
}
function SlidingNumber({
ref,
number,
className,
inView = false,
inViewMargin = "0px",
inViewOnce = true,
padStart = false,
decimalSeparator = ".",
decimalPlaces = 0,
transition = {
stiffness: 200,
damping: 20,
mass: 0.4,
},
...props
}: SlidingNumberProps) {
const localRef = React.useRef<HTMLSpanElement>(null)
React.useImperativeHandle(ref, () => localRef.current!)
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
})
const isInView = !inView || inViewResult
const prevNumberRef = React.useRef<number>(0)
const effectiveNumber = React.useMemo(
() => (!isInView ? 0 : Math.abs(Number(number))),
[number, isInView]
)
const formatNumber = React.useCallback(
(num: number) =>
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
[decimalPlaces]
)
const numberStr = formatNumber(effectiveNumber)
const [newIntStrRaw, newDecStrRaw = ""] = numberStr.split(".")
const newIntStr =
padStart && newIntStrRaw?.length === 1 ? "0" + newIntStrRaw : newIntStrRaw
const prevFormatted = formatNumber(prevNumberRef.current)
const [prevIntStrRaw = "", prevDecStrRaw = ""] = prevFormatted.split(".")
const prevIntStr =
padStart && prevIntStrRaw.length === 1 ? "0" + prevIntStrRaw : prevIntStrRaw
const adjustedPrevInt = React.useMemo(() => {
return prevIntStr.length > (newIntStr?.length ?? 0)
? prevIntStr.slice(-(newIntStr?.length ?? 0))
: prevIntStr.padStart(newIntStr?.length ?? 0, "0")
}, [prevIntStr, newIntStr])
const adjustedPrevDec = React.useMemo(() => {
if (!newDecStrRaw) return ""
return prevDecStrRaw.length > newDecStrRaw.length
? prevDecStrRaw.slice(0, newDecStrRaw.length)
: prevDecStrRaw.padEnd(newDecStrRaw.length, "0")
}, [prevDecStrRaw, newDecStrRaw])
React.useEffect(() => {
if (isInView) prevNumberRef.current = effectiveNumber
}, [effectiveNumber, isInView])
const intDigitCount = newIntStr?.length ?? 0
const intPlaces = React.useMemo(
() =>
Array.from({ length: intDigitCount }, (_, i) =>
Math.pow(10, intDigitCount - i - 1)
),
[intDigitCount]
)
const decPlaces = React.useMemo(
() =>
newDecStrRaw
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
Math.pow(10, newDecStrRaw.length - i - 1)
)
: [],
[newDecStrRaw]
)
const newDecValue = newDecStrRaw ? parseInt(newDecStrRaw, 10) : 0
const prevDecValue = adjustedPrevDec ? parseInt(adjustedPrevDec, 10) : 0
return (
<span
ref={localRef}
data-slot="sliding-number"
className={cn("flex items-center", className)}
{...props}
>
{isInView && Number(number) < 0 && <span className="mr-1">-</span>}
{intPlaces.map((place) => (
<SlidingNumberRoller
key={`int-${place}`}
prevValue={parseInt(adjustedPrevInt, 10)}
value={parseInt(newIntStr ?? "0", 10)}
place={place}
transition={transition}
/>
))}
{newDecStrRaw && (
<>
<span>{decimalSeparator}</span>
{decPlaces.map((place) => (
<SlidingNumberRoller
key={`dec-${place}`}
prevValue={prevDecValue}
value={newDecValue}
place={place}
transition={transition}
/>
))}
</>
)}
</span>
)
}
export { SlidingNumber, type SlidingNumberProps }
Update the import paths to match your project setup.
Props
SlidingNumberRollerProps
Prop | Type | Default |
---|---|---|
className? | string | - |
number | number | string | - |
inView? | boolean | false |
inViewMargin? | string | 0px |
inViewOnce? | boolean | true |
padStart? | boolean | false |
decimalSeparator? | string | . |
decimalPlaces? | number | 0 |
transition? | SpringOptions | { stiffness: 200, damping: 20, mass: 0.4 } |