A dithered UI toolkit for Vue. 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 Svelte 5 port, 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 dither-kit/ 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), tailwind/ the components styled with
Tailwind classes and stylex/ the same components in StyleX, generated from
the Tailwind ones so the two never drift. @dither-kit points at the layer
your app styles with. Beside
Vue 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 (dist/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
npm run dev # rs dev: http://localhost:5173 (/, /docs, /studio)
npm run test # rs test (Rstest)
npm run lint # rs lint (Rslint) + ESLint for .vue files
npm run format # rs fmt
npm run build # type-check, rs build, the kit stylesheet, then the prerenderThe prerender at the end of npm run build drives headless Chromium; point
CHROME_PATH at a Chromium binary if it is not in a standard place. CI and the
GitHub Pages deploy run on Node 22.
# 1. copy the kit folder straight from the repo
npx degit drvova/dither-ui/dither-kit src/dither-kit
# 2. install the four packages it needs beside Vue
npm i d3-scale d3-shape clsx tailwind-merge
# 3. alias @dither-kit (bundler resolve.alias and tsconfig paths)
# "@dither-kit" -> "./src/dither-kit/tailwind"On StyleX, alias "@dither-kit" to ./src/dither-kit/stylex instead, 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.
Svelte 5 apps copy drvova/dither-ui/dither-kit-svelte instead and import from
@dither-kit-svelte. Charts use a children-as-config API: compose parts inside
a root.
<script setup lang="ts">
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" },
}
</script>
<template>
<AreaChart class="h-80" :data="data" :config="config" bloom="low">
<Grid />
<XAxis dataKey="month" />
<YAxis />
<Area dataKey="desktop" is-clickable />
<Area dataKey="mobile" variant="hatched" is-clickable />
<Legend is-clickable />
<Tooltip labelKey="month" />
</AreaChart>
</template>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 comes in Vue or Svelte, 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 Vue SFC, 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:
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/bridge 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 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 Vue 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" render-mode="static" :max-cols="480" :max-rows="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 inside Vue'sflushJobs.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 setsvisible.valuebefore callingonWakesoschedule()sees the correct state and requests a RAF immediately.
Kit-level benchmarks (Chrome DevTools, real DOM, 3 warmups + 5 batches × 3 runs):
| Operation | Compute | Full (RAF incl.) | Notes |
|---|---|---|---|
| DitherGradient live mount | 0.45 ms | 16.6 ms | 960×120, cell=3 |
| DitherGradient static mount | 0.42 ms | 16.4 ms | 320×200 cap, cell=4, idle-deferred |
| DitherGradient live re-render | 0.75 ms | 16.7 ms | direction up→down |
| DitherButton live mount | 0.51 ms | 16.4 ms | 240×56, cell=2 |
| DitherButton static mount | 0.48 ms | 16.5 ms | 320×200 cap, idle-deferred |
| DitherSpinner live mount | 0.59 ms | 16.4 ms | 32px, RAF loop |
| Raw raster gradient | 5.21 ms | 4.9 ms | 480×300, no Vue |
Compute time is mount→nextTick (Vue patch + onMounted setup — the kit's
actual work). Full time adds the requestAnimationFrame frame wait
(~16.67 ms at 60 fps) before the canvas has visible pixels. The raw raster
benchmark has no Vue 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 desktop, not device-independent
latency promises. Re-run on target devices before choosing cell, animation,
bloom, or precompilation policy.
Open http://localhost:5173/benchmarks/ after npm run dev (a dev-only page; it is not built or deployed). The browser
benchmark performs 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.
A local Chrome run of the expanded benchmark produced:
| Painter | Mean | Median | p95 | Canvas calls | RGBA allocations |
|---|---|---|---|---|---|
| Gradient legacy fillRect | 109.11 ms | 107.70 ms | 174.40 ms | 144,000 | 0 |
| Gradient RGBA + putImageData | 4.70 ms | 4.10 ms | 10.00 ms | 1 | 13 |
| Button fresh RGBA buffer | 0.28 ms | 0.30 ms | 0.40 ms | 1 | 24 |
| Button reused RGBA buffer | 0.45 ms | 0.20 ms | 2.90 ms | 1 | 2 |
The gradient rows in that run show a 23.2× speedup and 95.7% 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.
Earlier five independent local Chrome runs without CPU throttling produced:
| Run | Legacy mean | Legacy median | Legacy p95 | Raster mean | Raster median | Raster p95 |
|---|---|---|---|---|---|---|
| 1 | 110.80 ms | 109.70 ms | 173.20 ms | 6.44 ms | 4.00 ms | 36.10 ms |
| 2 | 111.76 ms | 114.40 ms | 180.00 ms | 4.01 ms | 4.30 ms | 5.10 ms |
| 3 | 108.18 ms | 110.00 ms | 151.00 ms | 4.07 ms | 4.50 ms | 4.80 ms |
| 4 | 122.25 ms | 121.80 ms | 179.60 ms | 5.62 ms | 4.60 ms | 20.10 ms |
| 5 | 118.31 ms | 129.30 ms | 156.60 ms | 3.94 ms | 3.90 ms | 4.60 ms |
| Average mean | 114.26 ms | — | — | 4.82 ms | — | — |
The earlier average means show a 23.7× speedup and 95.8% lower measured
paint latency. Each legacy gradient sample made 144,000 fillRect calls; each
raster gradient sample made one putImageData upload. These are directional
measurements on one desktop, not a device-independent latency promise. Re-run
the page 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.
dither-kit/ the Vue kit (@dither-kit): copy-in, MIT, no imports from the app
engine/ styling-free logic: rasters, painters, contexts, clock, tokens
tailwind/ the components, styled with Tailwind (what @dither-kit points at)
stylex/ the same components in StyleX, generated from tailwind/
dither-kit-svelte/ the Svelte 5 port (runes)
svelte-site/ a Svelte showcase built on the port
bridge/ the local bridge between the Studio and your coding agent
discord/ the /component slash command for Discord
benchmarks/ the raster benchmark page (rs dev only)
scripts/ prerender, kit stylesheet, frame renderer
src/
app/ entry, the site's styles, the path and legacy-hash router
pages/ landing, docs, studio, play (the video player page)
widgets/ the docs workbench and source panel; the Studio's toolbar,
layer tree, inspector, canvas, renderers, data editor, agent panel
features/ history, keyboard, persistence, code, image and video export,
reels, pan and zoom, artboard transforms, the agent protocol
entities/ editor, chart, widget, artboard
shared/ ui, config, lib
tests/ Rstest specs
The app follows Feature-Sliced Design: imports only point down (app, pages,
widgets, features, entities, shared), and everything reaches the kit through
@dither-kit. The whole toolchain is the Rstack CLI (rs) with one config,
rstack.config.ts: Rsbuild for dev and build, 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 Vue port of Dither Kit, inspired by Evil Charts:
- React context became Vue
provide/injectwith a reactive getter-facade controller. useMemo/useCallbackceremony becamecomputed/ref.- The
chartLayerchildren routing became render-function vnode inspection. - The framework-agnostic
requestAnimationFramecanvas painters ported almost verbatim. - The tooltip's
motionspring became Vue<Transition>and 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, Vue, Svelte, Tailwind CSS, CSS Modules, CSS, GitHub, Qwen and OpenCode
from Simple Icons (CC0; the Vue logo is by the Vue
team, CC BY-NC-SA 4.0); 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/.










