# Dither Spotlight (`dither-spotlight`)

> A WebGL cursor spotlight rendered as ordered halftone dots instead of a soft glow.

- **Docs:** https://www.mellowui.com/components/dither-spotlight
- **Markdown:** https://www.mellowui.com/components/dither-spotlight.md
- **Registry:** https://www.mellowui.com/r/dither-spotlight.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/dither-spotlight
- **Categories:** background, webgl, interactive, animation
- **Dependencies:** none

## AI prompt

Add a DitherSpotlight background component from the mellow library. It renders a cursor-following WebGL spotlight as ordered halftone dots, using theme-aware ink/background colors. Place hero or card content as children above it. Key props: radius, softness, dotScale, intensity, followSpeed.

## Install

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `radius` | `number` | `220` | Spotlight radius in pixels. |
| `softness` | `number` | `0.25` | How quickly the spotlight falls off near its edge. |
| `dotScale` | `number` | `5` | Halftone dot cell size in pixels. |
| `intensity` | `number` | `0.92` | Maximum strength of the printed spotlight. |
| `followSpeed` | `number` | `0.14` | Cursor smoothing speed; higher values follow faster. |
| `className` | `string` | — | Additional CSS classes. |
| `children` | `React.ReactNode` | — | Content rendered above the shader 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, useState } from "react";

function useCssRgb(name: string, fallback: string): string {
  const [rgb, setRgb] = useState<string>(fallback);

  useEffect(() => {
    const read = () => {
      const value = getComputedStyle(document.documentElement)
        .getPropertyValue(name)
        .trim();
      if (value) setRgb(value);
    };

    read();
    const obs = new MutationObserver(read);
    obs.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    });
    return () => obs.disconnect();
  }, [name]);

  return rgb;
}

function parseRgb(rgb: string): [number, number, number] {
  const parts = rgb
    .split(",")
    .map((part) => Number.parseFloat(part.trim()))
    .filter((part) => Number.isFinite(part));

  return [
    (parts[0] ?? 244) / 255,
    (parts[1] ?? 241) / 255,
    (parts[2] ?? 236) / 255,
  ];
}

function compileShader(
  gl: WebGL2RenderingContext,
  type: number,
  source: string
) {
  const shader = gl.createShader(type);
  if (!shader) return null;

  gl.shaderSource(shader, source);
  gl.compileShader(shader);

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    gl.deleteShader(shader);
    return null;
  }

  return shader;
}

function createProgram(gl: WebGL2RenderingContext) {
  const vertexShader = compileShader(
    gl,
    gl.VERTEX_SHADER,
    `#version 300 es
    precision highp float;

    const vec2 positions[6] = vec2[6](
      vec2(-1.0, -1.0),
      vec2(1.0, -1.0),
      vec2(-1.0, 1.0),
      vec2(-1.0, 1.0),
      vec2(1.0, -1.0),
      vec2(1.0, 1.0)
    );

    void main() {
      gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0);
    }`
  );

  const fragmentShader = compileShader(
    gl,
    gl.FRAGMENT_SHADER,
    `#version 300 es
    precision highp float;

    uniform vec2 u_resolution;
    uniform vec2 u_pointer;
    uniform vec3 u_ink;
    uniform vec3 u_background;
    uniform float u_time;
    uniform float u_radius;
    uniform float u_softness;
    uniform float u_dotScale;
    uniform float u_intensity;
    uniform float u_active;

    out vec4 outColor;

    float bayer4(vec2 p) {
      ivec2 ip = ivec2(mod(p, 4.0));
      int i = ip.x + ip.y * 4;
      float v = 0.0;
      if (i == 0) v = 0.0;
      else if (i == 1) v = 8.0;
      else if (i == 2) v = 2.0;
      else if (i == 3) v = 10.0;
      else if (i == 4) v = 12.0;
      else if (i == 5) v = 4.0;
      else if (i == 6) v = 14.0;
      else if (i == 7) v = 6.0;
      else if (i == 8) v = 3.0;
      else if (i == 9) v = 11.0;
      else if (i == 10) v = 1.0;
      else if (i == 11) v = 9.0;
      else if (i == 12) v = 15.0;
      else if (i == 13) v = 7.0;
      else if (i == 14) v = 13.0;
      else v = 5.0;
      return (v + 0.5) / 16.0;
    }

    float hash(vec2 p) {
      return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
    }

    void main() {
      vec2 p = gl_FragCoord.xy;
      float distToPointer = distance(p, u_pointer);
      float light = (1.0 - smoothstep(u_radius * u_softness, u_radius, distToPointer)) * u_active;

      vec2 cell = floor(p / u_dotScale);
      vec2 local = fract(p / u_dotScale) - 0.5;
      float vignette = 1.0 - smoothstep(0.2, 1.0, distance(p / u_resolution, vec2(0.5)) * 1.35);
      float paper = hash(cell) * 0.08 + sin((cell.x + cell.y) * 0.32 + u_time * 0.45) * 0.025;
      float tone = clamp(light * u_intensity + vignette * 0.08 + paper, 0.0, 1.0);

      float threshold = bayer4(cell);
      float dotRadius = mix(0.08, 0.48, smoothstep(threshold - 0.18, threshold + 0.18, tone));
      float dot = 1.0 - smoothstep(dotRadius, dotRadius + 0.075, length(local));

      vec3 color = mix(u_background, u_ink, dot * (0.16 + light * 0.84));
      outColor = vec4(color, 1.0);
    }`
  );

  if (!vertexShader || !fragmentShader) return null;

  const program = gl.createProgram();
  if (!program) return null;

  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  gl.deleteShader(vertexShader);
  gl.deleteShader(fragmentShader);

  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    gl.deleteProgram(program);
    return null;
  }

  return program;
}

