From 7f3728b5bf6f9af12cada89500ad73c3b08886b8 Mon Sep 17 00:00:00 2001 From: lukas Date: Thu, 17 Sep 2026 11:05:24 +0200 Subject: [PATCH] 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 --- src/components/ui/AnimatedCounter.tsx | 44 +++++++++++ src/components/ui/AnimatedNumber.tsx | 18 ++++- src/routes/basket.tsx | 103 ++++++++++++++------------ 3 files changed, 115 insertions(+), 50 deletions(-) create mode 100644 src/components/ui/AnimatedCounter.tsx 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} >
    - {resolvedLines.map(({ line, item, unitPriceCents }) => { - const names = optionNames(item, line.optionIds) - return ( -
  • -
    - {item.name} - {names.length > 0 && ( -
      - {names.map((name) => ( -
    • {name}
    • - ))} -
    - )} - - {formatPrice(unitPriceCents * line.quantity)} - -
    -
    - - - -
    -
  • - ) - })} + + {resolvedLines.map(({ line, item, unitPriceCents }) => { + const names = optionNames(item, line.optionIds) + return ( + +
    + {item.name} + {names.length > 0 && ( +
      + {names.map((name) => ( +
    • {name}
    • + ))} +
    + )} + +
    +
    + + + +
    +
    + ) + })} +
Total - {formatPrice(totalCents)} +