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 <cursoragent@cursor.com>
This commit is contained in:
@@ -58,9 +58,14 @@ export function MatrixRain({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let animationId: number;
|
let animationId: number;
|
||||||
|
let lastTime: number | null = null;
|
||||||
|
|
||||||
const draw = () => {
|
const draw = (timestamp: number) => {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const delta =
|
||||||
|
lastTime !== null ? (timestamp - lastTime) / 1000 : 1 / 60;
|
||||||
|
lastTime = timestamp;
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.05)';
|
ctx.fillStyle = 'rgba(15, 23, 42, 0.05)';
|
||||||
ctx.fillRect(0, 0, rect.width, rect.height);
|
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) {
|
if (drop.y > rect.height + 100) {
|
||||||
drop.y = -50;
|
drop.y = -50;
|
||||||
drop.charIndex = (drop.charIndex + 1) % 20;
|
drop.charIndex = (drop.charIndex + 1) % 20;
|
||||||
@@ -95,7 +101,7 @@ export function MatrixRain({
|
|||||||
animationId = requestAnimationFrame(draw);
|
animationId = requestAnimationFrame(draw);
|
||||||
};
|
};
|
||||||
|
|
||||||
draw();
|
animationId = requestAnimationFrame(draw);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(animationId);
|
cancelAnimationFrame(animationId);
|
||||||
|
|||||||
@@ -3,16 +3,34 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
function supportsScrollDrivenAnimations(): boolean {
|
||||||
|
if (typeof CSS === 'undefined') return false;
|
||||||
|
return CSS.supports?.('animation-timeline', 'scroll()') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
export function ReadingProgress() {
|
export function ReadingProgress() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [useScrollDriven, setUseScrollDriven] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return;
|
if (!mounted || useScrollDriven) return;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
|
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
|
||||||
@@ -28,21 +46,38 @@ export function ReadingProgress() {
|
|||||||
handleScroll();
|
handleScroll();
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
}, [mounted]);
|
}, [mounted, useScrollDriven]);
|
||||||
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-[1200] h-1.5 bg-transparent">
|
<div className="pointer-events-none fixed inset-x-0 top-0 z-[1200] h-1.5 bg-transparent">
|
||||||
<div className="relative h-1.5 w-full overflow-visible">
|
<div className="relative h-1.5 w-full overflow-visible">
|
||||||
<div
|
{useScrollDriven ? (
|
||||||
className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)] transition-[transform,opacity] duration-300 ease-out"
|
<div className="reading-progress-bar-scroll-driven absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)]">
|
||||||
style={{ transform: `scaleX(${progress / 100})`, opacity: progress > 0 ? 1 : 0 }}
|
<span
|
||||||
>
|
className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/80 blur-[1px] dark:bg-slate-900/80"
|
||||||
<span className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/80 blur-[1px] dark:bg-slate-900/80" aria-hidden="true" />
|
aria-hidden="true"
|
||||||
</div>
|
/>
|
||||||
<span className="absolute inset-x-0 top-2 h-px bg-gradient-to-r from-transparent via-blue-200/40 to-transparent blur-sm dark:via-blue-900/30" aria-hidden="true" />
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)] transition-[transform,opacity] duration-300 ease-out"
|
||||||
|
style={{
|
||||||
|
transform: `scaleX(${progress / 100})`,
|
||||||
|
opacity: progress > 0 ? 1 : 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/80 blur-[1px] dark:bg-slate-900/80"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="absolute inset-x-0 top-2 h-px bg-gradient-to-r from-transparent via-blue-200/40 to-transparent blur-sm dark:via-blue-900/30"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
@@ -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 {
|
:root {
|
||||||
--motion-duration-short: 180ms;
|
--motion-duration-short: 180ms;
|
||||||
--motion-duration-medium: 260ms;
|
--motion-duration-medium: 260ms;
|
||||||
|
|||||||
Reference in New Issue
Block a user