{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "gradient", "type": "registry:component", "title": "Dither Gradient", "description": "Dithered gradient washes for backgrounds — footer glows, section fades, card backdrops. Dissolves to transparent or dither-blends two colours, any direction, with optional bloom. Standalone: installs without the chart engine.", "author": "ripgrim", "categories": [ "gradients" ], "version": "0.1.0", "dependencies": [ "clsx", "tailwind-merge" ], "devDependencies": [], "registryDependencies": [], "files": [ { "path": "components/dither-kit/gradient.tsx", "type": "registry:component", "target": "components/dither-kit/gradient.tsx", "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"./lib\"\nimport { rgb } from \"./palette\"\nimport {\n BAYER4,\n fillOf,\n type PixelBloom,\n type PixelColor,\n pixelBloomStyle,\n} from \"./pixel\"\n\n// Backing-resolution caps — a background wash never needs more cells than this.\nconst MAX_COLS = 960\nconst MAX_ROWS = 600\n\nexport type GradientDirection = \"up\" | \"down\" | \"left\" | \"right\"\n\nexport type DitherGradientProps = {\n /** The colour the gradient starts solid as — a palette name or a hue. */\n from: PixelColor\n /** What it dissolves into: another colour for a two-tone dither blend, or\n * \"transparent\" (default) so the background shows through. */\n to?: PixelColor | \"transparent\"\n /** Where `to` ends up — \"up\" reads as a glow rising from the bottom edge. */\n direction?: GradientDirection\n /** CSS px per dither cell — bigger is chunkier. */\n cell?: number\n /** Overall opacity multiplier. */\n opacity?: number\n /** Glow on the dither fill. */\n bloom?: PixelBloom\n className?: string\n}\n\ntype PaintSpec = {\n from: PixelColor\n to: PixelColor | \"transparent\"\n direction: GradientDirection\n cell: number\n opacity: number\n}\n\n/**\n * Paint the ordered-dither ramp onto a low-res backing canvas sized from the\n * wrapper's box. Static — one paint per prop/size change, no animation loop,\n * so it's free to use as a page-wide background.\n */\nfunction paintGradient(\n canvas: HTMLCanvasElement,\n bloomCanvas: HTMLCanvasElement | null,\n width: number,\n height: number,\n spec: PaintSpec\n): void {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx || width <= 0 || height <= 0) return\n const cols = Math.min(MAX_COLS, Math.max(4, Math.round(width / spec.cell)))\n const rows = Math.min(MAX_ROWS, Math.max(4, Math.round(height / spec.cell)))\n canvas.width = cols\n canvas.height = rows\n\n const fromFill = fillOf(spec.from)\n const toFill = spec.to === \"transparent\" ? null : fillOf(spec.to)\n const o = spec.opacity\n\n for (let y = 0; y < rows; y++) {\n for (let x = 0; x < cols; x++) {\n // t runs 0 at the `from` edge → 1 at the `to` edge.\n const t =\n spec.direction === \"up\"\n ? 1 - (y + 0.5) / rows\n : spec.direction === \"down\"\n ? (y + 0.5) / rows\n : spec.direction === \"left\"\n ? 1 - (x + 0.5) / cols\n : (x + 0.5) / cols\n const density = 1 - t\n const lit = density > BAYER4[y & 3][x & 3]\n if (toFill) {\n // Two-tone: every cell is painted, the dither decides which colour.\n ctx.fillStyle = rgb(lit ? fromFill : toFill, 1, o)\n ctx.fillRect(x, y, 1, 1)\n } else {\n // Dissolve to transparent: lit cells carry the ramp, off cells keep a\n // faint tint that also fades out, so the falloff reads smooth.\n const alpha = (lit ? 0.35 + 0.65 * density : 0.12 * density) * o\n if (alpha <= 0.004) continue\n ctx.fillStyle = rgb(fromFill, 1, alpha)\n ctx.fillRect(x, y, 1, 1)\n }\n }\n }\n\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas && bloomCtx) {\n bloomCanvas.width = cols\n bloomCanvas.height = rows\n bloomCtx.drawImage(canvas, 0, 0)\n }\n}\n\n/**\n * Dithered gradient wash — the charts' ordered-dither texture as a background.\n * Fills its nearest positioned ancestor (footer glows, section fades, card\n * backdrops). Dissolves to transparent by default, or dither-blends between\n * two colours when `to` is set.\n */\nexport function DitherGradient({\n from,\n to = \"transparent\",\n direction = \"up\",\n cell = 3,\n opacity = 1,\n bloom = \"off\",\n className,\n}: DitherGradientProps) {\n const wrapRef = useRef(null)\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n\n useEffect(() => {\n const wrap = wrapRef.current\n const canvas = canvasRef.current\n if (!wrap || !canvas) return\n const paint = () => {\n const box = wrap.getBoundingClientRect()\n paintGradient(canvas, bloomRef.current, box.width, box.height, {\n from,\n to,\n direction,\n cell,\n opacity,\n })\n }\n paint()\n if (typeof ResizeObserver === \"undefined\") return\n const ro = new ResizeObserver(paint)\n ro.observe(wrap)\n return () => ro.disconnect()\n }, [from, to, direction, cell, opacity, bloom])\n\n const bloomStyle = pixelBloomStyle(bloom)\n\n return (\n \n \n {bloomStyle && (\n \n )}\n \n )\n}\n" }, { "path": "components/dither-kit/pixel.ts", "type": "registry:component", "target": "components/dither-kit/pixel.ts", "content": "// Standalone pixel primitives for the non-chart Dither Kit pieces (avatar,\n// gradient). Deliberately free of the chart engine so those items install\n// without `core` — only palette.ts is shared. The Bayer matrix and bloom\n// presets mirror dither-paint.ts so everything reads as one texture.\n\nimport { type DitherColor, PALETTE, type Rgb } from \"./palette\"\n\n// 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds — the same matrix\n// the charts dither with.\nexport const BAYER4 = [\n [0, 8, 2, 10],\n [12, 4, 14, 6],\n [3, 11, 1, 9],\n [15, 7, 13, 5],\n].map((row) => row.map((v) => (v + 0.5) / 16))\n\nexport const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t)\n\n/** 32-bit FNV-1a hash — turns any string seed into a stable uint32. */\nexport function fnv1a(str: string): number {\n let h = 0x811c9dc5\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i)\n h = Math.imul(h, 0x01000193)\n }\n return h >>> 0\n}\n\n/** Tiny deterministic PRNG (xorshift32) — returns floats in [0, 1). */\nexport function xorshift32(seed: number): () => number {\n let s = seed || 0x9e3779b9\n return () => {\n s ^= s << 13\n s >>>= 0\n s ^= s >>> 17\n s ^= s << 5\n s >>>= 0\n return s / 0x100000000\n }\n}\n\n/** A named palette colour or a raw hue (0–360). */\nexport type PixelColor = DitherColor | number\n\n/** Hue (0–360) → an rgb fill tuned to sit alongside the chart palette. */\nexport function hueFill(hue: number): Rgb {\n const h = ((hue % 360) + 360) % 360\n const s = 0.85\n const l = 0.58\n const c = (1 - Math.abs(2 * l - 1)) * s\n const x = c * (1 - Math.abs(((h / 60) % 2) - 1))\n const m = l - c / 2\n const [r, g, b] =\n h < 60\n ? [c, x, 0]\n : h < 120\n ? [x, c, 0]\n : h < 180\n ? [0, c, x]\n : h < 240\n ? [0, x, c]\n : h < 300\n ? [x, 0, c]\n : [c, 0, x]\n return [\n Math.round((r + m) * 255),\n Math.round((g + m) * 255),\n Math.round((b + m) * 255),\n ]\n}\n\n/** Resolve a {@link PixelColor} to its rgb fill. */\nexport function fillOf(color: PixelColor): Rgb {\n return typeof color === \"number\" ? hueFill(color) : PALETTE[color].fill\n}\n\n// Bloom — same recipe as the charts: a blurred copy of the crisp canvas,\n// composited additively so the glow stays in the dither's own colour.\nexport type PixelBloom = \"off\" | \"low\" | \"high\" | \"aura\"\n\nconst BLOOM_PRESET: Record<\n Exclude,\n { blur: number; brightness: number; opacity: number; saturate: number }\n> = {\n low: { blur: 3, brightness: 1.35, opacity: 0.7, saturate: 1.4 },\n high: { blur: 5, brightness: 1.5, opacity: 0.78, saturate: 1.5 },\n aura: { blur: 15, brightness: 2.9, opacity: 0.1, saturate: 3 },\n}\n\nexport type PixelBloomStyle = {\n filter: string\n opacity: number\n mixBlendMode: \"plus-lighter\"\n imageRendering: \"auto\"\n}\n\n/** Style for the bloom layer canvas. null when off. */\nexport function pixelBloomStyle(bloom: PixelBloom): PixelBloomStyle | null {\n if (bloom === \"off\") return null\n const cfg = BLOOM_PRESET[bloom]\n return {\n filter: `blur(${cfg.blur}px) brightness(${cfg.brightness}) saturate(${cfg.saturate})`,\n opacity: cfg.opacity,\n mixBlendMode: \"plus-lighter\",\n imageRendering: \"auto\",\n }\n}\n\n/** Whether the OS asks for reduced motion (skip entrances). */\nexport function pixelPrefersReducedMotion(): boolean {\n return (\n window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches ?? false\n )\n}\n" }, { "path": "components/dither-kit/palette.ts", "type": "registry:component", "target": "components/dither-kit/palette.ts", "content": "// Shared seed palette for the dither chart family. Mirrors the seeds in\n// `dither-chart.tsx` so a series rendered through the composable engine reads\n// with the exact same fill / line / star hues as the legacy sparkline.\n\nexport type Rgb = [number, number, number]\n\nexport type DitherColor =\n | \"green\"\n | \"blue\"\n | \"purple\"\n | \"pink\"\n | \"orange\"\n | \"red\"\n | \"grey\"\n\nexport type Seed = { fill: Rgb; line: Rgb; star: Rgb }\n\n// Each seed: the area-fill hue, the bright series line, and the star sparkle.\nexport const PALETTE: Record = {\n green: { fill: [40, 210, 110], line: [150, 255, 180], star: [200, 255, 220] },\n blue: { fill: [53, 143, 243], line: [150, 200, 255], star: [205, 228, 255] },\n purple: {\n fill: [150, 110, 255],\n line: [200, 175, 255],\n star: [225, 210, 255],\n },\n pink: { fill: [240, 90, 190], line: [255, 170, 220], star: [255, 205, 235] },\n orange: {\n fill: [255, 150, 50],\n line: [255, 195, 130],\n star: [255, 220, 175],\n },\n red: { fill: [240, 70, 70], line: [255, 150, 140], star: [255, 195, 185] },\n // No-data: a muted grey so empty metrics read as \"nothing here\".\n grey: { fill: [92, 92, 100], line: [140, 140, 150], star: [165, 165, 175] },\n}\n\nexport const rgb = ([r, g, b]: Rgb, k = 1, a = 1) =>\n `rgba(${Math.round(r * k)},${Math.round(g * k)},${Math.round(b * k)},${a})`\n\nexport const seedOfColor = (color: DitherColor): Seed => PALETTE[color]\n\nexport const isDitherColor = (value: unknown): value is DitherColor =>\n typeof value === \"string\" && value in PALETTE\n" }, { "path": "components/dither-kit/lib.ts", "type": "registry:component", "target": "components/dither-kit/lib.ts", "content": "import { type ClassValue, clsx } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\n/** Tailwind-aware className combiner — local copy so the chart pack is\n * self-contained and portable as a registry. */\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n" } ] }