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:
@@ -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%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user