# Halftone Wave

> A halftone screen in motion: dots swell and shrink with slow, crossing waves like a printed gradient breathing, and grow around the cursor.

Category: Backgrounds · Free · Updated 2026-10-26 · Live demo: https://www.motionkitui.com/components/halftone-wave

## When to use it

Editorial, print, fashion and design studio heroes, and textured section backgrounds that need movement without colour.

## How it works

A canvas draws a grid of dots each frame. Every dot's size comes from two slow sine waves crossing at different angles, which is what makes the pattern look like a printed gradient breathing. The level is lowered towards the middle so text sits on smaller dots, and near the pointer a Gaussian swell grows them. Dots below half a pixel are skipped, and the canvas pauses off screen.

## Installation

```bash
npx shadcn@latest add https://www.motionkitui.com/r/halftone-wave.json
```

Or copy `components/motionkit/halftone-wave.tsx` below. Requires react and Tailwind CSS v4. Free for personal projects; commercial use needs MotionKit Plus (https://www.motionkitui.com/pricing).

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `color` | `string` | `"#5FAEB4"` | Dot colour. |
| `spacing` | `number` | `14` | Distance between dots, in pixels. |
| `speed` | `number` | `1` | Wave speed. 0 holds it still. |
| `interactive` | `boolean` | `true` | Swell the dots around the pointer. |
| `children` | `ReactNode` |  | Your content, layered above. |

## Accessibility

- **Keyboard: Not applicable.** Decorative, nothing to focus.
- **Screen readers: Pass.** The canvas is hidden from assistive tech; only your content is read.
- **Reduced motion: Pass.** With reduced motion on, it draws one still frame. It also stops drawing off screen.
- **Colour contrast: Pass.** Dots shrink towards the middle, so ivory text there measures 13.4:1 or better.

## Source

```tsx
"use client";

import { useEffect, useRef, type ReactNode } from "react";

type HalftoneWaveProps = {
  children?: ReactNode;
  /** Colour of the dots. */
  color?: string;
  /** Distance between dot centres, in pixels. */
  spacing?: number;
  /** Wave speed. 0 holds it still. */
  speed?: number;
  /** Swell the dots around the pointer. */
  interactive?: boolean;
  className?: string;
};

/**
 * A halftone screen in motion: a grid of dots whose size follows slow, crossing waves, like a printed
 * gradient that breathes, with dots that swell around the pointer.
 */
export function HalftoneWave({ children, color = "#5FAEB4", spacing = 14, speed = 1, interactive = true, className = "" }: HalftoneWaveProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const context = canvas?.getContext("2d");
    const area = canvas?.parentElement;
    if (!canvas || !context || !area) return;
    const still = speed === 0 || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let width = 0;
    let height = 0;
    let frame = 0;
    let visible = true;
    let elapsed = 0;
    let last = 0;
    const pointer = { x: 0, y: 0, strength: 0, target: 0 };

    function resize() {
      const rect = canvas!.getBoundingClientRect();
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas!.width = width * ratio;
      canvas!.height = height * ratio;
      context!.setTransform(ratio, 0, 0, ratio, 0, 0);
    }

    function draw(time: number) {
      elapsed += last ? Math.min(0.05, (time - last) / 1000) * speed : 0;
      last = time;
      pointer.strength += (pointer.target - pointer.strength) * 0.08;
      const t = elapsed * 0.6;
      const max = spacing * 0.48;
      context!.clearRect(0, 0, width, height);
      context!.fillStyle = color;
      for (let y = spacing / 2; y < height; y += spacing) {
        for (let x = spacing / 2; x < width; x += spacing) {
          const u = x / width;
          const v = y / height;
          // two waves crossing at different angles, kept fainter in the middle where text sits
          let level = 0.5 + 0.25 * Math.sin(u * 6 + v * 2 + t) + 0.25 * Math.sin(v * 7 - u * 3 - t * 0.8);
          const cx = u - 0.5;
          const cy = v - 0.5;
          level *= 0.25 + 0.75 * Math.min(1, (cx * cx + cy * cy) * 5);
          if (pointer.strength > 0.01) {
            const dx = x - pointer.x;
            const dy = y - pointer.y;
            level += Math.exp(-(dx * dx + dy * dy) / (2 * 90 * 90)) * pointer.strength * 0.8;
          }
          const radius = Math.min(1, level) * max;
          if (radius < 0.4) continue;
          context!.globalAlpha = 0.25 + Math.min(1, level) * 0.45;
          context!.beginPath();
          context!.arc(x, y, radius, 0, Math.PI * 2);
          context!.fill();
        }
      }
      context!.globalAlpha = 1;
    }

    function loop(time: number) {
      draw(time);
      frame = visible ? requestAnimationFrame(loop) : 0;
      if (!frame) last = 0;
    }

    resize();
    draw(0);
    if (!still) frame = requestAnimationFrame(loop);
    const resizeObserver = new ResizeObserver(() => {
      resize();
      draw(performance.now());
    });
    resizeObserver.observe(canvas);
    const visibilityObserver = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible && !frame && !still) frame = requestAnimationFrame(loop);
    });
    visibilityObserver.observe(canvas);

    function move(event: PointerEvent) {
      if (!interactive || still || event.pointerType === "touch") return;
      const rect = canvas!.getBoundingClientRect();
      pointer.x = event.clientX - rect.left;
      pointer.y = event.clientY - rect.top;
      pointer.target = 1;
    }
    const leave = () => (pointer.target = 0);
    area.addEventListener("pointermove", move);
    area.addEventListener("pointerleave", leave);

    return () => {
      cancelAnimationFrame(frame);
      resizeObserver.disconnect();
      visibilityObserver.disconnect();
      area.removeEventListener("pointermove", move);
      area.removeEventListener("pointerleave", leave);
    };
  }, [color, spacing, speed, interactive]);

  return (
    <div className={`relative isolate overflow-hidden bg-[#0C1010] ${className}`}>
      <canvas ref={canvasRef} aria-hidden className="absolute inset-0 -z-10 size-full" />
      {children}
    </div>
  );
}
```
