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-scroll {
from {
@keyframes menu-backdrop-float { background-position: 0 0;
0%,
100% {
transform: translateY(0) rotate(var(--icon-rotate, 0deg));
} }
50% { to {
transform: translateY(-6px) rotate(calc(var(--icon-rotate, 0deg) + 6deg)); // 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; z-index: -1;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
filter: blur(2px); filter: blur(4px) grayscale(1);
} opacity: 0.6;
background-repeat: repeat;
.icon { background-size: 240px 240px;
position: absolute;
color: $color-primary;
opacity: 0.16;
transform: rotate(var(--icon-rotate, 0deg));
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
animation: menu-backdrop-float 5s ease-in-out infinite; animation: menu-backdrop-scroll 20s linear infinite;
}
svg {
width: 100%;
height: 100%;
} }
} }
+57 -82
View File
@@ -1,51 +1,30 @@
import type { CSSProperties } from 'react'
import styles from './MenuBackdrop.module.scss' import styles from './MenuBackdrop.module.scss'
const PizzaIcon = () => ( // Rendered once as a tiny repeating SVG tile (not as individual DOM icons)
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> // so the whole pattern can scroll diagonally forever via a plain
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3 3 20h18L12 3Z" /> // `background-position` CSS animation — moving by exactly one tile per
<path // loop wraps seamlessly since the background repeats.
strokeLinecap="round" const pizza = (color: string) => `
d="M4.5 17.5c2.5-1.5 4.5-1.5 7.5-.5s5 1 7.5-.5" <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="currentColor" stroke="none" /> <circle cx="10" cy="14" r="0.9" fill="${color}" />
<circle cx="14.5" cy="15.5" r="0.9" fill="currentColor" stroke="none" /> <circle cx="14.5" cy="15.5" r="0.9" fill="${color}" />
</svg> `
)
const BurgerIcon = () => ( const burger = (color: string) => `
<svg <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" />
viewBox="0 0 24 24" <path d="M3 10.5h18" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
fill="none" <path d="M4 13.5h16" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
stroke="currentColor" <path d="M3.5 16.5h17" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
strokeWidth="1.5" <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" />
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 FriesIcon = () => ( const fries = (color: string) => `
<svg <path d="M6 9 5 21h14l-1-12Z" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
viewBox="0 0 24 24" <path d="M9 4.5v6M12 3.5v7M15 4.5v6" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
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 ICONS = [PizzaIcon, BurgerIcon, FriesIcon] const ICON_VARIANTS = [pizza, burger, fries]
const ICON_COLORS = [ const ICON_COLORS = [
'#e2725b', // tomato '#e2725b', // tomato
'#d9a441', // crust '#d9a441', // crust
@@ -55,45 +34,41 @@ const ICON_COLORS = [
'#8a5a44', // cocoa '#8a5a44', // cocoa
] ]
// Fixed, hand-placed positions rather than random ones — random values would // One icon centered in each grid cell within the tile, so they line up
// differ between the server-rendered and first client-rendered HTML and // evenly instead of clustering.
// trigger a hydration mismatch. Positioned as percentages of the containing const TILE_SIZE = 240
// card, not the viewport, so this only ever covers whatever box renders it. const GRID_COLS = 4
const PLACEMENTS = [ const GRID_ROWS = 4
{ top: '10%', left: '8%', size: 32, rotate: -14, delay: 0 }, const CELL_WIDTH = TILE_SIZE / GRID_COLS
{ top: '14%', left: '82%', size: 26, rotate: 18, delay: 0.5 }, const CELL_HEIGHT = TILE_SIZE / GRID_ROWS
{ top: '40%', left: '20%', size: 24, rotate: 8, delay: 1.1 }, const ICON_SIZE = 30
{ top: '55%', left: '80%', size: 30, rotate: -10, delay: 0.3 }, const ICON_SCALE = ICON_SIZE / 24
{ top: '78%', left: '14%', size: 26, rotate: 16, delay: 1.6 },
{ top: '82%', left: '58%', size: 22, rotate: -20, delay: 0.8 }, const buildTileSvg = () => {
{ top: '22%', left: '48%', size: 20, rotate: 6, delay: 1.3 }, const instances = Array.from(
{ top: '62%', left: '44%', size: 24, rotate: -6, delay: 0.2 }, { 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 = () => ( export const MenuBackdrop = () => (
<div className={styles.backdrop} aria-hidden="true"> <div
{PLACEMENTS.map((placement, index) => { className={styles.backdrop}
const Icon = ICONS[index % ICONS.length] aria-hidden="true"
const color = ICON_COLORS[index % ICON_COLORS.length] style={{ backgroundImage: `url("${TILE_DATA_URL}")` }}
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>
) )