Files
blog-nextjs/components/matrix-rain.tsx
gbanyan 1b495d2d2d Remove next-view-transitions and use native View Transition API
- Remove external next-view-transitions dependency
- Use Next.js 16 native navigation and Safari 18+ native View Transition API
- Add ViewTransitionProvider for minimal wrapping with Safari 18+ detection
- Updated all Link imports from external package to next/link
- Removed link-wrapper.tsx and view-transitions-wrapper.tsx

This resolves Safari compatibility issues while maintaining transitions on modern browsers.
2026-03-14 23:00:21 +08:00

148 lines
4.3 KiB
TypeScript

'use client';
import { useEffect, useRef } from 'react';
// Matrix-style characters: katakana, numbers, Latin
const CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
interface MatrixRainProps {
/** Opacity 0-1 for fade out control */
opacity?: number;
className?: string;
}
interface Drop {
x: number;
y: number;
speed: number;
chars: string[];
charIndex: number;
}
export function MatrixRain({
opacity = 1,
className = '',
}: MatrixRainProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const resize = () => {
const rect = canvas.getBoundingClientRect();
// Calculate DPR safely - use 1 as fallback
const dpr = (typeof window !== 'undefined' && 'devicePixelRatio' in window)
? Math.min(window.devicePixelRatio ?? 1, 2)
: 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
};
const handleResize = () => {
// Use requestAnimationFrame for smoother resizing
requestAnimationFrame(() => {
const rect = canvasRef.current?.getBoundingClientRect();
if (rect) {
const dpr = (typeof window !== 'undefined' && 'devicePixelRatio' in window)
? Math.min(window.devicePixelRatio ?? 1, 2)
: 1;
canvasRef.current!.width = rect.width * dpr;
canvasRef.current!.height = rect.height * dpr;
if (ctx) {
ctx.scale(dpr, dpr);
}
canvasRef.current!.style.width = `${rect.width}px`;
canvasRef.current!.style.height = `${rect.height}px`;
}
});
};
resize();
window.addEventListener('resize', handleResize, { passive: true, signal: AbortSignal.timeout(60000) });
const fontSize = 14;
const columns = Math.floor(canvas.getBoundingClientRect().width / fontSize);
const drops: Drop[] = Array.from({ length: columns }, (_, i) => ({
x: i * fontSize,
y: Math.random() * -100,
speed: 0.15 + Math.random() * 0.4,
chars: Array.from({ length: 20 }, () =>
CHARS[Math.floor(Math.random() * CHARS.length)]
),
charIndex: Math.floor(Math.random() * 20),
}));
let animationId: number;
let lastTime: number | null = null;
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);
ctx.font = `${fontSize}px "JetBrains Mono", "SF Mono", "Fira Code", monospace`;
drops.forEach((drop) => {
// Bright green for leading char
ctx.fillStyle = 'rgba(34, 197, 94, 1)';
ctx.fillText(drop.chars[drop.charIndex], drop.x, drop.y);
// Dimmer trailing chars
for (let i = 1; i < 8; i++) {
const idx = (drop.charIndex - i + 20) % 20;
const alpha = 1 - i * 0.12;
ctx.fillStyle = `rgba(34, 197, 94, ${alpha * 0.4})`;
ctx.fillText(
drop.chars[idx],
drop.x,
drop.y - i * 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;
} else {
drop.charIndex = (drop.charIndex + 1) % 20;
}
});
animationId = requestAnimationFrame(draw);
};
animationId = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', handleResize);
};
}, []);
return (
<canvas
ref={canvasRef}
className={className}
style={{
opacity,
transition: 'opacity 0.6s ease-out',
background: 'rgb(15, 23, 42)',
}}
aria-hidden="true"
role="img"
/>
);
}