Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

Phosphor Sweep Background

Rotating beam sweeps a dot grid, leaving dots to fade with phosphor-persistence decay.

MSource LandMellow UI

Why it stands out

Rotating beam sweeps a dot grid, leaving dots to fade with phosphor-persistence decay.

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/phosphor-sweep-demo.tsx

File content: "use client";

import React from "react";
import { PhosphorSweep } from "../mellow/phosphor-sweep";

export default function PhosphorSweepDemo() {
  return (
    <PhosphorSweep className="w-full h-full">
      <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-2 text-center">
        <p className="text-[rgba(var(--ink-rgb),0.55)] text-sm">
          Rotating beam — phosphor persistence decay
        </p>
        <p className="text-[rgba(var(--ink-rgb),0.3)] text-xs">
          Dots glow as the sweep passes, fade slowly
        </p>
      </div>
    </PhosphorSweep>
  );
}



File location: components/mellow/phosphor-sweep.tsx

File content: "use client";

import React, { useEffect, useRef, useCallback, useState } from "react";

function useInkRgb(): string {
  const [rgb, setRgb] = useState<string>("244, 241, 236");
  useEffect(() => {
    const read = () => {
      const v = getComputedStyle(document.documentElement)
        .getPropertyValue("--ink-rgb")
        .trim();
      if (v) setRgb(v);
    };
    read();
    const obs = new MutationObserver(read);
    obs.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    });
    return () => obs.disconnect();
  }, []);
  return rgb;
}

export interface PhosphorSweepProps {
  dotColor?: string;
  dotSize?: number;
  spacing?: number;
  speed?: number;
  decay?: number;
  style?: React.CSSProperties;
  className?: string;
  children?: React.ReactNode;
}

export function PhosphorSweep({
  dotColor,
  dotSize = 1.5,
  spacing = 28,
  speed = 0.12,
  decay = 2.5,
  style,
  className,
  children,
}: PhosphorSweepProps) {
  const inkRgb = useInkRgb();
  const resolvedDotColor = dotColor ?? `rgba(${inkRgb}, 0.07)`;
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const rafRef = useRef<number>(0);
  const angleRef = useRef(0);
  const lastHitsRef = useRef<Map<string, number>>(new Map());
  const prevTimeRef = useRef(0);
  const sizeRef = useRef({ w: 0, h: 0 });

  const draw = useCallback(
    (timestamp: number) => {
      const canvas = canvasRef.current;
      if (!canvas) return;
      const ctx = canvas.getContext("2d");
      if (!ctx) return;

      const now = timestamp / 1000;
      const dt = prevTimeRef.current
        ? Math.min(now - prevTimeRef.current, 0.05)
        : 0.016;
      prevTimeRef.current = now;

      const { w: W, h: H } = sizeRef.current;
      if (!W || !H) {
        rafRef.current = requestAnimationFrame(draw);
        return;
      }

      ctx.clearRect(0, 0, canvas.width, canvas.height);

      const cx = W / 2;
      const cy = H / 2;

      angleRef.current += speed * Math.PI * 2 * dt;
      const beamAngle = angleRef.current;
      const lastHits = lastHitsRef.current;

      const cols = Math.ceil(W / spacing) + 1;
      const rows = Math.ceil(H / spacing) + 1;
      const offX = (W % spacing) / 2;
      const offY = (H % spacing) / 2;

      for (let row = 0; row < rows; row++) {
        for (let col = 0; col < cols; col++) {
          const x = offX + col * spacing;
          const y = offY + row * spacing;
          const key = `${col},${row}`;

          const dotAngle = Math.atan2(y - cy, x - cx);
          let diff =
            ((dotAngle - beamAngle) % (Math.PI * 2) + Math.PI * 2) %
            (Math.PI * 2);
          if (diff > Math.PI) diff -= Math.PI * 2;

          if (Math.abs(diff) < 0.05) {
            lastHits.set(key, now);
          }

          const lastHit = lastHits.get(key);
          let intensity = 0;
          if (lastHit !== undefined) {
            const age = now - lastHit;
            if (age < decay) {
              intensity = Math.exp((-age * 4) / decay);
            } else {
              lastHits.delete(key);
            }
          }

          ctx.beginPath();
          ctx.arc(
            x,
            y,
            dotSize * (1 + intensity * 1.2),
            0,
            Math.PI * 2
          );
          if (intensity > 0.01) {
            ctx.fillStyle = `rgba(${inkRgb},${(0.07 + intensity * 0.83).toFixed(3)})`;
          } else {
            ctx.fillStyle = resolvedDotColor;
          }
          ctx.fill();
        }
      }

      const reach = Math.hypot(W, H);
      const bx = cx + Math.cos(beamAngle) * reach;
      const by = cy + Math.sin(beamAngle) * reach;
      const grad = ctx.createLinearGradient(cx, cy, bx, by);
      grad.addColorStop(0, `rgba(${inkRgb},0.18)`);
      grad.addColorStop(0.35, `rgba(${inkRgb},0.06)`);
      grad.addColorStop(1, `rgba(${inkRgb},0)`);
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.lineTo(bx, by);
      ctx.strokeStyle = grad;
      ctx.lineWidth = 1.5;
      ctx.stroke();

      rafRef.current = requestAnimationFrame(draw);
    },
    [resolvedDotColor, inkRgb, dotSize, spacing, speed, decay]
  );

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    if (reduced) return;

    const ro = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const { width, height } = entry.contentRect;
        const dpr = Math.min(window.devicePixelRatio, 2);
        sizeRef.current = { w: width, h: height };
        canvas.width = width * dpr;
        canvas.height = height * dpr;
        const ctx = canvas.getContext("2d");
        if (ctx) ctx.scale(dpr, dpr);
        canvas.style.width = `${width}px`;
        canvas.style.height = `${height}px`;
        lastHitsRef.current.clear();
      }
    });
    ro.observe(canvas.parentElement!);

    rafRef.current = requestAnimationFrame(draw);
    return () => {
      cancelAnimationFrame(rafRef.current);
      ro.disconnect();
    };
  }, [draw]);

  return (
    <div
      className={["relative overflow-hidden", className].filter(Boolean).join(" ")}
      style={style}
    >
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="absolute inset-0 w-full h-full pointer-events-none"
      />
      {children && (
        <div className="absolute inset-0 z-[1]">{children}</div>
      )}
    </div>
  );
}

export default PhosphorSweep;



Usage notes:
Add a PhosphorSweep background component from the mellow library. A rotating radar beam sweeps across a dot grid — dots glow on hit then fade with exponential phosphor-persistence decay. Entirely autonomous, no cursor interaction needed. Key props: dotSize, spacing, speed (rotations/sec), decay (fade seconds).

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI