Reflow remaining basket lines with framer-motion when an item is removed, slide the per-line price when it changes, and tween the basket total up/down to its new amount instead of snapping to it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
69 lines
2.0 KiB
TypeScript
69 lines
2.0 KiB
TypeScript
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
|
import { useState } from 'react'
|
|
|
|
import styles from './AnimatedNumber.module.scss'
|
|
|
|
type AnimatedNumberProps = {
|
|
value: number
|
|
format?: (value: number) => string
|
|
align?: 'center' | 'start'
|
|
className?: string
|
|
}
|
|
|
|
const variants = {
|
|
initial: (direction: number) => ({ x: direction * 12, opacity: 0 }),
|
|
animate: { x: 0, opacity: 1 },
|
|
exit: (direction: number) => ({ x: direction * -12, opacity: 0 }),
|
|
}
|
|
|
|
// A quantity stepper's count slides leftward when incrementing (the new
|
|
// digit enters from the right, the old one exits to the left) and
|
|
// rightward when decrementing.
|
|
//
|
|
// `direction` is derived from a prop change during render (the React-docs
|
|
// "adjusting state when a prop changes" pattern) rather than mutating a ref
|
|
// 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,
|
|
format = String,
|
|
align = 'center',
|
|
className,
|
|
}: AnimatedNumberProps) => {
|
|
const reduceMotion = useReducedMotion()
|
|
const [previousValue, setPreviousValue] = useState(value)
|
|
const [direction, setDirection] = useState(1)
|
|
|
|
if (value !== previousValue) {
|
|
setDirection(value > previousValue ? 1 : -1)
|
|
setPreviousValue(value)
|
|
}
|
|
|
|
if (reduceMotion) {
|
|
return <span className={className}>{format(value)}</span>
|
|
}
|
|
|
|
return (
|
|
<span
|
|
className={`${styles.wrapper} ${className ?? ''}`}
|
|
style={{ justifyItems: align }}
|
|
>
|
|
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
|
|
<motion.span
|
|
key={value}
|
|
custom={direction}
|
|
variants={variants}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
|
className={styles.digit}
|
|
>
|
|
{format(value)}
|
|
</motion.span>
|
|
</AnimatePresence>
|
|
</span>
|
|
)
|
|
}
|