export interface DitherSpotlightProps {
  radius?: number;
  softness?: number;
  dotScale?: number;
  intensity?: number;
  followSpeed?: number;
  style?: React.CSSProperties;
  className?: string;
  children?: React.ReactNode;
}

export function DitherSpotlight({
  radius = 220,
  softness = 0.25,
  dotScale = 5,
  intensity = 0.92,
  followSpeed = 0.14,
  style,
  className,
  children,
}: DitherSpotlightProps) {
  const inkRgb = useCssRgb("--ink-rgb", "244, 241, 236");
  const backgroundRgb = useCssRgb("--background-rgb", "12, 12, 11");
  const wrapperRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const sizeRef = useRef({ w: 0, h: 0, dpr: 1 });
  const pointerRef = useRef({ x: 0, y: 0 });
  const targetRef = useRef({ x: 0, y: 0 });
  const activeRef = useRef(0);
  const hasPointerRef = useRef(false);

  useEffect(() => {
    const wrapper = wrapperRef.current;
    if (!wrapper) return;

    const onPointerMove = (event: PointerEvent) => {
      const rect = wrapper.getBoundingClientRect();
      targetRef.current = {
        x: event.clientX - rect.left,
        y: rect.height - (event.clientY - rect.top),
      };
      hasPointerRef.current = true;
    };

    const onPointerEnter = (event: PointerEvent) => {
      const rect = wrapper.getBoundingClientRect();
      targetRef.current = {
        x: event.clientX - rect.left,
        y: rect.height - (event.clientY - rect.top),
      };
      pointerRef.current = { ...targetRef.current };
      hasPointerRef.current = true;
    };

    const onPointerLeave = () => {
      hasPointerRef.current = false;
    };

    wrapper.addEventListener("pointerenter", onPointerEnter);
    wrapper.addEventListener("pointermove", onPointerMove);
    wrapper.addEventListener("pointerleave", onPointerLeave);
    return () => {
      wrapper.removeEventListener("pointerenter", onPointerEnter);
      wrapper.removeEventListener("pointermove", onPointerMove);
      wrapper.removeEventListener("pointerleave", onPointerLeave);
    };
  }, []);

  useEffect(() => {
    if (!canvasRef.current) return;
    const canvas: HTMLCanvasElement = canvasRef.current;
    const gl = canvas.getContext("webgl2", {
      antialias: false,
      alpha: false,
      powerPreference: "high-performance",
    });
    if (!gl) return;
    const context: WebGL2RenderingContext = gl;

    const program = createProgram(context);
    if (!program) return;

    const uniforms = {
      resolution: context.getUniformLocation(program, "u_resolution"),
      pointer: context.getUniformLocation(program, "u_pointer"),
      ink: context.getUniformLocation(program, "u_ink"),
      background: context.getUniformLocation(program, "u_background"),
      time: context.getUniformLocation(program, "u_time"),
      radius: context.getUniformLocation(program, "u_radius"),
      softness: context.getUniformLocation(program, "u_softness"),
      dotScale: context.getUniformLocation(program, "u_dotScale"),
      intensity: context.getUniformLocation(program, "u_intensity"),
      active: context.getUniformLocation(program, "u_active"),
    };

    const ink = parseRgb(inkRgb);
    const background = parseRgb(backgroundRgb);
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let rafId = 0;
    const start = performance.now();

    function resize() {
      const rect = canvas.getBoundingClientRect();
      const dpr = Math.min(window.devicePixelRatio, 2);
      const width = Math.max(1, Math.floor(rect.width * dpr));
      const height = Math.max(1, Math.floor(rect.height * dpr));

      sizeRef.current = { w: rect.width, h: rect.height, dpr };
      canvas.width = width;
      canvas.height = height;
      context.viewport(0, 0, width, height);
    }

    function render(now: number) {
      const { w, h, dpr } = sizeRef.current;
      pointerRef.current.x += (targetRef.current.x - pointerRef.current.x) * followSpeed;
      pointerRef.current.y += (targetRef.current.y - pointerRef.current.y) * followSpeed;

      const targetActive = hasPointerRef.current ? 1 : 0;
      if (reduced) {
        activeRef.current = targetActive;
      } else {
        activeRef.current += (targetActive - activeRef.current) * followSpeed;
      }

      context.useProgram(program);
      context.uniform2f(uniforms.resolution, canvas.width, canvas.height);
      context.uniform2f(
        uniforms.pointer,
        pointerRef.current.x * dpr,
        pointerRef.current.y * dpr
      );
      context.uniform3f(uniforms.ink, ink[0], ink[1], ink[2]);
      context.uniform3f(uniforms.background, background[0], background[1], background[2]);
      context.uniform1f(uniforms.time, (now - start) / 1000);
      context.uniform1f(uniforms.radius, radius * dpr);
      context.uniform1f(uniforms.softness, Math.min(Math.max(softness, 0.01), 0.95));
      context.uniform1f(uniforms.dotScale, Math.max(2, dotScale * dpr));
      context.uniform1f(uniforms.intensity, intensity);
      context.uniform1f(uniforms.active, activeRef.current);
      context.drawArrays(context.TRIANGLES, 0, 6);

      if (!reduced && w > 0 && h > 0) {
        rafId = requestAnimationFrame(render);
      }
    }

    const ro = new ResizeObserver(resize);
    ro.observe(canvas);
    resize();
    render(start);

    return () => {
      cancelAnimationFrame(rafId);
      ro.disconnect();
      context.deleteProgram(program);
    };
  }, [backgroundRgb, dotScale, followSpeed, inkRgb, intensity, radius, softness]);

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

export default DitherSpotlight;

```

## Demo

```tsx
"use client";

import React, { useState } from "react";
import { DitherSpotlight } from "../mellow/dither-spotlight";

type Scale = "fine" | "coarse";

export default function DitherSpotlightDemo() {
  const [scale, setScale] = useState<Scale>("fine");

  return (
    <div className="flex h-full w-full flex-col">
      <div className="flex justify-start gap-[2px] border-b border-(--rule) px-4 py-3">
        {(["fine", "coarse"] as Scale[]).map((item) => {
          const active = scale === item;
          return (
            <button
              key={item}
              onClick={() => setScale(item)}
              className={[
                "cursor-pointer border px-3 py-[0.3rem] font-mono text-[0.6875rem] uppercase tracking-[0.16em] transition-all duration-150",
                active
                  ? "border-(--ink) bg-(--ink) text-(--background)"
                  : "border-[rgba(var(--ink-rgb),0.1)] bg-transparent text-[rgba(var(--ink-rgb),0.45)]",
              ].join(" ")}
            >
              {item}
            </button>
          );
        })}
      </div>

      <DitherSpotlight
        key={scale}
        radius={scale === "fine" ? 230 : 260}
        dotScale={scale === "fine" ? 4.5 : 8}
        intensity={scale === "fine" ? 0.92 : 1}
        className="min-h-[360px] w-full flex-1"
      >
        <div className="pointer-events-none absolute inset-0 flex items-center justify-center p-8">
          <div className="relative max-w-136 text-center">
            {/* Scrim behind the text instead of per-glyph stroke — subdues the
                dither dots locally so letters stay readable without the
                overlap artifacts a text-stroke gets on tight serif kerning. */}
            <div
              aria-hidden
              className="pointer-events-none absolute -inset-x-10 -inset-y-8 -z-10"
              style={{
                background:
                  "radial-gradient(ellipse at center, rgba(var(--background-rgb),0.94) 0%, rgba(var(--background-rgb),0.7) 55%, transparent 78%)",
              }}
            />
            <p className="mb-5 font-mono text-[0.68rem] uppercase tracking-[0.32em] text-[rgba(var(--ink-rgb),0.62)]">
              Move cursor through the plate
            </p>
            <h3 className="font-serif text-[clamp(3rem,9vw,7.5rem)] leading-[0.82] tracking-[-0.07em] text-(--ink) italic">
              Printed light
            </h3>
            <p className="mx-auto mt-6 max-w-92 text-sm leading-6 text-[rgba(var(--ink-rgb),0.64)]">
              A WebGL spotlight translated into ordered halftone dots, tuned for
              editorial backgrounds instead of glassy glow.
            </p>
          </div>
        </div>
      </DitherSpotlight>
    </div>
  );
}

```
