"use client"; import * as React from "react"; type AuraSource = "top" | "right" | "bottom" | "left" | "around"; type AuraTone = "dark" | "light"; /** How the energy behaves: `sparks` drift off the edge, `rain` falls onto it. */ type AuraMotion = "sparks" | "rain"; interface BitAuraProps { /** The edge the energy sits on, or `around` to let it travel round the element. */ source: AuraSource; /** The background the element stands on: `dark` adds light, `light` only ink digits. */ tone: AuraTone; motion?: AuraMotion; /** Share of the usual amount of digits (1 = default). */ density?: number; /** Corner radius of the lit element in px. */ radius?: number; className?: string; children: React.ReactNode; } interface Box { width: number; height: number; } /** A bright zone on the outline, placed by its position along the perimeter (0..1). */ interface Spot { at: number; power: number; } interface Digit { x: number; y: number; vx: number; vy: number; age: number; life: number; seed: number; power: number; large: boolean; /** Outward normal at the point of the outline the digit belongs to. */ nx: number; ny: number; } interface Palette { blend: GlobalCompositeOperation; glow: string; core: string; digit: string; gain: number; /** Share of the digit sparks that are kept (1 = all). */ density: number; } const MARGIN = 180; const FRAME_MS = 1000 / 30; const MAX_STEP_S = 0.1; const MAX_PIXEL_RATIO = 2; /** `rounded-3xl` in this project's Tailwind theme. */ const DEFAULT_RADIUS = 32; const STILL_FRAME_STEPS = 90; /** The glow is the outline stroked from wide and faint to thin and bright. */ const GLOW_LAYERS = [ { width: 150, alpha: 0.035, hot: false }, { width: 110, alpha: 0.05, hot: false }, { width: 78, alpha: 0.07, hot: false }, { width: 52, alpha: 0.1, hot: false }, { width: 32, alpha: 0.15, hot: false }, { width: 18, alpha: 0.22, hot: false }, { width: 7, alpha: 0.45, hot: true }, { width: 2, alpha: 0.95, hot: true }, ] as const; const LIGHT_PALETTE: Palette = { blend: "source-over", glow: "15, 23, 42", core: "15, 23, 42", digit: "15, 23, 42", // No haze on light backgrounds: coloured light only reads on dark. Ink digits only. gain: 0, density: 0.6, }; const DARK_PALETTE: Palette = { blend: "lighter", glow: "99, 102, 241", core: "224, 231, 255", digit: "199, 210, 254", gain: 1, density: 1, }; const MOTION_DENSITY: Record = { sparks: 0.45, rain: 0.3, }; /** How often a drifting digit switches between 0 and 1. */ const FLIPS_PER_S = 0.9; const fraction = (value: number) => value - Math.floor(value); const perimeter = ({ width, height }: Box) => 2 * (width + height); /** Point and outward normal on the outline, walking clockwise from the top left corner. */ const outlinePoint = (at: number, { width, height }: Box) => { let distance = fraction(at) * 2 * (width + height); if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 }; distance -= width; if (distance < height) return { x: width, y: distance, nx: 1, ny: 0 }; distance -= height; if (distance < width) return { x: width - distance, y: height, nx: 0, ny: 1 }; distance -= width; return { x: 0, y: height - distance, nx: -1, ny: 0 }; }; /** The stretch of the perimeter (0..1) that one edge covers. */ const edgeRange = ( source: Exclude, box: Box, ): readonly [number, number] => { const total = perimeter(box); const top = box.width / total; const right = (box.width + box.height) / total; const bottom = (2 * box.width + box.height) / total; if (source === "top") return [0, top]; if (source === "right") return [top, right]; if (source === "bottom") return [right, bottom]; return [bottom, 1]; }; /** Where the energy is at time `t` (seconds): it wanders along one edge or circles the element. */ const spotsAt = ( source: AuraSource, motion: AuraMotion, t: number, box: Box, ): readonly Spot[] => { if (source === "around") { return [ { at: fraction(t * 0.03), power: 1 }, { at: fraction(t * 0.03 + 0.5), power: 0.7 }, { at: fraction(1.2 - t * 0.018), power: 0.5 }, ]; } const [from, to] = edgeRange(source, box); const along = (share: number) => from + (to - from) * share; return [ { at: along(0.5 + 0.32 * Math.sin(t * 0.5)), power: 0.85 + 0.15 * Math.sin(t * 0.9), }, { at: along(0.5 + 0.4 * Math.sin(t * 0.31 + 2)), power: 0.6 }, { at: along(0.5 + 0.45 * Math.sin(t * 0.83 + 4)), power: 0.35 + 0.2 * Math.sin(t * 1.3), }, ]; }; const spawnDigit = ( spots: readonly Spot[], motion: AuraMotion, box: Box, ): Digit => { const total = spots.reduce((sum, spot) => sum + spot.power, 0); let pick = Math.random() * total; const spot = spots.find((candidate) => (pick -= candidate.power) <= 0) ?? spots[0]; const power = spot?.power ?? 1; const base = { age: 0, seed: Math.random(), power, large: Math.random() < 0.22, }; const wide = motion === "rain" ? 0.12 : 0.07; const scatter = (Math.random() + Math.random() + Math.random() - 1.5) * wide; const point = outlinePoint((spot?.at ?? 0) + scatter, box); if (motion === "rain") { const distance = 40 + Math.random() * 80; const speed = 22 + Math.random() * 26; return { ...base, x: point.x + point.nx * distance, y: point.y + point.ny * distance, vx: -point.nx * speed, vy: -point.ny * speed, nx: point.nx, ny: point.ny, life: distance / speed, }; } const speed = 10 + Math.random() * 24; const slide = (Math.random() - 0.5) * 12; return { ...base, x: point.x, y: point.y, vx: point.nx * speed - point.ny * slide, vy: point.ny * speed + point.nx * slide, nx: point.nx, ny: point.ny, life: 2.4 + Math.random() * 2.6, }; }; /** * BitAura: energy that hugs the outline of the element it wraps. The glow flows * along the edge and binary digits drift off it like sparks. Decorative only: * it runs while in view and stands still when reduced motion is requested. */ export const BitAura: React.FC = ({ source, tone, motion = "sparks", density = 1, radius = DEFAULT_RADIUS, className = "", children, }) => { const wrapperRef = React.useRef(null); const canvasRef = React.useRef(null); React.useEffect(() => { const wrapper = wrapperRef.current; const canvas = canvasRef.current; const context = canvas?.getContext("2d"); if (!wrapper || !canvas || !context) return; const palette = tone === "light" ? LIGHT_PALETTE : DARK_PALETTE; const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const fontFamily = window.getComputedStyle(canvas).fontFamily; let box: Box = { width: 0, height: 0 }; let digits: Digit[] = []; let owed = 0; let clock = Math.random() * 40; let last = 0; let frame = 0; let visible = false; const step = (seconds: number) => { clock += seconds; const spots = spotsAt(source, motion, clock, box); const rate = Math.min(90, Math.max(28, perimeter(box) / 30)) * palette.density * MOTION_DENSITY[motion] * density; owed += rate * seconds; while (owed >= 1) { digits.push(spawnDigit(spots, motion, box)); owed -= 1; } const drag = 1 - 0.6 * seconds; digits = digits.filter((digit) => { digit.age += seconds; if (motion !== "rain") { digit.vx *= drag; digit.vy = digit.vy * drag - 7 * seconds; } digit.x += digit.vx * seconds; digit.y += digit.vy * seconds; return digit.age < digit.life; }); return spots; }; const draw = (spots: readonly Spot[]) => { const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO); context.setTransform(ratio, 0, 0, ratio, MARGIN * ratio, MARGIN * ratio); context.clearRect( -MARGIN, -MARGIN, box.width + 2 * MARGIN, box.height + 2 * MARGIN, ); context.globalCompositeOperation = palette.blend; context.globalAlpha = 1; context.beginPath(); context.roundRect(0, 0, box.width, box.height, radius); const reach = Math.min(560, Math.max(220, perimeter(box) * 0.2)); for (const spot of palette.gain > 0 ? spots : []) { const point = outlinePoint(spot.at, box); for (const layer of GLOW_LAYERS) { const colour = layer.hot ? palette.core : palette.glow; const light = context.createRadialGradient( point.x, point.y, 0, point.x, point.y, reach, ); light.addColorStop( 0, `rgba(${colour}, ${layer.alpha * spot.power * palette.gain})`, ); light.addColorStop(1, `rgba(${colour}, 0)`); context.strokeStyle = light; context.lineWidth = layer.width; context.stroke(); } } context.fillStyle = `rgb(${palette.digit})`; context.textAlign = "center"; context.textBaseline = "middle"; for (const large of [false, true]) { context.font = `${large ? 700 : 500} ${large ? 14 : 11}px ${fontFamily}`; for (const digit of digits) { if (digit.large !== large) continue; const fade = Math.sin((Math.PI * digit.age) / digit.life); context.globalAlpha = Math.min(1, fade * digit.power * 1.1); const one = Math.floor(digit.age * FLIPS_PER_S + digit.seed * 10) % 2 === 0; context.fillText(one ? "1" : "0", digit.x, digit.y); } } }; const resize = () => { const rect = wrapper.getBoundingClientRect(); box = { width: rect.width, height: rect.height }; const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO); canvas.width = Math.round((box.width + 2 * MARGIN) * ratio); canvas.height = Math.round((box.height + 2 * MARGIN) * ratio); if (still) { digits = []; let spots = spotsAt(source, motion, clock, box); for (let i = 0; i < STILL_FRAME_STEPS; i++) spots = step(FRAME_MS / 1000); draw(spots); } }; const tick = (now: number) => { frame = window.requestAnimationFrame(tick); if (now - last < FRAME_MS) return; const seconds = Math.min(MAX_STEP_S, (now - last) / 1000); last = now; draw(step(seconds)); }; const sizeObserver = new ResizeObserver(resize); sizeObserver.observe(wrapper); resize(); const viewObserver = new IntersectionObserver( ([entry]) => { const next = entry?.isIntersecting ?? false; if (next === visible || still) return; visible = next; if (visible) { last = performance.now(); frame = window.requestAnimationFrame(tick); } else { window.cancelAnimationFrame(frame); } }, { rootMargin: `${MARGIN}px` }, ); viewObserver.observe(wrapper); return () => { sizeObserver.disconnect(); viewObserver.disconnect(); window.cancelAnimationFrame(frame); }; }, [source, tone, motion, density, radius]); return (
); };