'use client'; import { useEffect, useRef } from 'react'; /** * HeroAnimation — full-bleed Canvas 2D piece for the marketing hero. * * Four layers (back to front): * 1. Drifting indigo particle dots (texture) * 2. Code-block panel with typewriter cycling 3 snippets (centerpiece) * 3. Mouse-tracking glow ring (interactive, additive) * 4. Data packets shooting from the panel toward the canvas edges * * Hex colors are hardcoded here on purpose — Tailwind v4 beta oklch tokens * don't round-trip cleanly when read from JS, and we want pixel-stable * brand colors inside the canvas regardless of the design-token layer. */ const COLORS = { bg: '#0a0a0b', bgElevated: '#111114', border: '#1f1f22', borderStrong: '#2a2a2e', fg: '#fafafa', fgMuted: '#a1a1aa', fgSubtle: '#71717a', accent: '#6366f1', success: '#22c55e', } as const; const SNIPPETS: ReadonlyArray> = [ [ '// MCP server — auto-generated', 'import { Server } from "@bmm/mcp";', '', 'const server = new Server({', ' name: "data-bridge",', ' tools: [getRecord, search, write],', '});', '', 'await server.listen();', ], [ '> Generating spec... OK', '> Static checks OK', '> Building image OK 17.2s', '> Deploying container OK', '> Live at https://...mcp.bmm.io', ], [ '// claude_desktop_config.json', '{', ' "mcpServers": {', ' "data-bridge": {', ' "url": "https://data.mcp.bmm.io",', ' "auth": "oauth2"', ' }', ' }', '}', ], ]; const KEYWORDS = new Set([ 'import', 'from', 'const', 'await', 'new', 'return', 'function', 'let', 'var', 'if', 'else', 'export', 'default', ]); interface Particle { x: number; y: number; vx: number; vy: number; r: number; seed: number; } interface Packet { x: number; y: number; vx: number; vy: number; life: number; // 0..1, decreases maxLife: number; } interface HeroAnimationProps { className?: string; } export function HeroAnimation({ className }: HeroAnimationProps) { const wrapRef = useRef(null); const canvasRef = useRef(null); useEffect(() => { const wrap = wrapRef.current; const canvas = canvasRef.current; if (!wrap || !canvas) return; const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) return; const reducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; // ----- sizing (DPR-aware) ----- let cssW = 0; let cssH = 0; const setSize = () => { const rect = wrap.getBoundingClientRect(); cssW = Math.max(1, Math.floor(rect.width)); cssH = Math.max(1, Math.floor(rect.height)); const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = cssW * dpr; canvas.height = cssH * dpr; canvas.style.width = `${cssW}px`; canvas.style.height = `${cssH}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); seedParticles(); }; // ----- particles ----- const particles: Particle[] = []; const PARTICLE_COUNT = 60; const seedParticles = () => { particles.length = 0; for (let i = 0; i < PARTICLE_COUNT; i++) { const angle = Math.random() * Math.PI * 2; const speed = (0.15 + Math.random() * 0.25) * (reducedMotion ? 0.4 : 1); particles.push({ x: Math.random() * cssW, y: Math.random() * cssH, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, r: 0.7 + Math.random() * 1.3, seed: Math.random() * 1000, }); } }; // ----- pointer state ----- // Track in CSS pixels (post setTransform), -1,-1 means "not over". const pointer = { raw: { x: -1, y: -1 }, smooth: { x: cssW / 2, y: cssH / 2 }, inside: false, ringAlpha: 0, // eased }; const onPointerMove = (e: PointerEvent) => { const rect = canvas.getBoundingClientRect(); pointer.raw.x = e.clientX - rect.left; pointer.raw.y = e.clientY - rect.top; pointer.inside = true; }; const onPointerLeave = () => { pointer.inside = false; }; canvas.addEventListener('pointermove', onPointerMove, { passive: true }); canvas.addEventListener('pointerleave', onPointerLeave, { passive: true }); canvas.addEventListener('pointercancel', onPointerLeave, { passive: true }); // ----- packets ----- const packets: Packet[] = []; let lastPacketSpawn = 0; // ----- typewriter state ----- // Snippet cycle: // typing -> holding -> erasing -> pausing -> next snippet // Timings calibrated so each snippet runs ~4-5s. const FORWARD_MS = reducedMotion ? 45 : 25; const ERASE_MS = reducedMotion ? 28 : 15; const HOLD_MS = 2500; const PAUSE_MS = 500; let snippetIndex = 0; type Phase = 'typing' | 'holding' | 'erasing' | 'pausing'; let phase: Phase = 'typing'; let phaseStart = 0; // Characters revealed across the FULL snippet text including newlines. const fullText = (i: number): string => (SNIPPETS[i] ?? []).join('\n'); // ----- animation loop ----- let rafId = 0; let startTime = performance.now(); let lastFrame = startTime; let visible = !document.hidden; const onVisibility = () => { visible = !document.hidden; if (visible) { // Reset frame delta so paused time doesn't lurch animation. lastFrame = performance.now(); rafId = requestAnimationFrame(frame); } }; document.addEventListener('visibilitychange', onVisibility); // ----- drawing helpers ----- const drawParticles = (elapsed: number, dt: number) => { ctx.save(); ctx.fillStyle = COLORS.accent; ctx.globalAlpha = 0.2; const driftScale = reducedMotion ? 0.4 : 1; for (const p of particles) { // Cheap pseudo-simplex: layered sines indexed by seed + time. const t = elapsed * 0.0004; const nx = Math.sin(t + p.seed) * Math.cos(t * 0.7 + p.seed * 1.3); const ny = Math.cos(t * 0.9 + p.seed * 0.6) * Math.sin(t * 0.5 + p.seed); p.x += (p.vx + nx * 0.3) * dt * 0.06 * driftScale; p.y += (p.vy + ny * 0.3) * dt * 0.06 * driftScale; // Wrap around edges so density stays uniform. if (p.x < -4) p.x = cssW + 4; else if (p.x > cssW + 4) p.x = -4; if (p.y < -4) p.y = cssH + 4; else if (p.y > cssH + 4) p.y = -4; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); }; // Returns { x, y, w, h } of the code panel in CSS pixels. const panelRect = () => { const w = cssW * 0.8; // Sized to comfortably fit the longest snippet (~9 lines @ ~18px line-height). const h = Math.min(cssH * 0.55, 240); return { x: (cssW - w) / 2, y: (cssH - h) / 2, w, h, }; }; const drawPanel = (rect: { x: number; y: number; w: number; h: number }) => { ctx.save(); ctx.fillStyle = COLORS.bg; ctx.strokeStyle = COLORS.border; ctx.lineWidth = 1; const r = 8; const { x, y, w, h } = rect; ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore(); }; // Tokenize a line for syntax color. Cheap heuristic, not a real parser. type Token = { text: string; color: string }; const tokenizeLine = (line: string, elapsed: number): Token[] => { // Comment lines. if (line.trim().startsWith('//')) { return [{ text: line, color: COLORS.fgSubtle }]; } // Log lines: ">" prefix style, flash "OK" green briefly when it appears. if (line.startsWith('>')) { const tokens: Token[] = []; // Find OK occurrences and color them. const parts = line.split(/(\bOK\b)/g); for (const part of parts) { if (part === 'OK') { // Subtle flash effect: brighter for first ~500ms after fully typed. const flash = 0.65 + 0.35 * (0.5 + 0.5 * Math.sin(elapsed * 0.005)); tokens.push({ text: part, color: withAlpha(COLORS.success, flash), }); } else { tokens.push({ text: part, color: COLORS.fgMuted }); } } return tokens; } // Generic JS/JSON: split on word boundaries, color keywords + strings. const tokens: Token[] = []; // Match: string literals, words, or anything else. const re = /("[^"]*"|\b[A-Za-z_$][\w$]*\b|[^"\w]+)/g; let match: RegExpExecArray | null; while ((match = re.exec(line)) !== null) { const t = match[0]; if (t.startsWith('"')) { tokens.push({ text: t, color: COLORS.fgMuted }); } else if (KEYWORDS.has(t)) { tokens.push({ text: t, color: COLORS.accent }); } else { tokens.push({ text: t, color: COLORS.fg }); } } return tokens; }; const drawCode = ( rect: { x: number; y: number; w: number; h: number }, visibleText: string, elapsed: number, ) => { ctx.save(); // Clip to panel so text never bleeds over the rounded border. ctx.beginPath(); ctx.rect(rect.x, rect.y, rect.w, rect.h); ctx.clip(); const fontSize = 12.5; const lineHeight = 18; const padX = 16; const padY = 14; ctx.font = `${fontSize}px var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace`; ctx.textBaseline = 'top'; const lines = visibleText.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ''; const y = rect.y + padY + i * lineHeight; if (y > rect.y + rect.h - padY) break; let x = rect.x + padX; const tokens = tokenizeLine(line, elapsed); for (const tok of tokens) { ctx.fillStyle = tok.color; ctx.fillText(tok.text, x, y); x += ctx.measureText(tok.text).width; } } // Blinking caret at the end of the visible text. const lastLine = lines[lines.length - 1] ?? ''; const caretX = rect.x + padX + ctx.measureText(lastLine).width; const caretY = rect.y + padY + (lines.length - 1) * lineHeight; const blink = (elapsed % 1000) < 500; if (blink && caretY + lineHeight <= rect.y + rect.h - padY + lineHeight) { ctx.fillStyle = COLORS.accent; ctx.fillRect(caretX + 1, caretY + 2, 6, fontSize); } ctx.restore(); }; const drawRing = (elapsed: number) => { if (pointer.ringAlpha < 0.005) return; ctx.save(); ctx.globalCompositeOperation = 'lighter'; const cx = pointer.smooth.x; const cy = pointer.smooth.y; const baseRadii: ReadonlyArray<{ r: number; w: number; a: number }> = [ { r: 60, w: 2.4, a: 0.4 }, { r: 90, w: 1.6, a: 0.28 }, { r: 130, w: 1.0, a: 0.18 }, ]; for (const band of baseRadii) { ctx.beginPath(); const segments = 96; for (let i = 0; i <= segments; i++) { const theta = (i / segments) * Math.PI * 2; // Breathing distortion around the circumference. const wobble = Math.sin(theta * 3 + elapsed * 0.002) * 4; const r = band.r + wobble; const x = cx + Math.cos(theta) * r; const y = cy + Math.sin(theta) * r; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.strokeStyle = withAlpha( COLORS.accent, band.a * pointer.ringAlpha, ); ctx.lineWidth = band.w; ctx.stroke(); } ctx.restore(); }; const spawnPacket = (rect: { x: number; y: number; w: number; h: number }) => { // Pick an edge of the panel. const edge = Math.floor(Math.random() * 4); let x = 0; let y = 0; let vx = 0; let vy = 0; const speed = 0.06 + Math.random() * 0.04; // CSS px / ms if (edge === 0) { // top x = rect.x + Math.random() * rect.w; y = rect.y; vx = (Math.random() - 0.5) * 0.02; vy = -speed; } else if (edge === 1) { // right x = rect.x + rect.w; y = rect.y + Math.random() * rect.h; vx = speed; vy = (Math.random() - 0.5) * 0.02; } else if (edge === 2) { // bottom x = rect.x + Math.random() * rect.w; y = rect.y + rect.h; vx = (Math.random() - 0.5) * 0.02; vy = speed; } else { // left x = rect.x; y = rect.y + Math.random() * rect.h; vx = -speed; vy = (Math.random() - 0.5) * 0.02; } // Distance to nearest canvas edge along direction → travel time. const distX = vx > 0 ? cssW - x : vx < 0 ? x : Infinity; const distY = vy > 0 ? cssH - y : vy < 0 ? y : Infinity; const tx = vx !== 0 ? distX / Math.abs(vx) : Infinity; const ty = vy !== 0 ? distY / Math.abs(vy) : Infinity; const maxLife = Math.min(tx, ty); packets.push({ x, y, vx, vy, life: 1, maxLife }); }; const drawPackets = (dt: number) => { ctx.save(); for (let i = packets.length - 1; i >= 0; i--) { const p = packets[i]; if (!p) continue; p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt / p.maxLife; if ( p.life <= 0 || p.x < -4 || p.x > cssW + 4 || p.y < -4 || p.y > cssH + 4 ) { packets.splice(i, 1); continue; } const alpha = Math.max(0, p.life) * 0.85; ctx.fillStyle = withAlpha(COLORS.accent, alpha); ctx.beginPath(); ctx.arc(p.x, p.y, 1.6, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); }; // ----- main frame ----- const frame = (now: number) => { if (!visible) return; // visibilitychange will restart us const dt = Math.min(48, now - lastFrame); // cap to avoid lurches lastFrame = now; const elapsed = now - startTime; // Pointer smoothing (EMA). When outside, ease ring alpha to 0 (~400ms). if (pointer.inside && pointer.raw.x >= 0) { pointer.smooth.x = 0.82 * pointer.smooth.x + 0.18 * pointer.raw.x; pointer.smooth.y = 0.82 * pointer.smooth.y + 0.18 * pointer.raw.y; // Ease alpha to 1 over ~250ms. pointer.ringAlpha = Math.min(1, pointer.ringAlpha + dt / 250); } else { pointer.ringAlpha = Math.max(0, pointer.ringAlpha - dt / 400); } // Clear (transparent — wrapper supplies bg color). ctx.clearRect(0, 0, cssW, cssH); // Layer 1: particles drawParticles(elapsed, dt); // Layer 2: panel + typewriter const rect = panelRect(); drawPanel(rect); // Compute visible characters for the current snippet. const text = fullText(snippetIndex); const totalChars = text.length; let visibleChars = 0; const sincePhase = now - phaseStart; if (phase === 'typing') { visibleChars = Math.min(totalChars, Math.floor(sincePhase / FORWARD_MS)); if (visibleChars >= totalChars) { phase = 'holding'; phaseStart = now; } } else if (phase === 'holding') { visibleChars = totalChars; if (sincePhase >= HOLD_MS) { phase = 'erasing'; phaseStart = now; } } else if (phase === 'erasing') { visibleChars = Math.max( 0, totalChars - Math.floor(sincePhase / ERASE_MS), ); if (visibleChars <= 0) { phase = 'pausing'; phaseStart = now; } } else { visibleChars = 0; if (sincePhase >= PAUSE_MS) { snippetIndex = (snippetIndex + 1) % SNIPPETS.length; phase = 'typing'; phaseStart = now; } } drawCode(rect, text.slice(0, visibleChars), elapsed); // Layer 4 (drawn before ring so the glow sits on top): packets. // Skipped under reduced-motion. if (!reducedMotion) { if (now - lastPacketSpawn > 600 && packets.length < 6) { spawnPacket(rect); lastPacketSpawn = now; } drawPackets(dt); } // Layer 3: glow ring (additive, on top) drawRing(elapsed); rafId = requestAnimationFrame(frame); }; // ----- resize observer ----- const ro = new ResizeObserver(() => { setSize(); }); ro.observe(wrap); setSize(); // Kick off. startTime = performance.now(); lastFrame = startTime; phaseStart = startTime; rafId = requestAnimationFrame(frame); return () => { cancelAnimationFrame(rafId); ro.disconnect(); document.removeEventListener('visibilitychange', onVisibility); canvas.removeEventListener('pointermove', onPointerMove); canvas.removeEventListener('pointerleave', onPointerLeave); canvas.removeEventListener('pointercancel', onPointerLeave); }; }, []); return (
); } /** * Append an alpha channel to a 6-digit hex color. * `withAlpha('#6366f1', 0.4)` -> 'rgba(99, 102, 241, 0.4)' */ function withAlpha(hex: string, alpha: number): string { const h = hex.replace('#', ''); const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }