Tiny 5x5 dot-matrix indicator with 20 state-specific blink patterns.
This is a standalone component that does not depend on the assistant-ui runtime. Use it anywhere in your application.
Installation
npx shadcn@latest add https://r.assistant-ui.com/dot-matrix.jsonMain Component
import type { ComponentProps, CSSProperties } from "react";import { cn } from "@/lib/utils";const GRID = 5;const CENTER = (GRID - 1) / 2;const DOT_INDEXES = Array.from({ length: GRID * GRID }, (_, i) => i);/* Deterministic bit-mixing hash so server and client render identical markup; takes a range in milliseconds and returns seconds. A plain (i * prime) % range correlates indexes a grid-stride apart and renders as column-synchronized waves instead of a twinkle. */const hash = (n: number, salt: number, range: number) => { let h = (Math.imul(n, 374761393) + Math.imul(salt, 668265263)) >>> 0; h = Math.imul(h ^ (h >>> 13), 1274126177) >>> 0; return ((h ^ (h >>> 16)) % range) / 1000;};const glyph = (dots: [number, number][]) => new Set(dots.map(([row, col]) => row * GRID + col));const CHECK = glyph([ [1, 4], [2, 3], [3, 0], [3, 2], [4, 1],]);const CROSS = glyph([ [0, 0], [0, 4], [1, 1], [1, 3], [2, 2], [3, 1], [3, 3], [4, 0], [4, 4],]);const BANG = glyph([ [0, 2], [1, 2], [2, 2], [4, 2],]);const INFO = glyph([ [0, 2], [2, 2], [3, 2], [4, 2],]);const PAUSE = glyph([ [1, 1], [2, 1], [3, 1], [1, 3], [2, 3], [3, 3],]);const STOP = glyph([ [1, 1], [1, 2], [1, 3], [2, 1], [2, 2], [2, 3], [3, 1], [3, 2], [3, 3],]);const RECORD = glyph([ [1, 2], [2, 1], [2, 2], [2, 3], [3, 2],]);const ELLIPSIS = glyph([ [2, 0], [2, 2], [2, 4],]);type Blink = { duration: number; delay: number; lo: number };type StateConfig = { /** Text color class; dots inherit the surrounding color when omitted. */ color?: string; /** Dots that render at full opacity; all others rest at `dim`. Omit for the full grid. */ glyph?: Set<number>; /** Resting opacity of on dots. */ base?: number; /** Resting opacity of off dots when a glyph is set. */ dim?: number; /** Blink parameters per on dot, keyed by index and grid position. */ blink?: (i: number, row: number, col: number) => Blink;};const STATES = { idle: { color: "text-muted-foreground", base: 0.3 }, loading: { blink: (i) => ({ duration: 0.9 + hash(i, 2, 700), delay: -hash(i, 1, 1200), lo: 0.15, }), }, thinking: { blink: (_i, row, col) => ({ duration: 1.2, delay: -(row + col) * 0.09, lo: 0.2, }), }, streaming: { blink: (_i, row, col) => ({ duration: 0.9, delay: -(row * 0.12 + hash(col, 3, 900)), lo: 0.15, }), }, searching: { blink: (_i, _row, col) => ({ duration: 1.1, delay: -col * 0.12, lo: 0.2 }), }, syncing: { blink: (_i, row, col) => { const turn = (Math.atan2(row - CENTER, col - CENTER) + Math.PI) / (2 * Math.PI); return { duration: 1.3, delay: -turn * 1.3, lo: 0.2 }; }, }, connecting: { blink: (_i, row, col) => ({ duration: 1.4, delay: -Math.max(Math.abs(row - CENTER), Math.abs(col - CENTER)) * 0.18, lo: 0.15, }), }, waiting: { glyph: ELLIPSIS, blink: (_i, _row, col) => ({ duration: 1.2, delay: -col * 0.09, lo: 0.2, }), }, uploading: { blink: (_i, row) => ({ duration: 1, delay: -(GRID - 1 - row) * 0.12, lo: 0.2, }), }, downloading: { blink: (_i, row) => ({ duration: 1, delay: -row * 0.12, lo: 0.2 }), }, listening: { blink: (_i, _row, col) => ({ duration: 0.7 + hash(col, 4, 500), delay: -hash(col, 5, 900), lo: 0.25, }), }, speaking: { blink: (_i, _row, col) => ({ duration: 0.4 + hash(col, 6, 350), delay: -hash(col, 7, 700), lo: 0.2, }), }, recording: { color: "text-red-500", glyph: RECORD, dim: 0.12, blink: () => ({ duration: 1.4, delay: 0, lo: 0.3 }), }, success: { color: "text-emerald-500", glyph: CHECK }, error: { color: "text-red-500", glyph: CROSS, blink: () => ({ duration: 1.1, delay: 0, lo: 0.4 }), }, warning: { color: "text-amber-500", glyph: BANG, blink: () => ({ duration: 1.6, delay: 0, lo: 0.45 }), }, info: { color: "text-blue-500", glyph: INFO }, paused: { color: "text-muted-foreground", glyph: PAUSE }, stopped: { color: "text-muted-foreground", glyph: STOP }, offline: { color: "text-muted-foreground", base: 0.15 },} satisfies Record<string, StateConfig>;export type DotMatrixState = keyof typeof STATES;const dotMatrixStates = Object.keys(STATES) as readonly DotMatrixState[];export type DotMatrixProps = Omit<ComponentProps<"span">, "children"> & { state?: DotMatrixState; label?: string;};/* The blink animation runs on every dot in every state (static states set hi = lo) and the registered hi/lo custom properties carry a transition, because removing or adding an animation never triggers a CSS transition on the animated property itself; transitioning the amplitude bounds is what makes state changes cross-fade. */const DOT_MATRIX_CSS = '@property --aui-dot-matrix-hi{syntax:"<number>";inherits:false;initial-value:1}@property --aui-dot-matrix-lo{syntax:"<number>";inherits:false;initial-value:0.15}@keyframes aui-dot-matrix-blink{0%,100%{opacity:var(--aui-dot-matrix-hi,1)}50%{opacity:var(--aui-dot-matrix-lo,0.15)}}';/** * Tiny 5x5 dot-matrix status indicator with 20 built-in states. Dots inherit the text color and animate in state-specific patterns: twinkle, waves, ripples, sweeps, equalizer columns, and check/cross/bang glyphs. State changes cross-fade per dot. * * ```tsx * <DotMatrix state={isRunning ? "loading" : "success"} /> * ``` */function DotMatrix({ className, state = "loading", label, ...props}: DotMatrixProps) { const config: StateConfig = STATES[state]; return ( <span data-slot="dot-matrix" data-state={state} role="status" className={cn("inline-block size-4 shrink-0", config.color, className)} {...props} > <span className="sr-only">{label ?? state}</span> {/* Hoisted and deduplicated across instances by React; must live in HTML scope, inside the SVG it would be an SVG-namespace element React does not hoist. */} <style href="aui-dot-matrix" precedence="low"> {DOT_MATRIX_CSS} </style> <svg aria-hidden viewBox="0 0 20 20" fill="currentColor" className="size-full" > {DOT_INDEXES.map((i) => { const row = Math.floor(i / GRID); const col = i % GRID; const on = !config.glyph || config.glyph.has(i); const hi = on ? (config.base ?? 1) : (config.dim ?? 0.15); const blink = on ? config.blink?.(i, row, col) : undefined; return ( <circle key={i} data-slot="dot-matrix-dot" cx={2 + col * 4} cy={2 + row * 4} r={1.3} className="[transition-property:--aui-dot-matrix-hi,--aui-dot-matrix-lo,opacity] duration-300 [animation-iteration-count:infinite] [animation-name:aui-dot-matrix-blink] [animation-timing-function:ease-in-out] motion-reduce:[animation-name:none]" style={ { opacity: hi, animationDuration: `${blink?.duration ?? 1}s`, animationDelay: `${blink?.delay ?? 0}s`, "--aui-dot-matrix-hi": hi, "--aui-dot-matrix-lo": blink?.lo ?? hi, } as CSSProperties } /> ); })} </svg> </span> );}export { DotMatrix, dotMatrixStates };This adds a /components/assistant-ui/dot-matrix.tsx file to your project, which you can adjust as needed. The component has no dependencies beyond React.
Usage
import { DotMatrix } from "@/components/assistant-ui/dot-matrix";
export function RunIndicator({ isRunning }: { isRunning: boolean }) {
return <DotMatrix state={isRunning ? "loading" : "success"} />;
}Dots inherit the surrounding text color, so the matrix renders dark dots on light backgrounds and light dots on dark backgrounds without configuration. Every state is a combination of a dot pattern, a motion, and a color, and switching states cross-fades each dot into its new pattern.
States
| State | Pattern |
|---|---|
idle | Dim static grid |
loading | Randomized twinkle (default) |
thinking | Diagonal wave |
streaming | Falling rain, per-column streaks |
searching | Horizontal sweep |
syncing | Rotating sweep around the center |
connecting | Ripple expanding from the center |
waiting | Ellipsis dots blinking in sequence |
uploading | Wave rising upward |
downloading | Wave falling downward |
listening | Slow equalizer columns |
speaking | Fast equalizer columns |
recording | Red center dot breathing |
success | Green check glyph, static |
error | Red cross glyph, blinking |
warning | Amber exclamation glyph, slow blink |
info | Blue info glyph, static |
paused | Pause bars glyph, static |
stopped | Square glyph, static |
offline | Very dim static grid |
The component exports dotMatrixStates (the ordered list above) and the DotMatrixState union type, so UIs can enumerate or map states without duplicating the list. New states are added by extending the STATES record in the component source with a glyph and a per-dot blink function.
Examples
State Lifecycle
Drive the state prop from your run status; the matrix morphs between patterns instead of swapping components.
Inline With Text
At the default size-4 the matrix aligns with text like an icon, and the dots adapt to inverted surfaces through currentColor.
Sizes
The matrix is an SVG, so any size utility scales it crisply.
How It Works
The grid is a 5x5 SVG of currentColor circles. Blinking is a single CSS keyframe animation whose high/low opacity bounds come from registered per-dot CSS variables; the animation runs in every state (static states collapse the bounds) and the bounds carry a transition, which is what makes state changes cross-fade per dot. The randomized loading rhythm uses deterministic per-dot delays and durations, so server and client render identical markup and no JavaScript runs after render. With prefers-reduced-motion, the dots hold their resting opacity instead of blinking.
The root is a role="status" live region whose text content is the state name (or the label prop), so screen readers announce state changes; the SVG itself is aria-hidden.
API Reference
DotMatrix
DotMatrixPropsstate: DotMatrixState= "loading"One of the 20 built-in states listed above, controlling pattern, motion, and color.
label?: stringAccessible label announced by screen readers. Defaults to the state name.
className?: stringAdditional CSS classes. Use size utilities to scale and text color utilities to recolor.
Styling
Color follows currentColor, so className="text-blue-500" recolors the whole matrix; the outcome states (success, error, warning, info, recording, and the muted static states) set their own color which a className can override. Dots are targetable via [data-slot="dot-matrix"] and [data-slot="dot-matrix-dot"], and the current state is exposed as data-state on the root.
Related Components
- Number Roll - Animated rolling number
- Badge - Small status and metadata labels
- Voice - Voice activity visualization