Animate quantity steppers and make the nav bar sticky

- Add an AnimatedNumber component that slides a quantity stepper's
  count horizontally — left on increment, right on decrement — and
  wire it into both the menu and basket steppers.
- Make the top nav bar position: sticky, with an explicit background
  so scrolled content doesn't show through it.
- Reserve the scrollbar's gutter on every page (scrollable or not) so
  the sticky header doesn't shift width when navigating between short
  and tall pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 12:30:56 +02:00
co-authored by Claude Sonnet 5
parent 79bd64ce8c
commit e98b0d86d2
6 changed files with 86 additions and 3 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
import styles from './MenuList.module.scss'
@@ -51,7 +52,7 @@ export const MenuList = ({
>
−
</button>
<span className={styles.count}>{quantity}</span>
<AnimatedNumber value={quantity} className={styles.count} />
<button
type="button"
className={styles.stepperButton}
@@ -0,0 +1,11 @@
.wrapper {
position: relative;
display: inline-grid;
justify-items: center;
overflow: hidden;
vertical-align: middle;
}
.digit {
grid-area: 1 / 1;
}
+58
View File
@@ -0,0 +1,58 @@
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useState } from 'react'
import styles from './AnimatedNumber.module.scss'
type AnimatedNumberProps = {
value: number
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, 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}>{value}</span>
}
return (
<span className={`${styles.wrapper} ${className ?? ''}`}>
<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}
>
{value}
</motion.span>
</AnimatePresence>
</span>
)
}
+3 -1
View File
@@ -1,7 +1,8 @@
@use '../styles/variables' as *;
.topBar {
position: relative;
position: sticky;
top: 0;
// Explicit stacking context, ranked above any page content: setting
// `view-transition-name` on an element (here and on OrderStatusView's
// order card) implicitly makes it its own stacking context too — without
@@ -13,6 +14,7 @@
justify-content: space-between;
align-items: center;
padding: $space-3;
background: $color-background;
border-bottom: 1px solid $color-border;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
import { useBasket } from '#/lib/basket.tsx'
import { getBrand } from '#/server/brand.ts'
import { getMenu } from '#/server/menu.ts'
@@ -54,7 +55,7 @@ const BasketPage = () => {
>
−
</button>
<span className={styles.count}>{quantity}</span>
<AnimatedNumber value={quantity} className={styles.count} />
<button
type="button"
className={styles.stepperButton}
+10
View File
@@ -6,6 +6,16 @@
box-sizing: border-box;
}
// Reserve the scrollbar's width on every page, scrollable or not — without
// this, the sticky nav bar (and page content) shifts a few pixels wider
// when navigating from a short page (no scrollbar) to a tall one (has one).
// `scrollbar-gutter` only takes effect on an actual scroll container, so
// `html` needs an explicit non-visible overflow for it to apply at all.
html {
overflow-y: scroll;
scrollbar-gutter: stable;
}
body {
margin: 0;
font-family: