feat: add interactive stacked cube components with hover descriptions

This commit is contained in:
2025-10-22 17:33:29 +02:00
parent fa0a55d846
commit 6c0ed3cd65
8 changed files with 883 additions and 0 deletions

131
src/components/ui/Cube.tsx Normal file
View File

@@ -0,0 +1,131 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
interface CubeProps {
title: string;
descriptionTitle: string;
description: string;
isActive: boolean;
index: number;
onHover: () => void;
onLeave: () => void;
onClick: () => void;
}
const CubeSvg: React.FC<React.SVGProps<SVGSVGElement> & { index: number }> = ({ index, ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="507"
height="234"
fill="none"
viewBox="0 0 507 234"
{...props}
>
<path
fill={`url(#cube-gradient-${index})`}
d="M491.651 144.747L287.198 227.339C265.219 236.22 241.783 236.22 219.802 227.339L15.3486 144.747C-5.11621 136.479 -5.11621 97.5191 15.3486 89.2539L219.802 6.65884C241.783 -2.21961 265.219 -2.21961 287.198 6.65884L491.651 89.2539C512.116 97.5191 512.116 136.479 491.651 144.747Z"
/>
<defs>
<linearGradient
id={`cube-gradient-${index}`}
x1="185.298"
x2="185.298"
y1="-27.5515"
y2="206.448"
gradientUnits="userSpaceOnUse"
>
<stop />
<stop offset="1" stopColor="#3F3B3E" />
</linearGradient>
</defs>
</svg>
);
export function Cube({ title, descriptionTitle, description, isActive, index, onHover, onLeave, onClick }: CubeProps) {
return (
<div className="relative flex flex-col items-center">
<motion.div
className="relative cursor-pointer"
onMouseEnter={onHover}
onMouseLeave={onLeave}
onClick={onClick}
style={{
zIndex: 10 - index,
}}
animate={{
scale: isActive ? 1.05 : 1,
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
>
{/* SVG Cube */}
<CubeSvg
index={index}
className="w-48 sm:w-64 lg:w-80 h-auto drop-shadow-lg opacity-50"
style={{
filter: isActive ? 'brightness(1.2) drop-shadow(0 0 20px rgba(156, 163, 175, 0.5))' : 'brightness(0.9)',
}}
/>
{/* Title overlay */}
<div className="absolute inset-0 flex items-center justify-center">
<h3
className="text-white text-sm lg:text-base font-medium text-center px-4 drop-shadow-lg"
style={{
transform: 'rotate(0deg) skewX(0deg)',
transformOrigin: 'center'
}}
>
{title}
</h3>
</div>
{/* Description with arrow line - Desktop */}
{isActive && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="hidden lg:block absolute left-full top-1/2 -translate-y-1/2 z-50"
>
{/* Arrow line */}
<svg
className="absolute left-0 top-1/2 -translate-y-1/2"
width="120"
height="2"
viewBox="0 0 120 2"
fill="none"
>
<line
x1="0"
y1="1"
x2="120"
y2="1"
stroke="white"
strokeWidth="1"
opacity="0.6"
/>
</svg>
{/* Description text */}
<div className="ml-32 w-80">
<h4 className="text-white text-base font-semibold mb-2">
{descriptionTitle}
</h4>
<p className="text-white text-sm leading-relaxed font-light">
{description}
</p>
</div>
</motion.div>
)}
{/* Description for Mobile - Below cube */}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
interface CubeProps {
title: string;
descriptionTitle: string;
description: string;
isActive: boolean;
index: number;
onHover: () => void;
onLeave: () => void;
onClick: () => void;
}
const CubeSvg: React.FC<React.SVGProps<SVGSVGElement> & { index: number }> = ({ index, ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="507"
height="234"
fill="none"
viewBox="0 0 507 234"
{...props}
>
<path
fill={`url(#cube-gradient-${index})`}
d="M491.651 144.747L287.198 227.339C265.219 236.22 241.783 236.22 219.802 227.339L15.3486 144.747C-5.11621 136.479 -5.11621 97.5191 15.3486 89.2539L219.802 6.65884C241.783 -2.21961 265.219 -2.21961 287.198 6.65884L491.651 89.2539C512.116 97.5191 512.116 136.479 491.651 144.747Z"
/>
<defs>
<linearGradient
id={`cube-gradient-${index}`}
x1="185.298"
x2="185.298"
y1="-27.5515"
y2="206.448"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#E5E7EB" />
<stop offset="1" stopColor="#9CA3AF" />
</linearGradient>
</defs>
</svg>
);
export function CubeLight({ title, descriptionTitle, description, isActive, index, onHover, onLeave, onClick }: CubeProps) {
return (
<div className="relative flex flex-col items-center">
<motion.div
className="relative cursor-pointer"
onMouseEnter={onHover}
onMouseLeave={onLeave}
onClick={onClick}
style={{
zIndex: 10 - index,
}}
animate={{
scale: isActive ? 1.05 : 1,
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
>
{/* SVG Cube */}
<CubeSvg
index={index}
className="w-48 sm:w-64 lg:w-80 h-auto drop-shadow-lg opacity-80"
style={{
filter: isActive ? 'brightness(1.1) drop-shadow(0 0 15px rgba(0, 0, 0, 0.2))' : 'brightness(1)',
}}
/>
{/* Title overlay */}
<div className="absolute inset-0 flex items-center justify-center">
<h3
className="text-black text-sm lg:text-base font-medium text-center px-4 drop-shadow-sm"
style={{
transform: 'rotate(0deg) skewX(0deg)',
transformOrigin: 'center'
}}
>
{title}
</h3>
</div>
{/* Description with arrow line - Desktop */}
{isActive && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="hidden lg:block absolute left-full top-1/2 -translate-y-1/2 z-50"
>
{/* Arrow line */}
<svg
className="absolute left-0 top-1/2 -translate-y-1/2"
width="120"
height="2"
viewBox="0 0 120 2"
fill="none"
>
<line
x1="0"
y1="1"
x2="120"
y2="1"
stroke="black"
strokeWidth="1"
opacity="0.6"
/>
</svg>
{/* Description text */}
<div className="ml-32 w-80">
<h4 className="text-black text-base font-semibold mb-2">
{descriptionTitle}
</h4>
<p className="text-gray-800 text-sm leading-relaxed font-light">
{description}
</p>
</div>
</motion.div>
)}
{/* Description for Mobile - Below cube */}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
'use client'
import { ChevronDoubleDownIcon } from '@heroicons/react/24/outline'
import { useScroll } from '@/hooks/useScroll'
export function ScrollDown() {
const { isAtBottom, scrollToNext } = useScroll()
if (isAtBottom) {
return null
}
return (
<button
onClick={scrollToNext}
className="fixed bottom-8 right-8 z-50 flex items-center gap-x-2 text-2xl font-medium text-white lg:text-3xl animate-blink"
>
<span>scroll</span>
<ChevronDoubleDownIcon className="h-6 w-6" />
</button>
)
}

View File

@@ -0,0 +1,22 @@
'use client'
import { ChevronDoubleUpIcon } from '@heroicons/react/24/outline'
import { useScroll } from '@/hooks/useScroll'
export function ScrollUp() {
const { isAtBottom, scrollToTop } = useScroll()
if (!isAtBottom) {
return null
}
return (
<button
onClick={scrollToTop}
className="fixed bottom-8 right-8 z-50 flex items-center gap-x-2 text-2xl font-medium text-[#1c1c49] lg:text-3xl animate-blink"
>
<span>top</span>
<ChevronDoubleUpIcon className="h-6 w-6" />
</button>
)
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
import { Cube } from "@/components/ui/Cube"
const stackData = [
{
id: "agent",
title: "Agent Layer",
descriptionTitle: "Your sovereign agent with private memory and permissioned data access—always under your control.",
description:
"Choose from a wide library of open-source LLMs, paired with built-in semantic search and retrieval.\nIt coordinates across people, apps, and other agents to plan, create, and execute.\nIt operates inside a compliant legal & financial sandbox, ready for real-world transactions and operations.\nMore than just an assistant—an intelligent partner that learns and does your way.",
position: "top",
},
{
id: "network",
title: "Network Layer",
descriptionTitle: "A global, end-to-end encrypted overlay that simply doesnt break.",
description:
"Shortest-path routing moves your traffic the fastest way, every time.\nInstant discovery with integrated DNS, semantic search, and indexing.\nA distributed CDN and edge delivery keep content available and tamper-resistant worldwide.\nBuilt-in tool services and secure coding sandboxes—seamless on phones, desktops, and edge.",
position: "middle",
},
{
id: "cloud",
title: "Cloud Layer",
descriptionTitle: "An autonomous, stateless OS that enforces pre-deterministic deployments you define.",
description:
"Workloads are cryptographically bound to your private key—location and access are yours.\nNo cloud vendor or middleman in the path: end-to-end ownership and isolation by default.\nGeo-aware placement delivers locality, compliance, and ultra-low latency where it matters.\nEncrypted, erasure-coded storage, decentralized compute and GPU on demand—including LLMs.",
position: "bottom",
},
];
export function StackedCubes() {
const [active, setActive] = useState<string | null>("agent");
const [selectedForMobile, setSelectedForMobile] = useState<string | null>("agent");
const handleCubeClick = (id: string) => {
setSelectedForMobile(prev => (prev === id ? null : id));
};
const selectedMobileLayer = stackData.find(layer => layer.id === selectedForMobile);
return (
<div className="flex flex-col items-center">
<div
className="relative w-full flex items-center justify-center lg:justify-center min-h-[450px] lg:min-h-[400px]"
onMouseLeave={() => setActive("agent")}
>
<motion.div
className="relative lg:pl-0 pl-6 h-[300px] lg:h-[400px] w-64 sm:w-80 lg:w-96 scale-120 lg:scale-100"
animate={{ y: ["-8px", "8px"] }}
transition={{
duration: 4,
repeat: Infinity,
repeatType: "reverse",
ease: "easeInOut",
}}
>
{stackData.map((layer, index) => (
<div
key={layer.id}
className="absolute"
style={{
top: `calc(${index * 30}% - ${index * 10}px)`,
zIndex: active === layer.id ? 20 : 10 - index,
}}
>
<Cube
title={layer.title}
descriptionTitle={layer.descriptionTitle}
description={layer.description}
isActive={active === layer.id}
index={index}
onHover={() => setActive(layer.id)}
onLeave={() => {}}
onClick={() => handleCubeClick(layer.id)}
/>
</div>
))}
</motion.div>
</div>
{selectedMobileLayer && (
<div className="lg:hidden w-full max-w-md p-6 -mt-8 bg-gray-800/50 rounded-lg">
<h4 className="text-white text-lg font-semibold mb-2 text-center">
{selectedMobileLayer.descriptionTitle}
</h4>
<p className="text-gray-300 text-sm leading-relaxed text-center">
{selectedMobileLayer.description}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
import { CubeLight } from "@/components/ui/CubeLight"
const stackData = [
{
id: "agent",
title: "Agent Layer",
descriptionTitle: "Your sovereign agent with private memory and permissioned data access—always under your control.",
description:
"Choose from a wide library of open-source LLMs, paired with built-in semantic search and retrieval.\nIt coordinates across people, apps, and other agents to plan, create, and execute.\nIt operates inside a compliant legal & financial sandbox, ready for real-world transactions and operations.\nMore than just an assistant—an intelligent partner that learns and does your way.",
position: "top",
},
{
id: "network",
title: "Network Layer",
descriptionTitle: "A global, end-to-end encrypted overlay that simply doesnt break.",
description:
"Shortest-path routing moves your traffic the fastest way, every time.\nInstant discovery with integrated DNS, semantic search, and indexing.\nA distributed CDN and edge delivery keep content available and tamper-resistant worldwide.\nBuilt-in tool services and secure coding sandboxes—seamless on phones, desktops, and edge.",
position: "middle",
},
{
id: "cloud",
title: "Cloud Layer",
descriptionTitle: "An autonomous, stateless OS that enforces pre-deterministic deployments you define.",
description:
"Workloads are cryptographically bound to your private key—location and access are yours.\nNo cloud vendor or middleman in the path: end-to-end ownership and isolation by default.\nGeo-aware placement delivers locality, compliance, and ultra-low latency where it matters.\nEncrypted, erasure-coded storage, decentralized compute and GPU on demand—including LLMs.",
position: "bottom",
},
];
export function StackedCubesLight() {
const [active, setActive] = useState<string | null>("agent");
const [selectedForMobile, setSelectedForMobile] = useState<string | null>("agent");
const handleCubeClick = (id: string) => {
setSelectedForMobile(prev => (prev === id ? null : id));
};
const selectedMobileLayer = stackData.find(layer => layer.id === selectedForMobile);
return (
<div className="flex flex-col items-center">
<div
className="relative w-full flex items-center justify-center lg:justify-center min-h-[450px] lg:min-h-[400px]"
onMouseLeave={() => setActive("agent")}
>
<motion.div
className="relative lg:pl-0 pl-6 h-[300px] lg:h-[400px] w-64 sm:w-80 lg:w-96 scale-120 lg:scale-100"
animate={{ y: ["-8px", "8px"] }}
transition={{
duration: 4,
repeat: Infinity,
repeatType: "reverse",
ease: "easeInOut",
}}
>
{stackData.map((layer, index) => (
<div
key={layer.id}
className="absolute"
style={{
top: `calc(${index * 30}% - ${index * 10}px)`,
zIndex: active === layer.id ? 20 : 10 - index,
}}
>
<CubeLight
title={layer.title}
descriptionTitle={layer.descriptionTitle}
description={layer.description}
isActive={active === layer.id}
index={index}
onHover={() => setActive(layer.id)}
onLeave={() => {}}
onClick={() => handleCubeClick(layer.id)}
/>
</div>
))}
</motion.div>
</div>
{selectedMobileLayer && (
<div className="lg:hidden w-full max-w-md p-6 -mt-8 bg-gray-200/50 rounded-lg">
<h4 className="text-black text-lg font-semibold mb-2 text-center">
{selectedMobileLayer.descriptionTitle}
</h4>
<p className="text-gray-700 text-sm leading-relaxed text-center">
{selectedMobileLayer.description}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { cn } from "@/lib/utils";
import { CT, CP } from "@/components/Texts";
import Image from 'next/image';
import React from 'react';
import { motion } from 'framer-motion';
export const BentoGrid = ({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) => {
return (
<div
className={cn(
"mx-4 grid max-w-6xl grid-cols-1 gap-4 lg:grid-cols-3",
className,
)}
>
{children}
</div>
);
};
interface BentoGridItemProps {
className?: string;
title?: string | React.ReactNode;
subtitle?: string | React.ReactNode;
description?: string | React.ReactNode;
img?: string;
video?: string;
rowHeight?: string;
}
export const BentoGridItem = React.forwardRef<HTMLDivElement, BentoGridItemProps>(
({ className, title, subtitle, description, img, video, rowHeight }, ref) => {
return (
<div
ref={ref}
className={cn(
"group/bento shadow-input row-span-1 flex flex-col justify-between rounded-xl border border-black bg-black/10 backdrop-blur-md transition-all duration-300 ease-in-out hover:scale-105 hover:border-black hover:bg-black/40",
rowHeight ? rowHeight : "h-full",
className
)}
>
<div className="relative w-full h-[65%] min-h-[6rem] bg-transparent overflow-hidden">
{video ? (
<video
src={video}
autoPlay
loop
muted
playsInline
className="w-full h-full object-cover opacity-90 group-hover/bento:opacity-100 transition-opacity duration-300"
/>
) : img ? (
<Image
src={img}
alt={title as string}
width={300}
height={300}
className="w-full h-full object-cover opacity-90 group-hover/bento:opacity-100 transition-opacity duration-300"
/>
) : null}
</div>
<div className="p-4 transition bg-white/5 hover:bg-white/7 backdrop-blur-md duration-200 group-hover/bento:translate-x-2 ">
<CT>{title}</CT>
<CP className="font-medium">{subtitle}</CP>
<CP className="mt-2">{description}</CP>
</div>
</div>
);
}
);
BentoGridItem.displayName = "BentoGridItem";
export const MotionBentoGridItem = motion(BentoGridItem);

View File

@@ -0,0 +1,308 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
type DottedGlowBackgroundProps = {
className?: string;
/** distance between dot centers in pixels */
gap?: number;
/** base radius of each dot in CSS px */
radius?: number;
/** dot color (will pulse by alpha) */
color?: string;
/** optional dot color for dark mode */
darkColor?: string;
/** shadow/glow color for bright dots */
glowColor?: string;
/** optional glow color for dark mode */
darkGlowColor?: string;
/** optional CSS variable name for light dot color (e.g. --color-zinc-900) */
colorLightVar?: string;
/** optional CSS variable name for dark dot color (e.g. --color-zinc-100) */
colorDarkVar?: string;
/** optional CSS variable name for light glow color */
glowColorLightVar?: string;
/** optional CSS variable name for dark glow color */
glowColorDarkVar?: string;
/** global opacity for the whole layer */
opacity?: number;
/** background radial fade opacity (0 = transparent background) */
backgroundOpacity?: number;
/** minimum per-dot speed in rad/s */
speedMin?: number;
/** maximum per-dot speed in rad/s */
speedMax?: number;
/** global speed multiplier for all dots */
speedScale?: number;
};
/**
* Canvas-based dotted background that randomly glows and dims.
* - Uses a stable grid of dots.
* - Each dot gets its own phase + speed producing organic shimmering.
* - Handles high-DPI and resizes via ResizeObserver.
*/
export function DottedGlowBackground({
className,
gap = 12,
radius = 2,
color = "rgba(0,0,0,0.7)",
darkColor,
glowColor = "rgba(0, 170, 255, 0.85)",
darkGlowColor,
colorLightVar,
colorDarkVar,
glowColorLightVar,
glowColorDarkVar,
opacity = 0.6,
backgroundOpacity = 0,
speedMin = 0.4,
speedMax = 1.3,
speedScale = 1,
}: DottedGlowBackgroundProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [resolvedColor, setResolvedColor] = useState<string>(color);
const [resolvedGlowColor, setResolvedGlowColor] = useState<string>(glowColor);
// Resolve CSS variable value from the container or root
const resolveCssVariable = (
el: Element,
variableName?: string,
): string | null => {
if (!variableName) return null;
const normalized = variableName.startsWith("--")
? variableName
: `--${variableName}`;
const fromEl = getComputedStyle(el as Element)
.getPropertyValue(normalized)
.trim();
if (fromEl) return fromEl;
const root = document.documentElement;
const fromRoot = getComputedStyle(root).getPropertyValue(normalized).trim();
return fromRoot || null;
};
const detectDarkMode = (): boolean => {
const root = document.documentElement;
if (root.classList.contains("dark")) return true;
if (root.classList.contains("light")) return false;
return (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
};
// Keep resolved colors in sync with theme changes and prop updates
useEffect(() => {
const container = containerRef.current ?? document.documentElement;
const compute = () => {
const isDark = detectDarkMode();
let nextColor: string = color;
let nextGlow: string = glowColor;
if (isDark) {
const varDot = resolveCssVariable(container, colorDarkVar);
const varGlow = resolveCssVariable(container, glowColorDarkVar);
nextColor = varDot || darkColor || nextColor;
nextGlow = varGlow || darkGlowColor || nextGlow;
} else {
const varDot = resolveCssVariable(container, colorLightVar);
const varGlow = resolveCssVariable(container, glowColorLightVar);
nextColor = varDot || nextColor;
nextGlow = varGlow || nextGlow;
}
setResolvedColor(nextColor);
setResolvedGlowColor(nextGlow);
};
compute();
const mql = window.matchMedia
? window.matchMedia("(prefers-color-scheme: dark)")
: null;
const handleMql = () => compute();
mql?.addEventListener?.("change", handleMql);
const mo = new MutationObserver(() => compute());
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style"],
});
return () => {
mql?.removeEventListener?.("change", handleMql);
mo.disconnect();
};
}, [
color,
darkColor,
glowColor,
darkGlowColor,
colorLightVar,
colorDarkVar,
glowColorLightVar,
glowColorDarkVar,
]);
useEffect(() => {
const el = canvasRef.current;
const container = containerRef.current;
if (!el || !container) return;
const ctx = el.getContext("2d");
if (!ctx) return;
let raf = 0;
let stopped = false;
const dpr = Math.max(1, window.devicePixelRatio || 1);
const resize = () => {
const { width, height } = container.getBoundingClientRect();
el.width = Math.max(1, Math.floor(width * dpr));
el.height = Math.max(1, Math.floor(height * dpr));
el.style.width = `${Math.floor(width)}px`;
el.style.height = `${Math.floor(height)}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
// Precompute dot metadata for a medium-sized grid and regenerate on resize
let dots: { x: number; y: number; phase: number; speed: number }[] = [];
const regenDots = () => {
dots = [];
const { width, height } = container.getBoundingClientRect();
const cols = Math.ceil(width / gap) + 2;
const rows = Math.ceil(height / gap) + 2;
const min = Math.min(speedMin, speedMax);
const max = Math.max(speedMin, speedMax);
for (let i = -1; i < cols; i++) {
for (let j = -1; j < rows; j++) {
const x = i * gap + (j % 2 === 0 ? 0 : gap * 0.5); // offset every other row
const y = j * gap;
// Randomize phase and speed slightly per dot
const phase = Math.random() * Math.PI * 2;
const span = Math.max(max - min, 0);
const speed = min + Math.random() * span; // configurable rad/s
dots.push({ x, y, phase, speed });
}
}
};
const regenThrottled = () => {
regenDots();
};
regenDots();
let last = performance.now();
const draw = (now: number) => {
if (stopped) return;
const dt = (now - last) / 1000; // seconds
last = now;
const { width, height } = container.getBoundingClientRect();
ctx.clearRect(0, 0, el.width, el.height);
ctx.globalAlpha = opacity;
// optional subtle background fade for depth (defaults to 0 = transparent)
if (backgroundOpacity > 0) {
const grad = ctx.createRadialGradient(
width * 0.5,
height * 0.4,
Math.min(width, height) * 0.1,
width * 0.5,
height * 0.5,
Math.max(width, height) * 0.7,
);
grad.addColorStop(0, "rgba(0,0,0,0)");
grad.addColorStop(
1,
`rgba(0,0,0,${Math.min(Math.max(backgroundOpacity, 0), 1)})`,
);
ctx.fillStyle = grad as unknown as CanvasGradient;
ctx.fillRect(0, 0, width, height);
}
// animate dots
ctx.save();
ctx.fillStyle = resolvedColor;
const time = (now / 1000) * Math.max(speedScale, 0);
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
// Linear triangle wave 0..1..0 for linear glow/dim
const mod = (time * d.speed + d.phase) % 2;
const lin = mod < 1 ? mod : 2 - mod; // 0..1..0
const a = 0.25 + 0.55 * lin; // 0.25..0.8 linearly
// draw glow when bright
if (a > 0.6) {
const glow = (a - 0.6) / 0.4; // 0..1
ctx.shadowColor = resolvedGlowColor;
ctx.shadowBlur = 6 * glow;
} else {
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
}
ctx.globalAlpha = a * opacity;
ctx.beginPath();
ctx.arc(d.x, d.y, radius, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
raf = requestAnimationFrame(draw);
};
const handleResize = () => {
resize();
regenThrottled();
};
window.addEventListener("resize", handleResize);
raf = requestAnimationFrame(draw);
return () => {
stopped = true;
cancelAnimationFrame(raf);
window.removeEventListener("resize", handleResize);
ro.disconnect();
};
}, [
gap,
radius,
resolvedColor,
resolvedGlowColor,
opacity,
backgroundOpacity,
speedMin,
speedMax,
speedScale,
]);
return (
<div
ref={containerRef}
className={className}
style={{ position: "absolute", inset: 0 }}
>
<canvas
ref={canvasRef}
style={{ display: "block", width: "100%", height: "100%" }}
/>
</div>
);
}
export default DottedGlowBackground;