([]);
+
+ // Ensure gap is within allowed range (4 to 50).
+ const validatedGap = Math.min(Math.max(gap, 4), 50);
+
+ // Respect "prefers-reduced-motion."
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)")
+ .matches;
+ const throttle = 0.001;
+ const validatedSpeed =
+ speed <= 0 || reducedMotion
+ ? 0
+ : speed >= 100
+ ? 100 * throttle
+ : speed * throttle;
+
+ // Convert a color string to a valid CSS color or get computed color from Tailwind classes.
+ const resolveColor = (color: string): string => {
+ if (
+ color.startsWith("#") ||
+ color.startsWith("rgb") ||
+ color.startsWith("hsl")
+ ) {
+ return color;
+ }
+ const tempEl = document.createElement("div");
+ tempEl.className = color;
+ tempEl.style.display = "none";
+ document.body.appendChild(tempEl);
+ const computed = getComputedStyle(tempEl).backgroundColor;
+ document.body.removeChild(tempEl);
+ return computed;
+ };
+
+ // Resolve any Tailwind-based colors into actual CSS color strings.
+ useEffect(() => {
+ const computed = colors.map((c) => resolveColor(c));
+ setResolvedColors(computed);
+ }, [colors]);
+
+ // Distance from (x, y) to the canvas center.
+ const getDistanceToCanvasCenter = (x: number, y: number): number => {
+ const canvas = canvasRef.current;
+ if (!canvas) return 0;
+ const dx = x - canvas.width / 2;
+ const dy = y - canvas.height / 2;
+ return Math.sqrt(dx * dx + dy * dy);
+ };
+
+ // Create a new array of Pixel instances based on the canvas dimensions.
+ const createPixels = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ const pixelArray: Pixel[] = [];
+ const colorArray = resolvedColors.length > 0 ? resolvedColors : colors;
+ for (let x = 0; x < canvas.width; x += validatedGap) {
+ for (let y = 0; y < canvas.height; y += validatedGap) {
+ const color = colorArray[Math.floor(Math.random() * colorArray.length)];
+ const delay = reducedMotion ? 0 : getDistanceToCanvasCenter(x, y);
+ pixelArray.push(
+ new Pixel(canvas, ctx, x, y, color, validatedSpeed, delay)
+ );
+ }
+ }
+ pixelsRef.current = pixelArray;
+ }, [
+ colors,
+ resolvedColors,
+ validatedGap,
+ validatedSpeed,
+ reducedMotion,
+ getDistanceToCanvasCenter,
+ ]);
+
+ // Initialize the canvas size and pixel grid.
+ const init = useCallback(() => {
+ const container = containerRef.current;
+ const canvas = canvasRef.current;
+ if (!container || !canvas) return;
+ const rect = container.getBoundingClientRect();
+ const width = Math.floor(rect.width);
+ const height = Math.floor(rect.height);
+ canvas.width = width;
+ canvas.height = height;
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+ createPixels();
+ }, [createPixels]);
+
+ // The animation loop – calls either 'appear' or 'disappear' on each pixel.
+ const animate = useCallback(
+ (fnName: "appear" | "disappear") => {
+ animationFrameRef.current = requestAnimationFrame(() =>
+ animate(fnName)
+ );
+
+ const now = performance.now();
+ const timePassed = now - timePreviousRef.current;
+ if (timePassed < timeIntervalRef.current) return;
+ timePreviousRef.current = now - (timePassed % timeIntervalRef.current);
+
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ pixelsRef.current.forEach((pixel) => {
+ fnName === "appear" ? pixel.appear() : pixel.disappear();
+ });
+
+ // Only stop if not active and all pixels are idle.
+ if (!active && pixelsRef.current.every((p) => p.isIdle)) {
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current);
+ }
+ }
+ },
+ [active]
+ );
+
+ // Trigger the animation loop with a given mode.
+ const handleAnimation = useCallback(
+ (mode: "appear" | "disappear") => {
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current);
+ }
+ animate(mode);
+ },
+ [animate]
+ );
+
+ // Set up the canvas and resize observer on mount.
+ useEffect(() => {
+ init();
+ if (containerRef.current) {
+ resizeObserverRef.current = new ResizeObserver(() => {
+ init();
+ });
+ resizeObserverRef.current.observe(containerRef.current);
+ }
+
+ // Start animation if active is true on mount
+ if (active) {
+ handleAnimation('appear');
+ }
+
+ return () => {
+ if (resizeObserverRef.current && containerRef.current) {
+ resizeObserverRef.current.unobserve(containerRef.current);
+ resizeObserverRef.current.disconnect();
+ }
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current);
+ }
+ };
+ }, [init, active, handleAnimation]);
+
+ // Whenever "active" becomes true, we want the same fresh appearance as a hover.
+ // So we re-init the pixels and trigger the "appear" animation.
+ useEffect(() => {
+ if (active) {
+ init(); // Re-create the pixel grid so we start from scratch.
+ handleAnimation("appear");
+ }
+ }, [active, init, handleAnimation]);
+
+ // Default container styling (can be overridden by the "style" prop).
+ const containerStyle: CSSProperties = {
+ display: "grid",
+ width: "100%",
+ height: "100%",
+ overflow: "hidden",
+ ...style,
+ };
+
+ return (
+ {
+ if (!active) handleAnimation("appear");
+ }}
+ onMouseLeave={() => {
+ if (!active) handleAnimation("disappear");
+ }}
+ onFocus={() => {
+ if (!noFocus && !active) handleAnimation("appear");
+ }}
+ onBlur={() => {
+ if (!noFocus && !active) handleAnimation("disappear");
+ }}
+ tabIndex={0} // Make the container focusable.
+ >
+
+
+ );
+};
+
+export default PixelCanvas;