A component library that ships its own printing press.
Buttons, charts, switches and washes are halftone screens pressed onto canvas,
the way ink actually lands on paper.
halftone-ui.com · ▶ Docs & live demos
Most UI libraries paint: a fill is a hex value, a gradient is a CSS function, a chart is an SVG path. Halftone UI prints. Every fill is a live canvas holding a seeded dot cloud, and each dot carries its own threshold. A component supplies a tone function — how dark is the ink at this point? — and the press keeps the dots that tone can reach.
That single operation — threshold a tone field against a screen — is what halftone means. It's also the entire library.
// every component is just a tone function 0..1
const meter = surface(canvas, {
tone: (p, W, H) => (p.x / W < 0.72 ? 0.95 : 0.05),
pattern: "hatch",
})
// the press: keep the dots whose threshold the local tone can reach
for (const p of dots) {
if (tone(p) > p.threshold) ink(p)
}The dots come from a seeded Poisson-disk cloud — blue noise, which is exactly what stochastic screening uses on a real press. Because the seed is deterministic, a reload gives you the same ten thousand dots; reroll and the whole page reprints at once. Animation never tweens CSS: a value glides, every dot re-tests its threshold, and the grain itself is the motion.
It's a loving riff on dither-ui — same docs-site format, different printmaking tradition.
No install. It's one HTML file.
git clone https://github.com/ecgang/halftone-ui.git
open halftone-ui/dist/index.htmlOr just download dist/index.html and double-click it. Everything — engine, docs, demos, themes — is inside, in one self-contained file. The docs are dogfooded on the real library: dist/index.html is the framework-free core from halftone-kit/core/ inlined by tools/build-standalone.mjs.
Tip
Try the topbar: ☀ toggles the designed light mode, ◐ opens the OKLCH wheel (drag the ring — the whole site rethemes), ▦ opens the global grain dials, and reroll reprints every surface from a new seed.
There's no npm package, by design. Halftone UI ships as source you copy into your own repo — shadcn/dither-ui style — so you own the code instead of pinning a version.
# framework-free core (zero deps)
npx degit ecgang/halftone-ui/halftone-kit/core your-app/src/halftone/core
# + React adapter (needs core beside it — react/ imports '../core')
npx degit ecgang/halftone-ui/halftone-kit/react your-app/src/halftone/react
npx degit ecgang/halftone-ui/halftone-kit/core your-app/src/halftone/core
# + Vue adapter (same requirement — vue/ imports '../core' too)
npx degit ecgang/halftone-ui/halftone-kit/vue your-app/src/halftone/vue
npx degit ecgang/halftone-ui/halftone-kit/core your-app/src/halftone/corereact/ and vue/ are thin adapters over core/ — they always import it as ../core, so it has to live one directory up from wherever you drop the adapter folder.
import { createPressContext, press } from './halftone/core/index.js'
const ctx = createPressContext()
press(document.querySelector('canvas'), {
field: (u, v) => (v > 0.6 ? 1 : 0), // tone 0..1; u,v normalized, v=0 top
}, ctx)import { HalftoneProvider, Surface } from './halftone/react/index.js'
export default function App() {
return (
<HalftoneProvider>
<Surface field={(u, v) => (v > 0.6 ? 1 : 0)} style={{ height: 160 }} />
</HalftoneProvider>
)
}The adapter is .jsx — your own bundler (Vite, webpack, Next) compiles it like any other component in your app.
import { h } from 'vue'
import { HalftoneProvider, Surface } from './halftone/vue/index.js'
export default {
render() {
return h(HalftoneProvider, () =>
h(Surface, { field: (u, v) => (v > 0.6 ? 1 : 0), style: 'height:160px' }),
)
},
}Plain .js render functions (h()) — no SFC, nothing to compile, drop it straight into a Vite/Nuxt build.
| Component | Wraps (real DOM / a11y) |
|---|---|
Surface |
the base pressed canvas — bring your own semantic wrapper |
Text |
pair with your own visually-hidden heading |
Image |
pair with your own visually-hidden <img alt> |
Button |
a real <button> |
Meter |
a real <progress> |
Card |
a real container element (div by default, as to change it) |
BarChart |
a real <table> with <caption> |
LineChart |
a real <table> with <caption> |
Every canvas is aria-hidden decoration — the table above is where the actual semantics live.
Full API + prop reference: halftone-kit/README.md. Play with every screen and dial live in Studio.
- 88 documented sections — primitives (switch, slider, OTP field, dialogs, menus, combobox), charts (line, pie, radar, area, bars, heatmap, donut, sparkline), and whole page examples (dashboard, pricing, billing, sign-in flows)
- Four halftone screens —
hatch(crosshatch, the default),stipple(stochastic/FM),lines(line screen),waves. Every pressed example carries a picker: re-press it live. Swap the screen and the whole page changes character — the tone field underneath never moves - A real four-plate press — CMYK separation at the true process screen angles (C 15°, M 75°, Y 0°, K 45°), with plate order and adjustable misregistration
- Global grain dials — one
▦control rescales the screen, the ink weight and the washes across every surface on the page at once - Real light & dark themes — a designed print-shop-cream light mode and an archival-black dark mode, not an inversion filter
- OKLCH hue wheel — drag a ring in the topbar and every pigment rotates through OKLCH hue space live; neutrals stay neutral
- Image halftoning — any raster becomes tone: luminance drives the dot field, so photos re-print in the current ink
- Smudge 😈 — the resident ink imp, pressed from the same engine as everything else
- Zero dependencies, one file — no build step, no CDN, works from
file:// - Accessible — native elements underneath (
<dialog>,<details>, real inputs),prefers-reduced-motionrespected
![]() |
![]() |
![]() |
![]() |
- Format and spirit: dither-ui
- Crosshatch inspiration: Texturelabs' Retratone halftone technique
- Engine, docs, and Smudge: built with Claude Code
MIT © Eric Gang



