From d5ea352775172305f50ee1d1959fb187762610b5 Mon Sep 17 00:00:00 2001 From: gbanyan Date: Sat, 14 Feb 2026 00:02:35 +0800 Subject: [PATCH] perf: frame-rate independent animation + Scroll-Driven progress bar - MatrixRain: add delta time for consistent speed across 60/120/144Hz displays - ReadingProgress: use Scroll-Driven Animations API with JS fallback - Fallback to scroll events for unsupported browsers (Firefox) and prefers-reduced-motion Co-authored-by: Cursor --- components/matrix-rain.tsx | 12 +++++-- components/reading-progress.tsx | 55 +++++++++++++++++++++++++++------ styles/globals.css | 11 +++++++ 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/components/matrix-rain.tsx b/components/matrix-rain.tsx index 7267851..4d1d321 100644 --- a/components/matrix-rain.tsx +++ b/components/matrix-rain.tsx @@ -58,9 +58,14 @@ export function MatrixRain({ })); let animationId: number; + let lastTime: number | null = null; - const draw = () => { + const draw = (timestamp: number) => { const rect = canvas.getBoundingClientRect(); + const delta = + lastTime !== null ? (timestamp - lastTime) / 1000 : 1 / 60; + lastTime = timestamp; + ctx.fillStyle = 'rgba(15, 23, 42, 0.05)'; ctx.fillRect(0, 0, rect.width, rect.height); @@ -83,7 +88,8 @@ export function MatrixRain({ ); } - drop.y += drop.speed * fontSize; + // Frame-rate independent: scale by delta, 60fps as baseline + drop.y += drop.speed * fontSize * delta * 60; if (drop.y > rect.height + 100) { drop.y = -50; drop.charIndex = (drop.charIndex + 1) % 20; @@ -95,7 +101,7 @@ export function MatrixRain({ animationId = requestAnimationFrame(draw); }; - draw(); + animationId = requestAnimationFrame(draw); return () => { cancelAnimationFrame(animationId); diff --git a/components/reading-progress.tsx b/components/reading-progress.tsx index b14b297..6b9be3b 100644 --- a/components/reading-progress.tsx +++ b/components/reading-progress.tsx @@ -3,16 +3,34 @@ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; +function supportsScrollDrivenAnimations(): boolean { + if (typeof CSS === 'undefined') return false; + return CSS.supports?.('animation-timeline', 'scroll()') ?? false; +} + export function ReadingProgress() { const [mounted, setMounted] = useState(false); const [progress, setProgress] = useState(0); + const [useScrollDriven, setUseScrollDriven] = useState(false); useEffect(() => { setMounted(true); + const updateMode = () => { + const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ).matches; + setUseScrollDriven( + supportsScrollDrivenAnimations() && !prefersReducedMotion + ); + }; + updateMode(); + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + mq.addEventListener('change', updateMode); + return () => mq.removeEventListener('change', updateMode); }, []); useEffect(() => { - if (!mounted) return; + if (!mounted || useScrollDriven) return; const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = document.documentElement; @@ -28,21 +46,38 @@ export function ReadingProgress() { handleScroll(); window.addEventListener('scroll', handleScroll, { passive: true }); return () => window.removeEventListener('scroll', handleScroll); - }, [mounted]); - + }, [mounted, useScrollDriven]); if (!mounted) return null; return createPortal(
-
0 ? 1 : 0 }} - > -
-
, document.body diff --git a/styles/globals.css b/styles/globals.css index bf5aba8..b09d785 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -97,6 +97,17 @@ } } +/* Reading progress bar - Scroll-Driven Animations API (Chrome 115+, Safari 26+, Edge 115+) */ +@keyframes reading-progress-grow { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} + +.reading-progress-bar-scroll-driven { + animation: reading-progress-grow auto linear forwards; + animation-timeline: scroll(block root); +} + :root { --motion-duration-short: 180ms; --motion-duration-medium: 260ms;