Skip to content
txbrendan/ui

Dither Motion

beta

Moves a chart's dithered dots: an intro, a loop at rest, and dots that fill in near the pointer. It draws on a canvas under the chart.

Preview

Intro
At rest

Point at the chart to fill in dots near the pointer.

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/dither-motion

Use it for

  • A chart whose dithered areas should move. Put a canvas under its SVG, the same size, and pass both with the areas' paths and colors.
  • Load it with import() on first hover or focus, or once the chart is on screen. The dots at rest are the SVG mask, so nothing waits on it.
  • The same box the chart passes to DitherBoxMask, so the canvas and the mask draw the same dots. A chart with a mask per bar passes each bar's box as boxes, and the same thin.

Avoid it for

  • Dots that never move. DitherBoxMask draws them with no script.
  • Shapes other than filled areas. Lines and labels stay in the SVG, on top.

Props

  • startDitherMotion

    Helper. Sets up moving dots for one chart and returns intro, idle, point, and stop. Options: host, the chart's root; canvas; width and height, the SVG's size in CSS pixels; box; boxes, one per bar, each laying out its own dots; fill; start; thin; and shapes, each { d, paint, top?, bottom?, fade? }, bottom area first. Nothing draws until one of them asks.

    (options: DitherMotionOptions) => DitherMotion

  • intro

    Plays an intro over the motion pulse time. It resolves when the last dot is in, or at once when motion is off.

    (style: "sweep" | "rain" | "ripple" | "pop" | "fall") => Promise<void>

  • idle

    A loop at rest. Wave passes a band of extra dots across, then rests. Twinkle blinks single dots out and on, each on its own clock. Breathe fills the thin edge in deeper and back. Drift moves soft patches of extra dots slowly across. It stops while the chart is off screen.

    (style: "none" | "wave" | "twinkle" | "breathe" | "drift") => void

  • point

    Fills in hidden dots within 56px of the pointer, in the SVG's pixels. Pass null when the pointer leaves, and they fade back.

    (x: number | null, y?: number) => void

  • stop

    Stops every motion, clears the canvas, and hands the dots back to the mask. Call it on unmount.

    () => void

Accessibility

  • Motion off and reduced motion set the pulse to 0, and then nothing moves. The mask keeps drawing the dots at rest.
  • Forced colors and texture flat move nothing either, since the chart draws no dots then.
  • The canvas is aria-hidden. The chart's SVG keeps its name, and the dots carry no meaning of their own.

Examples

Play an intro on a dithered area

import { useEffect, useRef } from "react";

function MovingArea({ d, box }: { d: string; box: DitherBox }) {
  const host = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  useEffect(() => {
    if (!host.current || !canvas.current) return;
    const motion = startDitherMotion({ host: host.current, canvas: canvas.current, width: 320, height: 160, box, shapes: [{ d, paint: "var(--chart-1)" }] });
    void motion.intro("sweep");
    return () => motion.stop();
  }, [d, box]);
  return (
    <Dither>
      <div ref={host} className="relative h-40 w-80">
        <canvas ref={canvas} aria-hidden className="absolute inset-0 size-full" />
        <svg width={320} height={160} className="absolute inset-0" aria-hidden>
          <defs>
            <DitherBoxMask id="area-dots" box={box} />
          </defs>
          <path d={d} fill="var(--chart-1)" mask="url(#area-dots)" />
        </svg>
      </div>
    </Dither>
  );
}