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

Latest commit

 

History

556 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dither-ui

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

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.

Quick start

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 prerender

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

Use the kit in your app

# 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 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 comes in Vue or Svelte, 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 Vue SFC, 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:

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

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 inside Vue's flushJobs.
  • 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 sets visible.value before calling onWake so schedule() 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.

Benchmark

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.

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

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.

Port notes

The charts began as a Vue port of Dither Kit, inspired by Evil Charts:

  • React context became Vue provide/inject with a reactive getter-facade controller.
  • useMemo/useCallback ceremony became computed/ref.
  • The chartLayer children routing became render-function vnode inspection.
  • The framework-agnostic requestAnimationFrame canvas painters ported almost verbatim.
  • The tooltip's motion spring became Vue <Transition> and 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, 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/.

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