import { animate, AnimationOptions, HTMLMotionProps, motion, MotionConfigContext, useIsPresent, } from "motion/react" import { CSSProperties, forwardRef, useContext, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from "react" import { useIsInitialRender } from "../hooks/use-is-initial-render" import { Em } from "../types" import { getWidthInEm } from "../utils/get-width-in-ems" import { targetWidths } from "../utils/target-widths" import { maskHeight } from "./Mask" export const NumberDigit = forwardRef< HTMLSpanElement, Omit, "children"> & { value: number initialValue?: number } >(function NumberDigit( { value: _value, initialValue: _initialValue = _value, ...rest }, _ref ) { const { transition } = useContext(MotionConfigContext) const initialValue = useRef(_initialValue).current // non-reactive, like React's defaultValue props const isInitialRender = useIsInitialRender() const scope = useRef(null) const ref = useRef(null) useImperativeHandle(_ref, () => ref.current!, []) const numberRefs = useRef(new Array(10)) // Don't use a normal exit animation for this because we want it to trigger a resize: const isPresent = useIsPresent() const value = isPresent ? _value : 0 // Set the width to the width of the initial value immediately, so on the next render we animate from that: useLayoutEffect(() => { if (!scope.current || !numberRefs.current[initialValue]) return scope.current.style.width = getWidthInEm( numberRefs.current[initialValue] ) }, []) // Animate the y in a layout effect, because it's a FLIP const prevValue = useRef(_initialValue) useLayoutEffect(() => { if (!scope.current || value === prevValue.current) return const box = scope.current.getBoundingClientRect() const refBox = ref.current?.getBoundingClientRect() // Using a number seems to ensure Motion ends with "none", which we want: // Add the offset between the top of the inner and outer elements to account for // any current animation state: const initialY = box.height * (value - prevValue.current) + (box.top - (refBox ? refBox.top || 0 : box.top)) animate( scope.current, { y: [initialY, 0] }, transition as AnimationOptions ) return () => { prevValue.current = value } }, [value]) const [width, setWidth] = useState() useEffect(() => { // Skip setting the width if this is the first render and it's not going to animate: if (isInitialRender && initialValue === value) return if (!numberRefs.current[value]) return const w = getWidthInEm(numberRefs.current[value]) // Store the target width immediately, so it can be used for the section resize: if (ref.current) targetWidths.set(ref.current, w) // Trigger the actual layout animation by causing another render: setWidth(w) }, [value]) const renderNumber = (i: number) => ( void (numberRefs.current[i] = r)} > {i} ) return ( {value !== 0 && ( {new Array(value) .fill(null) .map((_, i) => renderNumber(i))} )} {renderNumber(value)} {value !== 9 && ( {new Array(9 - value) .fill(null) .map((_, i) => renderNumber(value + i + 1))} )} ) }) const digitFillStyle: CSSProperties = { display: "flex", flexDirection: "column", alignItems: "center", position: "absolute", width: "100%", }