|
| 1 | +<div align="center"> |
| 2 | + |
| 3 | +# ditherwave |
| 4 | + |
| 5 | +**A tiny WebGL2 dithering primitive for React.** |
| 6 | +Wrap any image, video, or canvas in `<Dither>` — or drop in `<DitheredWaves>` for an animated grainy background. Bayer, halftone, ASCII, or error-diffusion. Live, animated, **under 8kb gzipped**. |
| 7 | + |
| 8 | +[](https://www.npmjs.com/package/ditherwave) |
| 9 | +[](https://bundlephobia.com/package/ditherwave) |
| 10 | +[](./LICENSE) |
| 11 | + |
| 12 | +</div> |
| 13 | + |
| 14 | +--- |
| 15 | + |
| 16 | +## install |
| 17 | + |
| 18 | +```sh |
| 19 | +npm install ditherwave |
| 20 | +# or: pnpm add ditherwave · yarn add ditherwave · bun add ditherwave |
| 21 | +``` |
| 22 | + |
| 23 | +React is an **optional** peer dep — only needed if you import the React components. Zero runtime dependencies otherwise. |
| 24 | + |
| 25 | +### use without React (plain HTML / CSS / JS) |
| 26 | + |
| 27 | +```html |
| 28 | +<canvas id="bg" style="width:100%;height:100vh"></canvas> |
| 29 | + |
| 30 | +<script type="module"> |
| 31 | + import { createDitheredWaves } from 'https://esm.sh/ditherwave/vanilla'; |
| 32 | + createDitheredWaves(document.getElementById('bg'), { |
| 33 | + waveColor: '#39ff14', |
| 34 | + baseColor: '#050605', |
| 35 | + }); |
| 36 | +</script> |
| 37 | +``` |
| 38 | + |
| 39 | +Or via npm + a `<script>` tag (no bundler): |
| 40 | + |
| 41 | +```html |
| 42 | +<canvas id="bg"></canvas> |
| 43 | +<script src="./node_modules/ditherwave/dist/vanilla.umd.global.js"></script> |
| 44 | +<script> |
| 45 | + Dither.createDitheredWaves(document.getElementById('bg'), { /* ...opts */ }); |
| 46 | +</script> |
| 47 | +``` |
| 48 | + |
| 49 | +The `ditherwave/vanilla` entry contains only the framework-free helpers (`createDither`, `createDitheredWaves`, `dither`) and pulls in zero React code. |
| 50 | + |
| 51 | +## use |
| 52 | + |
| 53 | +Two primitives. Pick one. |
| 54 | + |
| 55 | +### `<Dither>` — dither existing content |
| 56 | + |
| 57 | +```tsx |
| 58 | +import { Dither } from 'ditherwave'; |
| 59 | + |
| 60 | +<Dither mode="bayer" palette={['#0f380f', '#9bbc0f']} animate> |
| 61 | + <img src="https://picsum.photos/800" alt="" /> |
| 62 | +</Dither> |
| 63 | +``` |
| 64 | + |
| 65 | +Drop an `<img>`, `<video>`, or `<canvas>` inside and it'll be dithered into the output canvas every frame. Sources update live — video textures via `requestVideoFrameCallback`, canvases every frame. |
| 66 | + |
| 67 | +### `<DitheredWaves>` — animated noise background |
| 68 | + |
| 69 | +```tsx |
| 70 | +import { DitheredWaves } from 'ditherwave'; |
| 71 | + |
| 72 | +<DitheredWaves |
| 73 | + waveColor="#9bbc0f" |
| 74 | + baseColor="#0f380f" |
| 75 | + pixelSize={3} |
| 76 | + colorNum={4} |
| 77 | +/> |
| 78 | +``` |
| 79 | + |
| 80 | +A full-bleed animated fBm-noise pattern, bayer-dithered, quantised to `colorNum` levels per channel. Drop-in hero background. |
| 81 | + |
| 82 | +## api |
| 83 | + |
| 84 | +### `<Dither>` props |
| 85 | + |
| 86 | +| Prop | Type | Default | Notes | |
| 87 | +| ---------------- | ------------------------------------------------- | -------------- | ----- | |
| 88 | +| `mode` | `'bayer' \| 'floyd' \| 'dots' \| 'ascii'` | `'bayer'` | | |
| 89 | +| `resolution` | `number` | `256` | output pixel grid on the short edge | |
| 90 | +| `palette` | `string[]` | `['#0d0c0a','#ece8df']` | 2–8 hex colors | |
| 91 | +| `intensity` | `number` | `1` | 0–1, strength of the pattern | |
| 92 | +| `animate` | `boolean` | `false` | slow drift on the threshold pattern | |
| 93 | +| `matrixSize` | `2 \| 4 \| 8` | `8` | bayer only | |
| 94 | +| `charset` | `string` | `' .:-=+*#%@'` | ascii only, density ramp low→high | |
| 95 | +| `pauseOffscreen` | `boolean` | `true` | uses IntersectionObserver | |
| 96 | +| `fallback` | `ReactNode` | `children` | rendered when WebGL2 isn't available | |
| 97 | + |
| 98 | +### `<DitheredWaves>` props |
| 99 | + |
| 100 | +| Prop | Type | Default | |
| 101 | +| ------------------------ | --------- | ----------- | |
| 102 | +| `mode` | `'bayer' \| 'floyd' \| 'dots' \| 'ascii'` | `'bayer'` | |
| 103 | +| `waveColor` | hex | `'#7e7e7e'` | |
| 104 | +| `baseColor` | hex | `'#000000'` | |
| 105 | +| `waveSpeed` | `number` | `0.05` | |
| 106 | +| `waveFrequency` | `number` | `3` | |
| 107 | +| `waveAmplitude` | `number` | `0.3` | |
| 108 | +| `pixelSize` | `number` | `2` | |
| 109 | +| `colorNum` | `number` | `4` | |
| 110 | +| `matrixSize` | `2 \| 4 \| 8` | `8` | |
| 111 | +| `enableMouseInteraction` | `boolean` | `true` | |
| 112 | +| `mouseRadius` | `number` | `1` | |
| 113 | +| `disableAnimation` | `boolean` | `false` | |
| 114 | + |
| 115 | +Also exported: |
| 116 | + |
| 117 | +- `useDither(canvasRef, sourceRef, options)` — imperative hook. |
| 118 | +- `createDither(target, source, options)` — framework-agnostic; returns `{ destroy, setOptions, render }`. |
| 119 | +- `createDitheredWaves(target, options)` — same, for the noise background. |
| 120 | +- `dither(source, target, options)` — alias for non-React users. |
| 121 | + |
| 122 | +## modes |
| 123 | + |
| 124 | +**bayer** — ordered dithering. Cheap, GPU-friendly, parallelizes perfectly. The Obra Dinn / Gameboy look. |
| 125 | + |
| 126 | +**floyd** — error diffusion. True Floyd-Steinberg is sequential and can't run in a single fragment pass. This mode ships a **Riemersma-style Hilbert-walk approximation** that reads neighbours and mixes a low-discrepancy threshold — it's *not* true FS, and we won't pretend it is. Looks close enough that you'll only notice in A/B. |
| 127 | + |
| 128 | +**dots** — newspaper halftone. Rotated 15° for authentic print feel. Dot radius scales with local darkness. The pretty one. |
| 129 | + |
| 130 | +**ascii** — luminance-indexed glyph atlas built at init. Uses the system monospace stack for ~0 bundle cost; pass your own `charset` for a denser ramp. |
| 131 | + |
| 132 | +## performance |
| 133 | + |
| 134 | +- 60fps on a 2020 MacBook Air at 1080p / resolution 256. |
| 135 | +- Zero per-frame allocations after init. |
| 136 | +- `devicePixelRatio` capped at 2 — dithering at 3× is wasteful. |
| 137 | +- Pauses when scrolled offscreen (IntersectionObserver, on by default). |
| 138 | +- Listens for `webglcontextlost`; reinit on `webglcontextrestored` is your call. |
| 139 | + |
| 140 | +## size |
| 141 | + |
| 142 | +Library core ships ESM, CJS, and UMD with sourcemaps. The full public surface tree-shakes down to under 8kb gzipped. Use `pnpm --filter ditherwave size` to check locally. |
| 143 | + |
| 144 | +## credits |
| 145 | + |
| 146 | +`<DitheredWaves>` is heavily inspired by [reactbits' Dither background](https://reactbits.dev/backgrounds/dither) — same domain-warped fBm noise + Bayer post-process, reimplemented in raw WebGL2 to keep the bundle small and avoid the three.js / postprocessing dependency. If you only need the background and you're already using `@react-three/fiber`, use theirs. |
| 147 | + |
| 148 | +## license |
| 149 | + |
| 150 | +MIT. |
0 commit comments