Dither Gradient
An animated dithered gradient background rendered on a 2D canvas, using Bayer-matrix ordered dithering to band a slowly-shifting three-color gradient into a retro pixel texture; colors derive from semantic tokens by default.
Preview
<div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border text-primary">
<x-ui.dither-gradient />
</div>
Installation
php artisan ui:add dither-gradient
Note
This component ships an Alpine behavior module at
resources/js/ui/dither-gradient.js. Import it once from your bundle so it registers on alpine:init:
import './dither-gradient.js';
Registry contract
php artisan ui:add dither-gradient
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/dither-gradient.blade.php -
resources/js/ui/dither-gradient.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Dither Gradient (`dither-gradient`)
An animated dithered gradient background rendered on a 2D canvas, using Bayer-matrix ordered dithering to band a slowly-shifting three-color gradient into a retro pixel texture; colors derive from semantic tokens by default.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add dither-gradient
```
## Usage
```blade
<div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border text-primary">
<x-ui.dither-gradient />
</div>
```
## Props
- `colorFrom` (mixed|null, default `null`) — Explicit first gradient stop; when null it's derived at runtime from the --primary token.
- `colorMid` (mixed|null, default `null`) — Explicit middle gradient stop; when null it's derived at runtime from the --accent token.
- `colorTo` (mixed|null, default `null`) — Explicit last gradient stop; when null it's derived at runtime from the --background token.
- `dither` (float, default `0.15`) — Dither intensity (0-1): how strongly the Bayer threshold perturbs the quantized colour bands.
- `speed` (int, default `3`) — Animation speed of the gradient drift; near 0 is nearly still.
- `gradientAngle` (int, default `45`) — Gradient direction in degrees, CSS convention (0 = up).
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a retro, textured animated gradient background instead of a smooth CSS gradient.
- Wanting a background that stays on-theme automatically by deriving its colour stops from semantic tokens.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a smooth, noise-free animated gradient; use mesh-gradient instead, which blends colours without dithering.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:dither-gradient>` tag (or `<x-ui.dither-gradient>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/dither-gradient
- Registry JSON (files, props, contract): https://brokui.dev/r/open/dither-gradient.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| colorFrom | mixed | null | null | Explicit first gradient stop; when null it's derived at runtime from the --primary token. |
| colorMid | mixed | null | null | Explicit middle gradient stop; when null it's derived at runtime from the --accent token. |
| colorTo | mixed | null | null | Explicit last gradient stop; when null it's derived at runtime from the --background token. |
| dither | float | 0.15 | Dither intensity (0-1): how strongly the Bayer threshold perturbs the quantized colour bands. |
| speed | int | 3 | Animation speed of the gradient drift; near 0 is nearly still. |
| gradientAngle | int | 45 | Gradient direction in degrees, CSS convention (0 = up). |
Slots
default— Not used by this component; present for attribute-bag compatibility.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Evaluates a drifting three-stop gradient on a small offscreen cell buffer each frame, quantizes it into bands and perturbs the quantization with an 8x8 Bayer ordered-dither matrix, then upscales the buffer to fill the canvas with smoothing disabled.
- Colour stops default to the primary/accent/background tokens resolved at runtime unless colorFrom/colorMid/colorTo override them.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.
Guidance
Support brand expression or a showcase moment.
Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a retro, textured animated gradient background instead of a smooth CSS gradient.
- Wanting a background that stays on-theme automatically by deriving its colour stops from semantic tokens.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a smooth, noise-free animated gradient; use mesh-gradient instead, which blends colours without dithering.
Use instead
- Static content-first presentation
Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The host is aria-hidden and pointer-events-none.
- Reduced motion renders a single static dithered frame with no animation loop, and the loop also pauses via IntersectionObserver while scrolled out of view.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border text-primary">
<x-ui.dither-gradient />
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Gradient stop colors. Any CSS color string (hex, rgb, oklch, …). When null
// the stop is derived at runtime from semantic tokens (--primary / --accent /
// --background) read off the root element, so the gradient follows the active
// theme. Passing a value puts it ONLY into the Alpine JS config below — never
// into a style attribute — so default markup stays free of raw colors.
'colorFrom' => null,
'colorMid' => null,
'colorTo' => null,
// Dither intensity (0..1): how strongly the Bayer threshold perturbs the
// quantized bands. 0 = clean banding, higher = more visible dot texture.
'dither' => 0.15,
// Animation speed of the gradient drift (higher = faster). 0-ish = nearly still.
'speed' => 3,
// Gradient direction in degrees (CSS-like: 0 = up, 45 = up-right).
'gradientAngle' => 45,
])
@php
$clean = fn ($c) => is_string($c) && trim($c) !== '' ? trim($c) : null;
$config = [
// null => behavior layer derives this stop from semantic tokens.
'colorFrom' => $clean($colorFrom),
'colorMid' => $clean($colorMid),
'colorTo' => $clean($colorTo),
'dither' => max(0, min(1, (float) $dither)),
'speed' => max(0, (float) $speed),
'gradientAngle' => (float) $gradientAngle,
];
@endphp
{{-- Decorative fill layer. The host is aria-hidden and pointer-events-none so it
never traps focus or blocks content layered above it — it is a purely
ambient background. Gradient stops default to semantic tokens (primary /
accent / background), so the effect follows the active theme; no raw color
ever appears in markup. Reduced motion renders one static dithered frame
with no animation loop. --}}
<div
data-slot="dither-gradient"
x-data="uiDitherGradient(@js($config))"
x-ref="root"
aria-hidden="true"
{{ $attributes->merge(['class' => 'pointer-events-none absolute inset-0 text-primary']) }}
>
<canvas x-ref="canvas" class="block h-full w-full"></canvas>
</div>
/**
* Dither Gradient behavior (animated ordered-dithered gradient background).
*
* A single <canvas> fills the parent box. Each frame we evaluate a linear
* gradient (direction `gradientAngle`) between three color stops (from → mid →
* to) whose mix point slowly drifts with time, then quantize it into a small
* number of banded steps. Ordered dithering with an 8x8 Bayer threshold matrix
* perturbs the per-pixel quantization so the banding breaks up into the
* characteristic retro dither texture instead of hard stripes — pure canvas-2D,
* no WebGL.
*
* PERFORMANCE: we never write the gradient per device pixel. Instead we render
* into a small ImageData buffer of fixed "dither cells" (CELL px each, capped to
* a max grid) and let the canvas upscale it crisply (imageSmoothing off). That
* keeps per-frame work bounded (a few thousand cells) regardless of box/DPR
* size, so the effect stays cheap on large or high-density screens. The buffer
* grid is rebuilt by a ResizeObserver when the box changes.
*
* COLOR is SEMANTIC: when a stop is null we derive it at runtime from the root's
* tokens — `--primary`, `--accent` and `--background` (falling back to the
* computed `color`/`--foreground`) — so the gradient follows the active theme
* rather than hard-coded values. Explicit stops passed in config override this.
*
* a11y: the host is aria-hidden and pointer-events-none (set in Blade); purely
* decorative. Reduced motion: a single static dithered frame is painted (time
* frozen) with no animation loop / rAF.
*
* Self-registers on `alpine:init` so import order does not matter.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
// 8x8 Bayer ordered-dither threshold matrix (values 0..63). Normalized to
// (-0.5..0.5) at use so it nudges the quantization symmetrically.
const BAYER8 = [
0, 32, 8, 40, 2, 34, 10, 42,
48, 16, 56, 24, 50, 18, 58, 26,
12, 44, 4, 36, 14, 46, 6, 38,
60, 28, 52, 20, 62, 30, 54, 22,
3, 35, 11, 43, 1, 33, 9, 41,
51, 19, 59, 27, 49, 17, 57, 25,
15, 47, 7, 39, 13, 45, 5, 37,
63, 31, 55, 23, 61, 29, 53, 21,
];
const BAYER_N = 8;
const BAYER_SIZE = BAYER_N * BAYER_N; // 64
window.Alpine.data('uiDitherGradient', (config = {}) => ({
colorFrom: typeof config.colorFrom === 'string' ? config.colorFrom : null,
colorMid: typeof config.colorMid === 'string' ? config.colorMid : null,
colorTo: typeof config.colorTo === 'string' ? config.colorTo : null,
dither: Math.max(0, Math.min(1, config.dither ?? 0.15)),
speed: Math.max(0, config.speed ?? 3),
gradientAngle: config.gradientAngle ?? 45,
_ctx: null,
_img: null,
_gw: 0, // buffer grid width in cells
_gh: 0, // buffer grid height in cells
_stops: [[0, 0, 0], [0, 0, 0], [0, 0, 0]], // resolved RGB stops
_dir: { x: 0, y: 0 }, // gradient direction unit vector
_t0: 0,
_raf: null,
_ro: null,
_reduced: false,
_viewObserver: null,
_offscreen: false,
// Number of quantization bands. Fewer bands → chunkier, more dithered.
_bands: 6,
// Target cell size (px) and the max cells per axis (perf ceiling).
_cell: 4,
_maxCells: 220,
init() {
const canvas = this.$refs.canvas;
this._ctx = canvas.getContext('2d');
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._t0 = performance.now();
this.computeDirection();
this.build();
if (this._reduced) {
// Static frame only: render at a frozen time, no rAF.
this.render(0);
return;
}
let pending = false;
this._ro = new ResizeObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
this.build();
});
});
this._ro.observe(this.$refs.root);
this._raf = requestAnimationFrame(() => this.loop());
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._raf = requestAnimationFrame(() => this.loop());
}
});
this._viewObserver.observe(this.$refs.root);
}
},
// Precompute the gradient direction unit vector from the angle. Uses the
// CSS convention (0deg = upward) so it reads intuitively.
computeDirection() {
const rad = (this.gradientAngle * Math.PI) / 180;
this._dir = { x: Math.sin(rad), y: -Math.cos(rad) };
},
// Resolve a CSS color string from a token into rgb. Tries the raw value
// first, then common wrappers (oklch / rgb) for bare channel lists.
resolveToken(name, root) {
const raw = getComputedStyle(root).getPropertyValue(name).trim();
if (raw === '') return null;
const probe = document.createElement('span');
probe.style.color = raw;
if (probe.style.color === '') {
probe.style.color = `oklch(${raw})`;
if (probe.style.color === '') probe.style.color = `rgb(${raw})`;
}
if (probe.style.color === '') return null;
document.body.appendChild(probe);
const resolved = getComputedStyle(probe).color;
document.body.removeChild(probe);
return resolved && resolved !== '' ? resolved : null;
},
// Parse any CSS color string into [r,g,b] via the 2D context (handles
// hex, rgb, hsl, oklch, named colors).
toRgb(color) {
const c = this._ctx;
c.save();
c.fillStyle = 'CanvasText';
c.fillStyle = color;
const computed = c.fillStyle;
c.restore();
if (computed.startsWith('#')) {
const hex = computed.slice(1);
const full = hex.length === 3 ? hex.split('').map((h) => h + h).join('') : hex;
return [
parseInt(full.slice(0, 2), 16),
parseInt(full.slice(2, 4), 16),
parseInt(full.slice(4, 6), 16),
];
}
const m = computed.match(/(\d+(?:\.\d+)?)/g);
return m ? [Number(m[0]), Number(m[1]), Number(m[2])] : [0, 0, 0];
},
// Build the three RGB stops: explicit config colors where given, else
// derived from semantic tokens (primary → accent → background), falling
// back to the root's computed color / foreground so a stop is always set.
resolveStops() {
const root = this.$refs.root;
const current = getComputedStyle(root).color || 'currentColor';
const primary = this.resolveToken('--primary', root) || current;
const accent = this.resolveToken('--accent', root) || primary;
const background =
this.resolveToken('--background', root) ||
this.resolveToken('--foreground', root) ||
current;
const from = this.colorFrom || primary;
const mid = this.colorMid || accent;
const to = this.colorTo || background;
this._stops = [this.toRgb(from), this.toRgb(mid), this.toRgb(to)];
},
// Size the canvas backing store for DPR and (re)build the cell buffer.
build() {
const canvas = this.$refs.canvas;
const root = this.$refs.root;
const dpr = Math.max(1, window.devicePixelRatio || 1);
const cssW = Math.max(1, root.clientWidth);
const cssH = Math.max(1, root.clientHeight);
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
this.resolveStops();
// Cell-grid dimensions, capped so per-frame work stays bounded.
this._gw = Math.max(1, Math.min(this._maxCells, Math.ceil(cssW / this._cell)));
this._gh = Math.max(1, Math.min(this._maxCells, Math.ceil(cssH / this._cell)));
this._img = this._ctx.createImageData(this._gw, this._gh);
// Disable smoothing so the small buffer upscales as crisp blocks —
// reinforcing the chunky retro look rather than blurring the dither.
this._ctx.imageSmoothingEnabled = false;
if (this._reduced) this.render(0);
},
// Interpolate the three RGB stops by t (0..1): from→mid over [0,0.5],
// mid→to over [0.5,1]. Writes into out[0..2].
sampleGradient(t, out) {
const s = this._stops;
let a, b, f;
if (t < 0.5) {
a = s[0]; b = s[1]; f = t * 2;
} else {
a = s[1]; b = s[2]; f = (t - 0.5) * 2;
}
out[0] = a[0] + (b[0] - a[0]) * f;
out[1] = a[1] + (b[1] - a[1]) * f;
out[2] = a[2] + (b[2] - a[2]) * f;
},
// Render one frame at the given time (ms since start). For each cell we
// project its center onto the gradient direction to get a 0..1 position,
// add a slow time drift, quantize into `_bands` steps, then nudge that
// quantization by the Bayer threshold (scaled by `dither`) so the bands
// break into ordered dither texture. Result is blitted upscaled.
render(timeMs) {
const ctx = this._ctx;
if (!ctx || !this._img) return;
const gw = this._gw;
const gh = this._gh;
const data = this._img.data;
const dirX = this._dir.x;
const dirY = this._dir.y;
// Half-diagonal of the projection range so positions normalize 0..1
// regardless of direction (corners reach the extremes).
const span = Math.abs(dirX) * (gw - 1) + Math.abs(dirY) * (gh - 1) || 1;
const offX = dirX < 0 ? gw - 1 : 0;
const offY = dirY < 0 ? gh - 1 : 0;
// Slow drift of the gradient along its axis over time.
const drift = (timeMs / 1000) * this.speed * 0.12;
const bands = this._bands;
const ditherAmt = this.dither;
const rgb = [0, 0, 0];
let i = 0;
for (let y = 0; y < gh; y++) {
for (let x = 0; x < gw; x++) {
// Project the cell onto the gradient axis and normalize to
// 0..1: subtract the axis origin (the corner the direction
// points away from) so the far corner reaches 1.
const proj = dirX * x + dirY * y - dirX * offX - dirY * offY;
const pos = proj / span;
// Animate: a smooth back-and-forth drift so it never "jumps".
let t = pos + 0.5 * (1 + Math.sin(drift + pos * Math.PI));
t = t - Math.floor(t); // wrap into 0..1 for a seamless loop
// Ordered-dither threshold for this cell, centered to (-0.5..0.5).
const bIdx = (y % BAYER_N) * BAYER_N + (x % BAYER_N);
const threshold = BAYER8[bIdx] / BAYER_SIZE - 0.5;
// Quantize into bands, perturbed by the Bayer threshold so the
// step boundary jitters per-cell → dither texture.
let q = Math.floor(t * bands + threshold * ditherAmt * 2 + 0.5) / bands;
if (q < 0) q = 0;
else if (q > 1) q = 1;
this.sampleGradient(q, rgb);
data[i] = rgb[0] | 0;
data[i + 1] = rgb[1] | 0;
data[i + 2] = rgb[2] | 0;
data[i + 3] = 255;
i += 4;
}
}
// Paint the small cell buffer into the top-left (putImageData ignores
// transforms / smoothing), then upscale that gw×gh region over the
// whole canvas with smoothing off so it reads as crisp dither blocks.
ctx.putImageData(this._img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(
ctx.canvas,
0, 0, gw, gh,
0, 0, ctx.canvas.width, ctx.canvas.height,
);
},
loop() {
this.render(performance.now() - this._t0);
this._raf = requestAnimationFrame(() => this.loop());
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
if (this._ro) {
this._ro.disconnect();
this._ro = null;
}
this._img = null;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.4 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0