Skip to content
Brok UI

Loading…

No results

Dither Gradient

Open source

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.

Version
v1.0.4
Stability
stable
License
MIT
Related
Mesh Gradient
Gradient Bars
Dot Pattern

Preview

previews.components.dither-gradient.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/dither-gradient.blade.php
  • js resources/js/ui/dither-gradient.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

dither-gradient.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

dither-gradient

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

Expressive and showcase effect

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
root
Theming hooks
dither-gradient

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/dither-gradient.blade.php Blade
@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>
resources/js/ui/dither-gradient.js JS
/**
 * 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