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:
@@ -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>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
+57
-46
@@ -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,56 +93,66 @@ const BasketPage = () => {
|
|||||||
transition={transition}
|
transition={transition}
|
||||||
>
|
>
|
||||||
<ul className={styles.lines}>
|
<ul className={styles.lines}>
|
||||||
{resolvedLines.map(({ line, item, unitPriceCents }) => {
|
<AnimatePresence mode="sync" initial={false}>
|
||||||
const names = optionNames(item, line.optionIds)
|
{resolvedLines.map(({ line, item, unitPriceCents }) => {
|
||||||
return (
|
const names = optionNames(item, line.optionIds)
|
||||||
<li
|
return (
|
||||||
key={`${line.menuItemId}:${line.optionIds.join(',')}`}
|
<motion.li
|
||||||
className={styles.line}
|
key={`${line.menuItemId}:${line.optionIds.join(',')}`}
|
||||||
>
|
layout
|
||||||
<div className={styles.lineInfo}>
|
initial={{ opacity: 0, y: -10 }}
|
||||||
<span className={styles.lineName}>{item.name}</span>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
{names.length > 0 && (
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
<ul className={styles.lineOptions}>
|
transition={{ ...transition, layout: transition }}
|
||||||
{names.map((name) => (
|
className={styles.line}
|
||||||
<li key={name}>{name}</li>
|
>
|
||||||
))}
|
<div className={styles.lineInfo}>
|
||||||
</ul>
|
<span className={styles.lineName}>{item.name}</span>
|
||||||
)}
|
{names.length > 0 && (
|
||||||
<span className={styles.linePrice}>
|
<ul className={styles.lineOptions}>
|
||||||
{formatPrice(unitPriceCents * line.quantity)}
|
{names.map((name) => (
|
||||||
</span>
|
<li key={name}>{name}</li>
|
||||||
</div>
|
))}
|
||||||
<div className={styles.quantity}>
|
</ul>
|
||||||
<button
|
)}
|
||||||
type="button"
|
<AnimatedNumber
|
||||||
className={styles.stepperButton}
|
value={unitPriceCents * line.quantity}
|
||||||
onClick={() => remove(line.menuItemId, line.optionIds)}
|
format={formatPrice}
|
||||||
aria-label={`Remove one ${item.name}`}
|
align="start"
|
||||||
>
|
className={styles.linePrice}
|
||||||
<span aria-hidden="true">−</span>
|
/>
|
||||||
</button>
|
</div>
|
||||||
<AnimatedNumber
|
<div className={styles.quantity}>
|
||||||
value={line.quantity}
|
<button
|
||||||
className={styles.count}
|
type="button"
|
||||||
/>
|
className={styles.stepperButton}
|
||||||
<button
|
onClick={() => remove(line.menuItemId, line.optionIds)}
|
||||||
type="button"
|
aria-label={`Remove one ${item.name}`}
|
||||||
className={styles.stepperButton}
|
>
|
||||||
onClick={() => add(line.menuItemId, line.optionIds)}
|
<span aria-hidden="true">−</span>
|
||||||
aria-label={`Add one ${item.name}`}
|
</button>
|
||||||
>
|
<AnimatedNumber
|
||||||
<span aria-hidden="true">+</span>
|
value={line.quantity}
|
||||||
</button>
|
className={styles.count}
|
||||||
</div>
|
/>
|
||||||
</li>
|
<button
|
||||||
)
|
type="button"
|
||||||
})}
|
className={styles.stepperButton}
|
||||||
|
onClick={() => add(line.menuItemId, line.optionIds)}
|
||||||
|
aria-label={`Add one ${item.name}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">+</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user