A dithered UI toolkit for Solid. 270 components draw themselves pixel by pixel from one seeded palette: charts, controls, forms, overlays, backgrounds, text effects, 3D models and GLSL shaders, all through the same ordered-dither (Bayer) engine. It has a live docs section for every component, and a Studio your coding agent can drive.
Live at dither-ui.com, with the docs and the Studio.
The kit is copy-in, shadcn-style: it lives in packages/dither-kit/src/ with
no imports from the app, so you copy the folder into your project and own every
line. It is one kit in layers: engine/ holds the styling-free logic (rasters,
painters, contexts, the clock, the tokens), solid/ the SolidJS components
styled with Tailwind classes and tailwind/ what that layer shares (the class
helpers and the kit's stylesheet entry). The same components in StyleX are a
folder beside it, packages/dither-kit-stylex/src/, generated from the
Tailwind ones so the two never drift. @dither-kit points at the layer your
app styles with. Beside Solid it needs four packages: d3-scale, d3-shape,
clsx and tailwind-merge. Tailwind is optional. npm run build:css compiles
the kit's own stylesheet (packages/site/.output/public/kit/dither-kit.css:
tokens, a scoped base and every class the components use) for apps on StyleX,
UnoCSS, plain CSS or CSS-in-JS.
npm install # one install for every package of the workspace (Node 24)
npm run dev # the site: http://localhost:3000 (/, /docs, /studio)
npm run build # the static site, into packages/site/.output/public
npm run preview # serves that build
npm run typecheck # tsc over every package
npm run test # rs test (Rstest), then each package's component specs (Vitest)
npm run lint # rs lint (Rslint)
npm run format # rs fmtEverything runs from the root, on Node 24 (SolidStart needs it). npm run build
prerenders every page, then draws the share cards and reads the agent registry
in headless Chromium; point CHROME_PATH at a Chromium binary if it is not in
a standard place. The GitHub Pages deploy runs on Node 24.
# 1. copy the kit folder straight from the repo
npx degit drvova/dither-ui/packages/dither-kit/src src/dither-kit
# 2. install the four packages it needs beside Solid
npm i d3-scale d3-shape clsx tailwind-merge
# 3. alias @dither-kit (bundler resolve.alias and tsconfig paths)
# "@dither-kit" -> "./src/dither-kit/solid"On StyleX, copy the StyleX folder beside the kit
(npx degit drvova/dither-ui/packages/dither-kit-stylex/src src/dither-kit-stylex),
alias "@dither-kit" to ./src/dither-kit-stylex and "dither-kit" to
./src/dither-kit (the StyleX files read the engine from the kit by that
name), install @stylexjs/stylex (with your bundler's StyleX plugin) in place
of clsx and tailwind-merge, and load engine/theme.css and
engine/base.css. Every
component that takes class also takes xstyle: your StyleX styles, merged
after its own.
Charts use a children-as-config API: compose parts inside a root.
import {
AreaChart, Area, Grid, XAxis, YAxis, Legend, Tooltip,
type ChartConfig,
} from "@dither-kit"
const data = [
{ month: "Jan", desktop: 186, mobile: 80 },
{ month: "Feb", desktop: 305, mobile: 200 },
]
const config: ChartConfig = {
desktop: { label: "Desktop", color: "blue" },
mobile: { label: "Mobile", color: "purple" },
}
export default function Traffic() {
return (
<AreaChart class="h-80" data={data} config={config} bloom="low">
<Grid />
<XAxis dataKey="month" />
<YAxis />
<Area dataKey="desktop" isClickable />
<Area dataKey="mobile" variant="hatched" isClickable />
<Legend isClickable />
<Tooltip labelKey="month" />
</AreaChart>
)
}Give every root a height: the canvas measures its box. Theme the kit by
overriding its tokens (dither-kit/engine/theme.css lists them: light at :root,
dark under .dark), from CSS or with applyTheme() from JS.
The page is printed in the dark, always: one bone ink on obsidian, the kit's art its only colour. The hero: the statement and the keys beside a live print the kit makes, the palette as tone with the kit's diamond knocked out of it. "Watch the film" opens the trailer, and under the keys a quiet line names what the kit works with, each tool under its own mark. Everything else on the page is the kit too, on plain filled grounds with no tags or borders:
- a bento of twelve live components, each leading into its docs section (a rail you swipe on a tablet or a phone): a 3D model you re-roll and recolour, a rating, a stat card, print screens, an area chart you reseed, the button's fills and hues, a GLSL shader, a ferrofluid, one snippet written as StyleX, CSS Modules, CSS or Tailwind beside the card it renders, a slide action, silk and decrypted text;
- one agent session, played as you scroll: the ask, what the agent does said in plain words, and the screen it lays out building beside it row by row;
- what's inside: the kit, the Studio and the agent bridge, each harness under its own mark, and the docs family by family;
- the install, under the marks of npm, pnpm and Bun, with a key that copies a prompt for your coding agent;
- the footer: link columns on the kit's own art, a field of grain, a small spiral galaxy turning around the brand's diamond, and dithered light trails that weave through it and sweep behind the line-screen wordmark. It dances for a few seconds as it comes into view, then moves while you scroll or move the pointer.
Every component has a section at /docs/<id>: a live preview, its props table,
pickers for its variants and the code. The code is Solid, written
with Tailwind classes, plain CSS, CSS Modules or StyleX. Every preview is also a
working tool.
- Inspect opens a workbench over the preview:
- measure: box model, distances, X-ray, rulers and guides, type and WCAG contrast, colour sampling, a pixel loupe;
- check: the accessibility view and the Tab order;
- annotate: arrows, ink, notes and comment threads;
- capture: screenshots, video, or a GIF dithered with the kit's own matrix.
Kcopies everything as Markdown for a coding agent.
- Code shows the preview as files: the example, the kit's own source for
each component it imports, the tokens and a
prompt.md. Edit any of them and Run compiles them in the page, or download them all as a zip. - Interface sounds play on taps, toggles and copies; the header switches them off.
- On this page, the rail beside wide screens, lists the section in view: its galleries and its API table, one click each.
A canvas-first editor for every component in the kit:
- Canvas: pan and zoom, artboards you move, resize, group, lock and hide.
- Library: one searchable place for charts, components, screens and presets.
- Inspector: colour, texture, bloom, easing and animation for every part.
- Data editor: a spreadsheet for each chart.
- Evolve: seeded variations of whatever you have selected.
- Reels: a sequence of frames with ordered-dither transitions between them.
- Export:
- code as a Solid component, in your styling system;
- PNG;
- MP4, through a HyperFrames composition that renders the same video every time.
- Projects: autosave,
.jsonfiles, undo and redo.
The Studio is addressable by the coding agent you already use:
packages/site/public/agent/SKILL.mdteaches any harness the document shape and the commands.- The build writes every component's props to
/agent/registry.json. - A live tab answers
dither-studio:commandDOM events and exposeswindow.ditherStudio.
To drive it from the Studio's Agent panel, run the local bridge:
npx degit drvova/dither-ui/packages/bridge/src bridge && node bridge/dither-bridge.mjsThe bridge finds the harnesses on your PATH and starts the one you pick as your own signed-in process. It works with:
- Claude Code
- Codex
- Gemini CLI
- Qwen Code
- oh-my-pi
- Goose
- OpenCode
- Auggie
- any other command that speaks the Agent Client Protocol
No key is ever asked for in the app. See packages/bridge/README.md.
For deterministic server-rendered surfaces, compile the dither backing store on the server and send its encoded image URL to the kit. The compiler has no DOM, canvas, or framework dependency and returns RGBA pixels, so use the image encoder your server already uses:
import sharp from "sharp"
import { renderDitherGradient } from "@dither-kit"
const raster = renderDitherGradient({
width: 960, height: 600, cell: 2, from: "blue", to: "transparent", seed: 42,
})
const png = await sharp(Buffer.from(raster.data), {
raw: { width: raster.width, height: raster.height, channels: 4 },
}).png().toBuffer()
// Store png and pass its URL to the client.Pass the packaged URL through precompiled. On charts it replaces only the
plot canvas, so axes, legends, tooltips, and interactions can remain composed
around it. The asset must match the chart's plot dimensions and should be
invalidated when data, colors, dimensions, or dither props change:
<AreaChart data={data} config={config} precompiled={{ src: chartPngUrl }} animate={false}>
<Grid /><XAxis dataKey="month" /><YAxis />
<Area dataKey="desktop" />
</AreaChart>DitherGradient, DitherImage, DitherButton, and DitherSpinner accept the
same precompiled URL. Use renderMode="static" when the visual should still
be painted in the browser but never animate or observe resizes. In static mode
the kit defers initial paint through requestIdleCallback (with setTimeout
fallback for Safari) so decorative gradients never block first contentful paint,
and auto-uses lower backing-resolution caps (320×200 vs 960×600) since the
visual difference at low opacity is imperceptible. Override per-instance with
maxCols/maxRows props:
<DitherGradient from="blue" opacity={0.14} renderMode="static" maxCols={480} maxRows={300} />Without a packaged asset, the kit also batches gradient/button/spinner pixels
through ImageData, caps chart backing resolution with cell, pauses
off-screen chart loops, and avoids rebuilding bloom layers when the frame is
unchanged. Exported constants DEFAULT_MAX_COLS (960), DEFAULT_MAX_ROWS
(600), STATIC_DEFAULT_MAX_COLS (320), STATIC_DEFAULT_MAX_ROWS (200) let
server-side compilers match client caps.
The kit applies several main-thread optimizations that benefit every consumer:
- Inlined pixel writes.
paintColumn(chart frames) and the gradient/button compilers write directly intoUint8ClampedArraywith| 0rounding instead of per-pixel function calls. willReadFrequentlycanvas contexts. All primary canvas contexts use{ willReadFrequently: true }, hinting the browser to use software rendering forputImageData-heavy surfaces. Bloom canvases omit the flag (they only usedrawImage).requestAnimationFramelayout deferral. Standalone components defer their initialgetBoundingClientRect()read to arequestAnimationFramecallback, batching layout reads into one frame after layout completes instead of forcing a synchronous reflow per component as it mounts.RasterBufferfor toggle canvases.paintToggleCanvas(shared by DitherToggle and DitherToggleGroup) usescreateRasterBuffer+putRasterBufferinstead of per-pixelfillRect.IntersectionObservervisibility gating. Chart paint loops remain paused until the IO reports the canvas visible. The IO callback marks the canvas visible before callingonWakesoschedule()sees the correct state and requests a RAF immediately.
Kit-level benchmarks on the Solid components, in a real DOM
(npm run bench:browser -w dither-kit, Solid's production build, 3 warmups +
5 batches × 3 runs, the mean, as the median of five such passes). Measured
2026-11-13 on a cloud container (4 cores of an Intel Xeon at 2.30 GHz, 16 GB),
headless Chromium 141 rendering in software (SwiftShader, no GPU):
| Operation | Compute | Full (RAF incl.) | Notes |
|---|---|---|---|
| DitherGradient live mount | 0.10 ms | 16.6 ms | 960×120, cell=3 |
| DitherGradient static mount | 0.10 ms | 16.6 ms | 320×200 cap, cell=4, idle-deferred |
| DitherGradient live re-render | 0.34 ms | 16.7 ms | direction up to down |
| DitherButton live mount | 0.17 ms | 33.3 ms | gradient variant, cell=2 |
| DitherButton static mount | 0.14 ms | 16.6 ms | 320×200 cap, idle-deferred |
| DitherSpinner live mount | 0.26 ms | 16.6 ms | 32px, RAF loop |
| Raw raster gradient | 2.25 ms | 2.2 ms | 480×300, no framework |
Compute time is mount to the next microtask (Solid's render and the
component's onMount: the kit's actual work). Full time adds the wait for
a requestAnimationFrame frame (~16.67 ms at 60 fps) before the canvas has
visible pixels; a live button measures its box in one frame and paints in the
next, so its first pixels take two. The raw raster benchmark has no framework
overhead and no RAF wait, so its full time equals compute time. Static mode
is comparable to live mode in compute time because both use the same inlined
pixel writes; the difference is that static mode auto-caps at 320×200 (4×
fewer pixels) and defers paint via requestIdleCallback.
These are per-component measurements on one machine without a GPU, not
device-independent latency promises. Re-run on target devices before choosing
cell, animation, bloom, or precompilation policy.
The kit carries two benchmarks, both run by hand and neither part of a build. The figures below were measured 2026-11-13 on the same cloud container (4 cores of an Intel Xeon at 2.30 GHz), Node 24.22.3.
npm run bench -w dither-kit times the engine's hot paths in Node, 3 warmups
then 20 batches a path. The median batch, as the median of seven runs:
| Hot path | Median |
|---|---|
renderDitherGradient, 960×600 at 2 px cells (144,000 pixels) |
1.80 ms |
renderDitherGradient, capped at 320×200 (64,000 pixels) |
0.83 ms |
renderDitherButton, 240×56 at 2 px cells, fresh buffer |
0.061 ms |
renderDitherButton, the same into a reused buffer |
0.056 ms |
paintColumn × 520 columns, a 520×200 chart frame |
1.24 ms |
setOrBlendRasterPixel × 64,000 (blendRasterPixel: 0.91 ms) |
0.86 ms |
npm run bench:browser -w dither-kit runs the painters in headless Chromium
141 (software rendering, SwiftShader): 3 warmups, then 6 measured batches × 2
repetitions. Gradient samples run at 960×600 CSS px with 2 px cells and
compare the legacy per-cell fillRect painter with RGBA generation plus one
putImageData upload. Button samples run at 240×56 CSS px and compare fresh
RGBA allocation against a reused target buffer. The table reports mean,
median, p95, canvas calls, and RGBA allocation count, each timing the median
of five independent runs (a fresh page each):
| Painter | Mean | Median | p95 | Canvas calls | RGBA allocations |
|---|---|---|---|---|---|
| Gradient legacy fillRect | 60.03 ms | 58.26 ms | 71.12 ms | 144,000 | 0 |
| Gradient RGBA + putImageData | 2.21 ms | 2.21 ms | 2.89 ms | 1 | 13 |
| Button fresh RGBA buffer | 0.13 ms | 0.12 ms | 0.17 ms | 1 | 24 |
| Button reused RGBA buffer | 0.07 ms | 0.07 ms | 0.11 ms | 1 | 2 |
The gradient rows show a 27.1× speedup and 96.3% lower measured paint latency. The button rows are intentionally small in wall-clock terms; their useful signal is allocation behavior, where reuse keeps the repeated RGBA buffer allocation count bounded at two reusable objects.
The five runs behind those medians:
| Run | Legacy mean | Legacy median | Legacy p95 | Raster mean | Raster median | Raster p95 |
|---|---|---|---|---|---|---|
| 1 | 66.45 ms | 66.80 ms | 79.11 ms | 2.57 ms | 2.31 ms | 3.58 ms |
| 2 | 66.20 ms | 60.40 ms | 92.17 ms | 2.49 ms | 2.43 ms | 3.71 ms |
| 3 | 58.33 ms | 56.61 ms | 68.05 ms | 2.21 ms | 2.21 ms | 2.58 ms |
| 4 | 60.03 ms | 58.26 ms | 70.00 ms | 1.93 ms | 1.75 ms | 2.89 ms |
| 5 | 58.74 ms | 56.51 ms | 71.12 ms | 1.89 ms | 1.76 ms | 2.88 ms |
Each legacy gradient sample made 144,000 fillRect calls; each raster
gradient sample made one putImageData upload. These are directional
measurements on one machine, in software, not a device-independent latency
promise. Measure on target low-power devices before choosing cell,
animation, bloom, or precompilation policy.
270 public Dither* components plus the chart roots and parts. Selected
exports:
| Charts | Parts | Standalone |
|---|---|---|
AreaChart LineChart |
Area Line Bar Pie Radar |
DitherButton DitherSwitch |
BarChart |
Grid XAxis YAxis |
DitherAvatar DitherImage |
PieChart RadarChart |
Dot ActiveDot Legend Tooltip |
DitherGradient DitherSpinner |
Sparkline RadarFrame |
DitherWorld DitherShader |
The barrel also exports the control, form, feedback, navigation, overlay and
surface families, 49 generative backgrounds (the poster among them), 24 text
effects and 49 interaction and motion effects. Every surface thresholds
through a 4x4 screen: Bayer, unless a DitherScreen around it picks a
printer's line, diagonal, dot or cross (a fill that carries a label
keeps Bayer). Shared chart props:
bloom(off,low,high,aura),bloomOnHover,animate;stackType(default,stacked,percent);hovered,markerIndex,defaultSelectedDataKey,onSelectionChange,onHoverChange.
Fill variant: gradient, dotted, hatched, solid. Palette colours:
green, blue, purple, pink, orange, red, grey.
package.json the workspace: one install, one lockfile, every script
packages/
dither-kit/ the Solid kit (@dither-kit): copy-in, MIT, no imports from the app
src/engine/ styling-free logic: rasters, painters, contexts, clock, tokens
src/solid/ the components, styled with Tailwind (what @dither-kit points at)
src/tailwind/ the class helpers, the kit's stylesheet entry
tests/ benches/ the engine's and the components' specs; hot paths timed in Node and Chromium
dither-kit-stylex/ the same components in StyleX, generated from the kit's solid/
restyle/ writes Tailwind-classed code in your styling system; the StyleX generator
site/ the site on SolidStart
src/ routes and the app, by slice: app, pages, widgets, features, entities, shared
build/ the Vite plugins and the post-build
public/ static files
tests/ framework-free specs, and component specs in site/
tasks/ share cards, kit stylesheet, frame renderer, the agent demo
bridge/ the local bridge between the Studio and your coding agent
discord/ the /component slash command for Discord
tests/ the workspace's own specs: package boundaries, the design doc
The repository is one npm workspace. Each package has its own manifest, source
and tests, and dependencies point one way: the kit depends on nothing here, the
site on everything it shows. A package imports another by name, never by a path
out of its folder; tests/workspace.spec.ts fails the build on an import that
goes the wrong way. The site follows Feature-Sliced Design: imports only point
down (app, pages, widgets, features, entities, shared). It is SolidStart on
Vite, prerendered to static files (vite, vitest); the root runs the Rstack
CLI (rs) with one config, rstack.config.ts: Rstest, Rslint and the
formatter. Routes are /, /docs/<section> and /studio; old hash links
still resolve. GitHub Pages deploys through .github/workflows/pages.yml to
dither-ui.com.
Every folder that owns rules has an AGENTS.md: the contract for that part of
the tree, for people and agents alike. Read the chain from the root down before
changing something.
The charts began as a port of Dither Kit, inspired by Evil Charts, first to Vue and from there to Solid:
- React context became a Solid context holding one controller with reactive getters.
useMemo/useCallbackceremony becamecreateMemoand signals.- The framework-agnostic
requestAnimationFramecanvas painters ported almost verbatim. - The tooltip's
motionspring became a CSS glide. - The shadcn-style tokens (
--foreground,--card, …) live indither-kit/engine/theme.css.
Dithering approach and the original Dither Kit charts by tripwire.sh/dither-kit.
The site names the tools it works with by their own marks: npm, pnpm, Bun,
Node.js, Solid, Tailwind CSS, CSS Modules, CSS, GitHub, Qwen and OpenCode
from Simple Icons (CC0); Claude Code, Codex,
Gemini CLI and Goose from LobeHub icons
(MIT); oh-my-pi, Auggie and StyleX from their own sites. Each mark is its owner's trademark,
shown only to name the tool, and none ships in dither-kit/.










