Animate basket price and item changes

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>
This commit is contained in:
2026-09-17 11:05:24 +02:00
co-authored by Claude Sonnet 5
parent 3d736769e0
commit 7f3728b5bf
3 changed files with 115 additions and 50 deletions
+44
View File
@@ -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 <span className={className}>{format(Math.round(displayValue))}</span>
}
+14 -4
View File
@@ -5,6 +5,8 @@ import styles from './AnimatedNumber.module.scss'
type AnimatedNumberProps = { type AnimatedNumberProps = {
value: number value: number
format?: (value: number) => string
align?: 'center' | 'start'
className?: string className?: string
} }
@@ -23,7 +25,12 @@ const variants = {
// in the render body — mutating a ref here would already reflect the new // 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, // value by the time `exit`'s "custom" is resolved for the outgoing digit,
// so every transition — increment or decrement — read as "forward". // 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 reduceMotion = useReducedMotion()
const [previousValue, setPreviousValue] = useState(value) const [previousValue, setPreviousValue] = useState(value)
const [direction, setDirection] = useState(1) const [direction, setDirection] = useState(1)
@@ -34,11 +41,14 @@ export const AnimatedNumber = ({ value, className }: AnimatedNumberProps) => {
} }
if (reduceMotion) { if (reduceMotion) {
return <span className={className}>{value}</span> return <span className={className}>{format(value)}</span>
} }
return ( return (
<span className={`${styles.wrapper} ${className ?? ''}`}> <span
className={`${styles.wrapper} ${className ?? ''}`}
style={{ justifyItems: align }}
>
<AnimatePresence mode="popLayout" initial={false} custom={direction}> <AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.span <motion.span
key={value} key={value}
@@ -50,7 +60,7 @@ export const AnimatedNumber = ({ value, className }: AnimatedNumberProps) => {
transition={{ duration: 0.15, ease: 'easeOut' }} transition={{ duration: 0.15, ease: 'easeOut' }}
className={styles.digit} className={styles.digit}
> >
{value} {format(value)}
</motion.span> </motion.span>
</AnimatePresence> </AnimatePresence>
</span> </span>
+17 -6
View File
@@ -3,6 +3,7 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { BasketIcon } from '#/components/basket/BasketLink.tsx' import { BasketIcon } from '#/components/basket/BasketLink.tsx'
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx' import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
import { AnimatedCounter } from '#/components/ui/AnimatedCounter.tsx'
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx' import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
import type { MenuItemWithRecipe } from '#/domain/ordering.ts' import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import { useBasket } from '#/lib/basket.tsx' import { useBasket } from '#/lib/basket.tsx'
@@ -92,11 +93,17 @@ const BasketPage = () => {
transition={transition} transition={transition}
> >
<ul className={styles.lines}> <ul className={styles.lines}>
<AnimatePresence mode="sync" initial={false}>
{resolvedLines.map(({ line, item, unitPriceCents }) => { {resolvedLines.map(({ line, item, unitPriceCents }) => {
const names = optionNames(item, line.optionIds) const names = optionNames(item, line.optionIds)
return ( return (
<li <motion.li
key={`${line.menuItemId}:${line.optionIds.join(',')}`} key={`${line.menuItemId}:${line.optionIds.join(',')}`}
layout
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ ...transition, layout: transition }}
className={styles.line} className={styles.line}
> >
<div className={styles.lineInfo}> <div className={styles.lineInfo}>
@@ -108,9 +115,12 @@ const BasketPage = () => {
))} ))}
</ul> </ul>
)} )}
<span className={styles.linePrice}> <AnimatedNumber
{formatPrice(unitPriceCents * line.quantity)} value={unitPriceCents * line.quantity}
</span> format={formatPrice}
align="start"
className={styles.linePrice}
/>
</div> </div>
<div className={styles.quantity}> <div className={styles.quantity}>
<button <button
@@ -134,14 +144,15 @@ const BasketPage = () => {
<span aria-hidden="true">+</span> <span aria-hidden="true">+</span>
</button> </button>
</div> </div>
</li> </motion.li>
) )
})} })}
</AnimatePresence>
</ul> </ul>
<div className={styles.total}> <div className={styles.total}>
<span>Total</span> <span>Total</span>
<span>{formatPrice(totalCents)}</span> <AnimatedCounter value={totalCents} format={formatPrice} />
</div> </div>
<CheckoutSection <CheckoutSection