Skip to content
drvovaPublic

About

A dithered UI toolkit for Vue 3 — charts, primitives and Base UI-parity components on one ordered-dither canvas engine, everything seed-generative.

Topics

Resources

Stars

17 stars

Watchers

0 watching

Forks

Repository files navigation

dither-ui

dither-ui: a dithered UI toolkit for Solid, the statement beside a live print the kit makes

Neutral dither face Smiling dither face Blushing dither face Winking dither face Surprised dither face Excited dither face

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 dither-ui landing: a bento of live kit components under "Every component, live."

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.

Quick start

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 fmt

Everything 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.

Use the kit in your app

# 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 site

Landing

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.

Docs

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.

The workbench over a docs preview: rulers, a pinned element's box model and the tool tray

  • 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. K copies 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.

The source panel: an edited example running in the preview above its files

Studio

The Studio as an app window: the project rail and sidebar with its layers, an area chart on the canvas, the inspector docked right

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, .json files, undo and redo.

Agents

The Studio is addressable by the coding agent you already use:

  • packages/site/public/agent/SKILL.md teaches 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:command DOM events and exposes window.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.mjs

The 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.

Performance

Fast and precompiled rendering

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.

Rendering performance

The kit applies several main-thread optimizations that benefit every consumer:

  • Inlined pixel writes. paintColumn (chart frames) and the gradient/button compilers write directly into Uint8ClampedArray with | 0 rounding instead of per-pixel function calls.
  • willReadFrequently canvas contexts. All primary canvas contexts use { willReadFrequently: true }, hinting the browser to use software rendering for putImageData-heavy surfaces. Bloom canvases omit the flag (they only use drawImage).
  • requestAnimationFrame layout deferral. Standalone components defer their initial getBoundingClientRect() read to a requestAnimationFrame callback, batching layout reads into one frame after layout completes instead of forcing a synchronous reflow per component as it mounts.
  • RasterBuffer for toggle canvases. paintToggleCanvas (shared by DitherToggle and DitherToggleGroup) uses createRasterBuffer + putRasterBuffer instead of per-pixel fillRect.
  • IntersectionObserver visibility gating. Chart paint loops remain paused until the IO reports the canvas visible. The IO callback marks the canvas visible before calling onWake so schedule() 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.

Benchmark

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.

Components

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.

Repository

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.

Port notes

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/useCallback ceremony became createMemo and signals.
  • The framework-agnostic requestAnimationFrame canvas painters ported almost verbatim.
  • The tooltip's motion spring became a CSS glide.
  • The shadcn-style tokens (--foreground, --card, …) live in dither-kit/engine/theme.css.

Creator

Contributors

Credits

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/.

About

A dithered UI toolkit for Vue 3 — charts, primitives and Base UI-parity components on one ordered-dither canvas engine, everything seed-generative.

Topics

Resources

Stars

17 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages