Dither Motion
betaMoves 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
Point at the chart to fill in dots near the pointer.
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. 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
| Prop | Type | Default | What it does |
|---|---|---|---|
| startDitherMotion | (options: DitherMotionOptions) => DitherMotion | - | 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. |
| intro | (style: "sweep" | "rain" | "ripple" | "pop" | "fall") => Promise<void> | - | Plays an intro over the motion pulse time. It resolves when the last dot is in, or at once when motion is off. |
| idle | (style: "none" | "wave" | "twinkle" | "breathe" | "drift") => void | - | 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. |
| point | (x: number | null, y?: number) => void | - | Fills in hidden dots within 56px of the pointer, in the SVG's pixels. Pass null when the pointer leaves, and they fade back. |
| stop | () => void | - | Stops every motion, clears the canvas, and hands the dots back to the mask. Call it on unmount. |
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>
);
}