diff --git a/src/components/ui/AnimatedCounter.tsx b/src/components/ui/AnimatedCounter.tsx new file mode 100644 index 0000000..10042a8 --- /dev/null +++ b/src/components/ui/AnimatedCounter.tsx @@ -0,0 +1,44 @@ +import { animate, useReducedMotion } from 'framer-motion' +import { useEffect, useRef, useState } from 'react' + +type AnimatedCounterProps = { + value: number + format?: (value: number) => string + className?: string +} + +// Tweens the displayed number from its previous value to the new one, +// rather than swapping straight to it, so a price change reads as counting +// up or down. `displayValueRef` tracks the in-flight value so a change that +// interrupts a running tween continues from wherever it currently is +// instead of jumping back to the last committed value. +export const AnimatedCounter = ({ + value, + format = String, + className, +}: AnimatedCounterProps) => { + const reduceMotion = useReducedMotion() + const [displayValue, setDisplayValue] = useState(value) + const displayValueRef = useRef(value) + + useEffect(() => { + if (reduceMotion) { + displayValueRef.current = value + setDisplayValue(value) + return + } + + const controls = animate(displayValueRef.current, value, { + duration: 0.5, + ease: 'easeOut', + onUpdate: (latest) => { + displayValueRef.current = latest + setDisplayValue(latest) + }, + }) + + return () => controls.stop() + }, [value, reduceMotion]) + + return {format(Math.round(displayValue))} +} diff --git a/src/components/ui/AnimatedNumber.tsx b/src/components/ui/AnimatedNumber.tsx index 0a4a7c4..425525e 100644 --- a/src/components/ui/AnimatedNumber.tsx +++ b/src/components/ui/AnimatedNumber.tsx @@ -5,6 +5,8 @@ import styles from './AnimatedNumber.module.scss' type AnimatedNumberProps = { value: number + format?: (value: number) => string + align?: 'center' | 'start' className?: string } @@ -23,7 +25,12 @@ const variants = { // in the render body — mutating a ref here would already reflect the new // value by the time `exit`'s "custom" is resolved for the outgoing digit, // so every transition — increment or decrement — read as "forward". -export const AnimatedNumber = ({ value, className }: AnimatedNumberProps) => { +export const AnimatedNumber = ({ + value, + format = String, + align = 'center', + className, +}: AnimatedNumberProps) => { const reduceMotion = useReducedMotion() const [previousValue, setPreviousValue] = useState(value) const [direction, setDirection] = useState(1) @@ -34,11 +41,14 @@ export const AnimatedNumber = ({ value, className }: AnimatedNumberProps) => { } if (reduceMotion) { - return {value} + return {format(value)} } return ( - + { transition={{ duration: 0.15, ease: 'easeOut' }} className={styles.digit} > - {value} + {format(value)} diff --git a/src/routes/basket.tsx b/src/routes/basket.tsx index a2d31aa..e9bc2e4 100644 --- a/src/routes/basket.tsx +++ b/src/routes/basket.tsx @@ -3,6 +3,7 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import { BasketIcon } from '#/components/basket/BasketLink.tsx' import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx' +import { AnimatedCounter } from '#/components/ui/AnimatedCounter.tsx' import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx' import type { MenuItemWithRecipe } from '#/domain/ordering.ts' import { useBasket } from '#/lib/basket.tsx' @@ -92,56 +93,66 @@ const BasketPage = () => { transition={transition} >
Total - {formatPrice(totalCents)} +