fix: rewrite BackgroundBoxes from 225 DOM divs to single canvas element

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 <canvas> 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)
This commit is contained in:
Fringg
2026-02-27 08:46:22 +03:00
parent fe32322c32
commit d89c534c0b
3 changed files with 160 additions and 62 deletions

View File

@@ -92,7 +92,7 @@ export function BackgroundRenderer() {
<div
className="pointer-events-none fixed inset-0"
style={{
zIndex: -1,
zIndex: -2,
opacity: effectiveConfig.opacity,
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
contain: 'strict',

View File

@@ -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 <canvas> element.
*
* Previous implementation used 225 DOM <div> 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<HTMLCanvasElement>(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 (
<div className="absolute inset-0 overflow-hidden">
{/* CSS keyframes for box fade — runs on compositor thread, zero main-thread cost */}
<style>{`
@keyframes boxFade {
0%, 100% { opacity: 0; }
50% { opacity: 0.15; }
}
`}</style>
<div
style={{
position: 'absolute',
top: '-100%',
left: '-100%',
width: '300%',
height: '300%',
display: 'grid',
gridTemplateRows: `repeat(${rows}, 1fr)`,
gridTemplateColumns: `repeat(${cols}, 1fr)`,
transform: 'skewX(-48deg) skewY(14deg) scale(0.675) translateZ(0)',
transformOrigin: 'center center',
contain: 'strict',
}}
>
{cells.map((cell, i) => (
<div
key={i}
className="border border-slate-700/50"
style={{
backgroundColor: cell.color,
animation: `boxFade ${cell.duration}s ${cell.delay}s ease-in-out infinite`,
opacity: 0,
}}
/>
))}
</div>
</div>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
}}
/>
);
});

View File

@@ -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;
}