# Phosphor Sweep Background (`phosphor-sweep`)

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

- **Docs:** https://www.mellowui.com/components/phosphor-sweep
- **Markdown:** https://www.mellowui.com/components/phosphor-sweep.md
- **Registry:** https://www.mellowui.com/r/phosphor-sweep.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/phosphor-sweep
- **Categories:** background, canvas, animation
- **Dependencies:** none

## AI prompt

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).

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/phosphor-sweep.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `dotColor` | `string` | `"rgba(var(--ink-rgb),0.07)"` | Base color of unlit dots. |
| `dotSize` | `number` | `1.5` | Radius of each dot in pixels. |
| `spacing` | `number` | `28` | Gap between dots in pixels. |
| `speed` | `number` | `0.12` | Beam rotations per second. |
| `decay` | `number` | `2.5` | Phosphor fade duration in seconds. |
| `className` | `string` | — | Additional CSS classes. |
| `children` | `React.ReactNode` | — | Content rendered above the canvas. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"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;

```

## Demo

```tsx
"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>
  );
}

```
