Rework the icon backdrop into a seamlessly scrolling tiled pattern

Replace the per-icon DOM elements (with client-side randomized
placement) with a single small SVG tile rendered once and set as a
repeating CSS background-image. Icons sit in a 4x4 grid within the
tile, evenly spaced and the same size, cycling through the food-icon
shapes and color palette.

Animates via background-position instead of per-element transforms:
moving by exactly one tile size per loop (linear, infinite) scrolls
the whole pattern diagonally forever with a perfectly seamless wrap,
which per-icon placements couldn't do without quadruplicating the DOM.
Also tried a grayscale pass (filter: grayscale(1)) alongside the blur,
now bumped from 2px to 4px.

This also sidesteps the hydration-mismatch concern entirely — there's
no more client-only randomization step, so no server/client mismatch
risk to guard against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvYdJ3os7tumimsLdLu2j9
This commit is contained in:
2026-09-13 20:47:21 +02:00
co-authored by Claude Sonnet 5
parent aa8d40bc4e
commit c539ef84cd
2 changed files with 69 additions and 104 deletions
@@ -1,12 +1,11 @@
@use '../../styles/variables' as *;
@keyframes menu-backdrop-float {
0%,
100% {
transform: translateY(0) rotate(var(--icon-rotate, 0deg));
@keyframes menu-backdrop-scroll {
from {
background-position: 0 0;
}
50% {
transform: translateY(-6px) rotate(calc(var(--icon-rotate, 0deg) + 6deg));
to {
// Exactly one tile (see TILE_SIZE in MenuBackdrop.tsx) — moving by
// precisely that distance loops seamlessly since the background repeats.
background-position: 240px 240px;
}
}
@@ -16,21 +15,12 @@
z-index: -1;
overflow: hidden;
pointer-events: none;
filter: blur(2px);
}
.icon {
position: absolute;
color: $color-primary;
opacity: 0.16;
transform: rotate(var(--icon-rotate, 0deg));
filter: blur(4px) grayscale(1);
opacity: 0.6;
background-repeat: repeat;
background-size: 240px 240px;
@media (prefers-reduced-motion: no-preference) {
animation: menu-backdrop-float 5s ease-in-out infinite;
}
svg {
width: 100%;
height: 100%;
animation: menu-backdrop-scroll 20s linear infinite;
}
}
+57 -82
View File
@@ -1,51 +1,30 @@
import type { CSSProperties } from 'react'
import styles from './MenuBackdrop.module.scss'
const PizzaIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3 3 20h18L12 3Z" />
<path
strokeLinecap="round"
d="M4.5 17.5c2.5-1.5 4.5-1.5 7.5-.5s5 1 7.5-.5"
/>
<circle cx="10" cy="14" r="0.9" fill="currentColor" stroke="none" />
<circle cx="14.5" cy="15.5" r="0.9" fill="currentColor" stroke="none" />
</svg>
)
// Rendered once as a tiny repeating SVG tile (not as individual DOM icons)
// so the whole pattern can scroll diagonally forever via a plain
// `background-position` CSS animation — moving by exactly one tile per
// loop wraps seamlessly since the background repeats.
const pizza = (color: string) => `
<path d="M12 3 3 20h18L12 3Z" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4.5 17.5c2.5-1.5 4.5-1.5 7.5-.5s5 1 7.5-.5" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" />
<circle cx="10" cy="14" r="0.9" fill="${color}" />
<circle cx="14.5" cy="15.5" r="0.9" fill="${color}" />
`
const BurgerIcon = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 10c0-3 3.5-5 8-5s8 2 8 5" />
<path d="M3 10.5h18" />
<path d="M4 13.5h16" />
<path d="M3.5 16.5h17" />
<path d="M4 19c0 1 .8 1.5 2.2 1.5h11.6c1.4 0 2.2-.5 2.2-1.5" />
</svg>
)
const burger = (color: string) => `
<path d="M4 10c0-3 3.5-5 8-5s8 2 8 5" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M3 10.5h18" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4 13.5h16" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M3.5 16.5h17" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4 19c0 1 .8 1.5 2.2 1.5h11.6c1.4 0 2.2-.5 2.2-1.5" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
`
const FriesIcon = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 9 5 21h14l-1-12Z" />
<path d="M9 4.5v6M12 3.5v7M15 4.5v6" />
</svg>
)
const fries = (color: string) => `
<path d="M6 9 5 21h14l-1-12Z" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 4.5v6M12 3.5v7M15 4.5v6" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
`
const ICONS = [PizzaIcon, BurgerIcon, FriesIcon]
const ICON_VARIANTS = [pizza, burger, fries]
const ICON_COLORS = [
'#e2725b', // tomato
'#d9a441', // crust
@@ -55,45 +34,41 @@ const ICON_COLORS = [
'#8a5a44', // cocoa
]
// Fixed, hand-placed positions rather than random ones — random values would
// differ between the server-rendered and first client-rendered HTML and
// trigger a hydration mismatch. Positioned as percentages of the containing
// card, not the viewport, so this only ever covers whatever box renders it.
const PLACEMENTS = [
{ top: '10%', left: '8%', size: 32, rotate: -14, delay: 0 },
{ top: '14%', left: '82%', size: 26, rotate: 18, delay: 0.5 },
{ top: '40%', left: '20%', size: 24, rotate: 8, delay: 1.1 },
{ top: '55%', left: '80%', size: 30, rotate: -10, delay: 0.3 },
{ top: '78%', left: '14%', size: 26, rotate: 16, delay: 1.6 },
{ top: '82%', left: '58%', size: 22, rotate: -20, delay: 0.8 },
{ top: '22%', left: '48%', size: 20, rotate: 6, delay: 1.3 },
{ top: '62%', left: '44%', size: 24, rotate: -6, delay: 0.2 },
]
// One icon centered in each grid cell within the tile, so they line up
// evenly instead of clustering.
const TILE_SIZE = 240
const GRID_COLS = 4
const GRID_ROWS = 4
const CELL_WIDTH = TILE_SIZE / GRID_COLS
const CELL_HEIGHT = TILE_SIZE / GRID_ROWS
const ICON_SIZE = 30
const ICON_SCALE = ICON_SIZE / 24
const buildTileSvg = () => {
const instances = Array.from(
{ length: GRID_COLS * GRID_ROWS },
(_, index) => {
const col = index % GRID_COLS
const row = Math.floor(index / GRID_COLS)
const x = col * CELL_WIDTH + CELL_WIDTH / 2
const y = row * CELL_HEIGHT + CELL_HEIGHT / 2
const rotate = ((index * 29) % 48) - 24
const color = ICON_COLORS[index % ICON_COLORS.length]
const icon = ICON_VARIANTS[index % ICON_VARIANTS.length](color)
const transform = `translate(${x.toFixed(1)} ${y.toFixed(1)}) rotate(${rotate}) scale(${ICON_SCALE.toFixed(3)}) translate(-12 -12)`
return `<g transform="${transform}">${icon}</g>`
},
).join('')
return `<svg xmlns="http://www.w3.org/2000/svg" width="${TILE_SIZE}" height="${TILE_SIZE}" viewBox="0 0 ${TILE_SIZE} ${TILE_SIZE}">${instances}</svg>`
}
const TILE_DATA_URL = `data:image/svg+xml,${encodeURIComponent(buildTileSvg())}`
export const MenuBackdrop = () => (
<div className={styles.backdrop} aria-hidden="true">
{PLACEMENTS.map((placement, index) => {
const Icon = ICONS[index % ICONS.length]
const color = ICON_COLORS[index % ICON_COLORS.length]
return (
<span
key={index}
className={styles.icon}
style={
{
top: placement.top,
left: placement.left,
width: placement.size,
height: placement.size,
animationDelay: `${placement.delay}s`,
'--icon-rotate': `${placement.rotate}deg`,
color,
} as CSSProperties
}
>
<Icon />
</span>
)
})}
</div>
<div
className={styles.backdrop}
aria-hidden="true"
style={{ backgroundImage: `url("${TILE_DATA_URL}")` }}
/>
)