From d89c534c0b21bff91747002a6e96bf12d114fcc2 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 08:46:22 +0300 Subject: [PATCH] fix: rewrite BackgroundBoxes from 225 DOM divs to single canvas element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: 225 individually animated DOM elements forced Chrome's compositor to create separate paint regions per element. Any hover state change on page content triggered re-compositing of all 225 regions, causing visible flickering on all pages. Canvas fix: single element renders the entire grid effect via requestAnimationFrame. Canvas content is pixel-based and cannot be affected by DOM hover state changes on other elements. Additional fixes: - Fix z-index collision: portal z-index -1 → -2 (was same as body::before noise) - Replace all transition-all with specific properties on .btn, .btn-icon, .input, .nav-item, .bottom-nav-item, checkbox - Remove backdrop-blur-sm from desktop .bento-card and .card (forced Chrome to re-sample animated background pixels on every hover) --- .../backgrounds/BackgroundRenderer.tsx | 2 +- .../ui/backgrounds/background-boxes.tsx | 186 +++++++++++++----- src/styles/globals.css | 34 ++-- 3 files changed, 160 insertions(+), 62 deletions(-) diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index d9dcb27..d712f5d 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -92,7 +92,7 @@ export function BackgroundRenderer() {
0 ? `blur(${effectiveConfig.blur}px)` : undefined, contain: 'strict', diff --git a/src/components/ui/backgrounds/background-boxes.tsx b/src/components/ui/backgrounds/background-boxes.tsx index 55b0350..62af05e 100644 --- a/src/components/ui/backgrounds/background-boxes.tsx +++ b/src/components/ui/backgrounds/background-boxes.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useRef, useMemo } from 'react'; import { sanitizeColor, clampNumber } from './types'; interface Props { @@ -16,60 +16,150 @@ const COLORS = [ '#c4b5fd', ]; +function hexToRgb(hex: string): [number, number, number] { + const v = parseInt(hex.slice(1), 16); + return [(v >> 16) & 255, (v >> 8) & 255, v & 255]; +} + +interface CellData { + rgb: [number, number, number]; + phase: number; + period: number; +} + +// Pre-computed transform constants for skewX(-48deg) skewY(14deg) scale(0.675) +const SKEW_X_TAN = Math.tan((-48 * Math.PI) / 180); +const SKEW_Y_TAN = Math.tan((14 * Math.PI) / 180); +const GRID_SCALE = 0.675; + +/** + * Animated boxes background rendered on a single element. + * + * Previous implementation used 225 DOM
elements with CSS @keyframes. + * Chrome's compositor created a separate paint region per div; any hover + * state change on page content forced Chrome to re-composite all 225 regions, + * causing visible flickering. A single canvas bypasses the DOM style/layout/paint + * pipeline entirely — hover interactions on other elements cannot trigger + * repaints of canvas content. + */ export default React.memo(function BackgroundBoxes({ settings }: Props) { + const canvasRef = useRef(null); const rows = clampNumber(settings.rows, 4, 30, 15); const cols = clampNumber(settings.cols, 4, 30, 15); const boxColor = sanitizeColor(settings.boxColor, '#818cf8'); - const cells = useMemo(() => { - const result: { color: string; delay: number; duration: number }[] = []; - for (let i = 0; i < rows * cols; i++) { - result.push({ - color: - boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor, - delay: Math.random() * 8, - duration: 3 + Math.random() * 4, - }); - } - return result; + const cells = useMemo((): CellData[] => { + return Array.from({ length: rows * cols }, () => ({ + rgb: hexToRgb( + boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor, + ), + phase: Math.random() * 8, + period: 3 + Math.random() * 4, + })); }, [rows, cols, boxColor]); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let animId = 0; + + const resize = () => { + const dpr = devicePixelRatio || 1; + const parent = canvas.parentElement; + if (!parent) return; + const rect = parent.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + }; + + resize(); + window.addEventListener('resize', resize); + + const draw = (now: number) => { + const t = now * 0.001; + const w = canvas.width; + const h = canvas.height; + + ctx.clearRect(0, 0, w, h); + ctx.save(); + + // Transform origin: center of viewport + const cx = w / 2; + const cy = h / 2; + ctx.translate(cx, cy); + + // Replicate CSS: skewX(-48deg) skewY(14deg) scale(0.675) + ctx.transform(1, 0, SKEW_X_TAN, 1, 0, 0); + ctx.transform(1, SKEW_Y_TAN, 0, 1, 0, 0); + ctx.scale(GRID_SCALE, GRID_SCALE); + + ctx.translate(-cx, -cy); + + // Grid: 300% of viewport, offset by -100% (matches the original CSS layout) + const gw = w * 3; + const gh = h * 3; + const ox = -w; + const oy = -h; + const cellW = gw / cols; + const cellH = gh / rows; + + // Draw colored cells + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + const cycleT = ((t + cell.phase) % cell.period) / cell.period; + const alpha = 0.15 * Math.sin(cycleT * Math.PI); + if (alpha < 0.005) continue; + + const col = i % cols; + const row = (i - col) / cols; + const [r, g, b] = cell.rgb; + + ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`; + ctx.fillRect(ox + col * cellW, oy + row * cellH, cellW, cellH); + } + + // Draw grid lines as a single batch (much cheaper than 225 individual strokeRects) + ctx.strokeStyle = 'rgba(51,65,85,0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); + + for (let r = 0; r <= rows; r++) { + const y = oy + r * cellH; + ctx.moveTo(ox, y); + ctx.lineTo(ox + gw, y); + } + for (let c = 0; c <= cols; c++) { + const x = ox + c * cellW; + ctx.moveTo(x, oy); + ctx.lineTo(x, oy + gh); + } + + ctx.stroke(); + ctx.restore(); + + animId = requestAnimationFrame(draw); + }; + + animId = requestAnimationFrame(draw); + + return () => { + cancelAnimationFrame(animId); + window.removeEventListener('resize', resize); + }; + }, [cells, rows, cols]); + return ( -
- {/* CSS keyframes for box fade — runs on compositor thread, zero main-thread cost */} - -
- {cells.map((cell, i) => ( -
- ))} -
-
+ ); }); diff --git a/src/styles/globals.css b/src/styles/globals.css index 0951096..8ad536d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -360,7 +360,7 @@ img.twemoji { @media (min-width: 1024px) { .bento-card { - @apply bg-dark-900/50 backdrop-blur-sm; + @apply bg-dark-900/60; } } @@ -455,7 +455,7 @@ img.twemoji { @media (min-width: 1024px) { .light .bento-card { - @apply bg-champagne-50/80 backdrop-blur-sm; + @apply bg-champagne-50/85; } } @@ -487,10 +487,9 @@ img.twemoji { box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05); } - /* Enable backdrop-blur only on desktop */ @media (min-width: 1024px) { .card { - @apply bg-dark-900/50 backdrop-blur-sm; + @apply bg-dark-900/60; } } @@ -520,7 +519,8 @@ img.twemoji { /* Buttons - Dark */ .btn { - @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 ease-smooth focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-900 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50; + @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium duration-200 ease-smooth focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-900 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50; + transition-property: color, background-color, border-color, box-shadow, transform, opacity; } .btn-primary { @@ -540,7 +540,8 @@ img.twemoji { } .btn-icon { - @apply rounded-lg p-2 text-dark-400 transition-all duration-200 hover:bg-dark-800 hover:text-dark-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 active:scale-95; + @apply rounded-lg p-2 text-dark-400 duration-200 hover:bg-dark-800 hover:text-dark-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 active:scale-95; + transition-property: color, background-color, transform; } /* Highlighted button for onboarding */ @@ -550,7 +551,8 @@ img.twemoji { /* Inputs - Dark */ .input { - @apply w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-100 placeholder-dark-500 transition-all duration-200 focus:border-accent-500/50 focus:outline-none focus:ring-2 focus:ring-accent-500/20; + @apply w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-100 placeholder-dark-500 duration-200 focus:border-accent-500/50 focus:outline-none focus:ring-2 focus:ring-accent-500/20; + transition-property: color, background-color, border-color, box-shadow; } .input-error { @@ -613,7 +615,8 @@ img.twemoji { } .progress-fill { - @apply h-full rounded-full transition-all duration-500 ease-smooth; + @apply h-full rounded-full duration-500 ease-smooth; + transition-property: width, background-color; } /* Stat card - Dark */ @@ -627,7 +630,8 @@ img.twemoji { /* Navigation - Dark */ .nav-item { - @apply flex items-center gap-3 rounded-xl px-4 py-3 text-dark-400 transition-all duration-200 hover:bg-dark-800/50 hover:text-dark-100; + @apply flex items-center gap-3 rounded-xl px-4 py-3 text-dark-400 duration-200 hover:bg-dark-800/50 hover:text-dark-100; + transition-property: color, background-color; } .nav-item-active { @@ -654,7 +658,8 @@ img.twemoji { } .bottom-nav-item { - @apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl px-3 py-2.5 text-dark-500 transition-all duration-200; + @apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl px-3 py-2.5 text-dark-500 duration-200; + transition-property: color, background-color; } .bottom-nav-item:hover { @@ -662,7 +667,8 @@ img.twemoji { } .bottom-nav-item-active { - @apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl bg-accent-500/15 px-3 py-2.5 text-accent-400 transition-all duration-200; + @apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl bg-accent-500/15 px-3 py-2.5 text-accent-400 duration-200; + transition-property: color, background-color; } /* Divider - Dark */ @@ -697,7 +703,7 @@ img.twemoji { @media (min-width: 1024px) { .light .card { - @apply bg-champagne-50/80 backdrop-blur-sm; + @apply bg-champagne-50/85; } } @@ -1252,7 +1258,9 @@ input[type='checkbox'] { border-radius: 0.375rem; background-color: rgb(var(--color-dark-700)); cursor: pointer; - transition: all 0.15s ease; + transition: + border-color 0.15s ease, + background-color 0.15s ease; position: relative; flex-shrink: 0; }