Skip to content
Skip to Content
DocsDither Canvas
Dither Canvas

Dither Canvas

A video becomes a bright blue and cyan dither texture on white, with fluid distortion that follows your pointer.

Move across the canvas to disturb the texture.

Component preview
Dither Canvas

Install using CLI

npx shadcn@latest add "https://www.obsidianui.dev/r/dither-canvas.json"

Usage

Code
"use client"; import { DitherCanvas } from "@/components/block/dither-canvas"; export default function Demo() { return <DitherCanvas className="h-[400px] w-full rounded-lg" />; }

Install Manually

1

Install dependencies

npm install clsx tailwind-merge
2

Copy the source code

Copy into components/block/dither-canvas.jsx

"use client"; import { useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; const FC = 80; const FR = 60; const FN = FC * FR; const CC = 110; const EDGE_LO = 36; const EDGE_HI = 130; const EDGES = [".", ",", "=", "+", "-"]; const BRIGHTS = [..."OBSIDIANUI"]; const ALL_CHARS = [...EDGES, ...BRIGHTS]; const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5]; const TL = 320; const TS = 10; const TM = 72; const TRAIL_CFG = { fb: 0.08, fss: 18, ffm: 0.15, fir: 0.8, firl: 1.0 }; const VS = `#version 300 es in vec2 a_pos; void main(){ gl_Position = vec4(a_pos, 0, 1); }`; const FS = `#version 300 es precision highp float; uniform sampler2D uVideo, uFluid, uAtlas; uniform vec2 uRes; uniform int uPhase, uTrailN; uniform vec4 uTP[${TM}]; uniform float uTL[${TM}]; out vec4 O; const float CC = ${CC}.0, FC = ${FC}.0, FR = ${FR}.0; const float EL = ${EDGE_LO}.0, EH = ${EDGE_HI}.0; const int BAYER[16] = int[16](${BAYER.map((v) => Math.round((v / 16) * 255)).join(",")}); const int CHAR_N = ${ALL_CHARS.length}; void main(){ float cw = uRes.x / CC; float rows = ceil(uRes.y / cw) + 1.0; float gx = floor(gl_FragCoord.x / cw); float gy = floor((uRes.y - gl_FragCoord.y) / cw); if(gx >= CC || gy >= rows) discard; vec2 cp = vec2(fract(gl_FragCoord.x / cw), fract((uRes.y - gl_FragCoord.y) / cw)); vec2 bp = vec2((gx + 0.5) * cw, (gy + 0.5) * cw); ivec2 fc = ivec2(gx / CC * FC, gy / rows * FR); fc = clamp(fc, ivec2(0), ivec2(int(FC)-1, int(FR)-1)); vec2 flow = texelFetch(uFluid, fc, 0).rg; vec2 disp = vec2(0.0); for(int i = 0; i < uTrailN; i++){ float life = uTL[i]; if(life <= 0.0) continue; vec2 d = bp - uTP[i].xy; float dist = length(d); float r = 5.0 + life * 3.0; if(dist == 0.0 || dist > r) continue; float f = pow(1.0 - dist / r, 2.0); disp += (d / dist) * f * life * 3.0 + uTP[i].zw * f * 0.04; } vec2 sp = bp + disp + flow * 6.0; vec2 uv = clamp(sp / uRes, 0.0, 1.0); vec3 vc = texture(uVideo, uv).rgb; // Retain the detail in saturated footage without turning dark frames into a solid field. float signal = max(vc.r, max(vc.g, vc.b)); float bg = smoothstep(0.035, 0.7, signal) * 255.0; float hm = min(1.0, length(flow) * 1.1); float gray = bg * (1.0 - hm) + (255.0 - bg) * hm; float thr = float(BAYER[(int(gy) & 3) * 4 + (int(gx) & 3)]); bool invDark = hm > 0.05 && bg > thr && gray <= thr; bool lit = gray > thr; if(!lit && !invDark) discard; float pg = invDark ? bg : gray; int ci; if(pg >= EL && pg <= EH) ci = uPhase % 5; else if(pg > EH) ci = 5 + uPhase % ${BRIGHTS.length}; else discard; float au = (float(ci) + cp.x) / float(CHAR_N); float ca = texture(uAtlas, vec2(au, cp.y)).a; if(ca < 0.05) discard; vec3 blue = vec3(0.145, 0.388, 0.922); vec3 cyan = vec3(0.02, 0.64, 0.88); vec3 violet = vec3(0.36, 0.29, 0.95); float tint = smoothstep(0.15, 0.9, uv.x * 0.6 + uv.y * 0.4); vec3 col = mix(blue, cyan, tint); col = mix(col, violet, hm * 0.65); float a = (invDark ? 0.85 : 1.0) * ca; O = vec4(col * a, a); }`; function createFluid() { const vx = new Float32Array(FN); const vy = new Float32Array(FN); const vx0 = new Float32Array(FN); const vy0 = new Float32Array(FN); const p = new Float32Array(FN); const div = new Float32Array(FN); const fi = (x, y) => Math.max(0, Math.min(FR - 1, y)) * FC + Math.max(0, Math.min(FC - 1, x)); const bnd = (b, a) => { for (let x = 1; x < FC - 1; x++) { a[fi(x, 0)] = b === 2 ? -a[fi(x, 1)] : a[fi(x, 1)]; a[fi(x, FR - 1)] = b === 2 ? -a[fi(x, FR - 2)] : a[fi(x, FR - 2)]; } for (let y = 1; y < FR - 1; y++) { a[fi(0, y)] = b === 1 ? -a[fi(1, y)] : a[fi(1, y)]; a[fi(FC - 1, y)] = b === 1 ? -a[fi(FC - 2, y)] : a[fi(FC - 2, y)]; } }; const diffuse = (b, d, s, diff, dt) => { const a = dt * diff * FN; for (let k = 0; k < 4; k++) { for (let y = 1; y < FR - 1; y++) for (let x = 1; x < FC - 1; x++) { d[fi(x, y)] = (s[fi(x, y)] + a * (d[fi(x - 1, y)] + d[fi(x + 1, y)] + d[fi(x, y - 1)] + d[fi(x, y + 1)])) / (1 + 4 * a); } bnd(b, d); } }; const advect = (b, d, d0, ux, uy, dt) => { const dtx = dt * FC * 1.4; const dty = dt * FR * 1.4; for (let y = 1; y < FR - 1; y++) for (let x = 1; x < FC - 1; x++) { const px = Math.max(0.5, Math.min(FC - 1.5, x - dtx * ux[fi(x, y)])); const py = Math.max(0.5, Math.min(FR - 1.5, y - dty * uy[fi(x, y)])); const x0 = Math.floor(px); const y0 = Math.floor(py); const s1 = px - x0; const s0 = 1 - s1; const t1 = py - y0; const t0 = 1 - t1; d[fi(x, y)] = s0 * (t0 * d0[fi(x0, y0)] + t1 * d0[fi(x0, y0 + 1)]) + s1 * (t0 * d0[fi(x0 + 1, y0)] + t1 * d0[fi(x0 + 1, y0 + 1)]); } bnd(b, d); }; const project = (ux, uy) => { const hx = 1 / FC; const hy = 1 / FR; for (let y = 1; y < FR - 1; y++) for (let x = 1; x < FC - 1; x++) { div[fi(x, y)] = -0.5 * (hx * (ux[fi(x + 1, y)] - ux[fi(x - 1, y)]) + hy * (uy[fi(x, y + 1)] - uy[fi(x, y - 1)])); p[fi(x, y)] = 0; } bnd(0, div); bnd(0, p); for (let k = 0; k < 4; k++) { for (let y = 1; y < FR - 1; y++) for (let x = 1; x < FC - 1; x++) { p[fi(x, y)] = (div[fi(x, y)] + p[fi(x - 1, y)] + p[fi(x + 1, y)] + p[fi(x, y - 1)] + p[fi(x, y + 1)]) / 4; } bnd(0, p); } for (let y = 1; y < FR - 1; y++) for (let x = 1; x < FC - 1; x++) { ux[fi(x, y)] -= 0.5 * (p[fi(x + 1, y)] - p[fi(x - 1, y)]) / hx; uy[fi(x, y)] -= 0.5 * (p[fi(x, y + 1)] - p[fi(x, y - 1)]) / hy; } bnd(1, ux); bnd(2, uy); }; return { vx, vy, fi, step() { diffuse(1, vx0, vx, 0.00002, 0.016); diffuse(2, vy0, vy, 0.00002, 0.016); project(vx0, vy0); advect(1, vx, vx0, vx0, vy0, 0.016); advect(2, vy, vy0, vx0, vy0, 0.016); project(vx, vy); for (let i = 0; i < FN; i++) { vx[i] *= 0.94; vy[i] *= 0.94; } }, }; } /** * The original video dither and fluid simulation, scoped to its own surface. * @param {{ videoSrc?: string, imageSrc?: string, className?: string, style?: import("react").CSSProperties, paused?: boolean }} props */ export function DitherCanvas({ videoSrc = "https://www.obsidianui.dev/effects/dither-canvas/dither-canvas-video.mp4", imageSrc = "https://www.obsidianui.dev/effects/dither-canvas/dither-canvas-poster.webp", className, style, paused = false, } = {}) { const ref = useRef(null); const videoRef = useRef(null); useEffect(() => { const canvas = ref.current; const video = videoRef.current; if (!canvas || !video) return; canvas.style.opacity = "0"; canvas.style.pointerEvents = "none"; let gl; try { gl = canvas.getContext("webgl2", { alpha: false, antialias: false }); } catch { return; } if (!gl) return; const textures = []; const shaders = []; const buffers = []; let prog; let rafId = 0; let disposed = false; let observer; const listeners = []; const motion = window.matchMedia("(prefers-reduced-motion: reduce)"); const isStatic = () => paused || motion.matches; const listen = (target, event, handler) => { target.addEventListener(event, handler); listeners.push(() => target.removeEventListener(event, handler)); }; const cleanup = () => { if (disposed) return; disposed = true; cancelAnimationFrame(rafId); observer?.disconnect(); listeners.forEach((remove) => remove()); video.pause(); textures.forEach((texture) => gl.deleteTexture(texture)); buffers.forEach((buffer) => gl.deleteBuffer(buffer)); shaders.forEach((shader) => gl.deleteShader(shader)); if (prog) gl.deleteProgram(prog); }; const fallback = () => { if (disposed) return; canvas.style.opacity = "0"; canvas.style.pointerEvents = "none"; cleanup(); }; try { const mkShader = (type, source) => { const shader = gl.createShader(type); if (!shader) throw new Error("Shader allocation failed."); shaders.push(shader); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error("Shader compilation failed."); } return shader; }; const mkTex = (unit) => { const tex = gl.createTexture(); if (!tex) throw new Error("Texture allocation failed."); textures.push(tex); gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, tex); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); return tex; }; prog = gl.createProgram(); if (!prog) throw new Error("Program allocation failed."); gl.attachShader(prog, mkShader(gl.VERTEX_SHADER, VS)); gl.attachShader(prog, mkShader(gl.FRAGMENT_SHADER, FS)); gl.linkProgram(prog); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { throw new Error("Shader linking failed."); } gl.useProgram(prog); const loc = (name) => gl.getUniformLocation(prog, name); const buf = gl.createBuffer(); if (!buf) throw new Error("Buffer allocation failed."); buffers.push(buf); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW); const aPos = gl.getAttribLocation(prog, "a_pos"); gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); const videoTex = mkTex(0); const fluidTex = mkTex(1); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, videoTex); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); const atlasCanvas = document.createElement("canvas"); const CELL = 64; atlasCanvas.width = CELL * ALL_CHARS.length; atlasCanvas.height = CELL; const actx = atlasCanvas.getContext("2d"); if (!actx) throw new Error("Character atlas unavailable."); actx.font = `${CELL * 0.92}px monospace`; actx.textAlign = "center"; actx.textBaseline = "middle"; actx.fillStyle = "#fff"; ALL_CHARS.forEach((char, index) => actx.fillText(char, CELL * (index + 0.5), CELL * 0.5)); mkTex(2); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlasCanvas); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.uniform1i(loc("uVideo"), 0); gl.uniform1i(loc("uFluid"), 1); gl.uniform1i(loc("uAtlas"), 2); const fluid = createFluid(); const fluidData = new Float32Array(FN * 2); const mouse = { x: -9999, y: -9999, vx: 0, vy: 0 }; const trail = []; const now = () => performance.now(); const onMove = (event) => { if (isStatic()) return; const rect = canvas.getBoundingClientRect(); const px = mouse.x; const py = mouse.y; mouse.x = event.clientX - rect.left; mouse.y = event.clientY - rect.top; mouse.vx = mouse.x - px; mouse.vy = mouse.y - py; if (px < 0 || py < 0) { trail.unshift({ x: mouse.x, y: mouse.y, vx: 0, vy: 0, b: now() }); if (trail.length > TM) trail.length = TM; return; } const d = Math.hypot(mouse.vx, mouse.vy); if (d < 0.5) return; const steps = Math.max(1, Math.ceil(d / TS)); const birth = now(); for (let s = 1; s <= steps; s++) { const t = s / steps; trail.unshift({ x: px + mouse.vx * t, y: py + mouse.vy * t, vx: mouse.vx / steps, vy: mouse.vy / steps, b: birth }); if (trail.length > TM) trail.length = TM; } }; listen(canvas, "pointermove", onMove); listen(canvas, "pointerleave", () => { mouse.x = mouse.y = -9999; }); listen(canvas, "webglcontextlost", (event) => { event.preventDefault(); fallback(); }); let W = 1; let H = 1; const resize = () => { const rect = canvas.getBoundingClientRect(); W = canvas.width = Math.max(1, Math.round(rect.width)); H = canvas.height = Math.max(1, Math.round(rect.height)); gl.viewport(0, 0, W, H); }; resize(); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); const uTP = loc("uTP"); const uTLoc = loc("uTL"); const uRes = loc("uRes"); const uPhase = loc("uPhase"); const uTrailN = loc("uTrailN"); const tpBuf = new Float32Array(TM * 4); const tlBuf = new Float32Array(TM); let phase = 0; let frame = 0; const draw = () => { if (video.readyState < 2 || disposed) return; const ts = now(); const { fb, fss, ffm, fir, firl } = TRAIL_CFG; for (let i = trail.length - 1; i >= 0; i--) { const pt = trail[i]; const age = ts - pt.b; if (age >= TL) { trail.splice(i, 1); continue; } const life = 1 - age / TL; const radius = fir + life * firl; const gr = Math.ceil(radius); const speed = Math.hypot(pt.vx, pt.vy); const force = (fb + Math.min(speed, fss) / fss) * life; const cx = ((pt.x / W) * FC) | 0; const cy = ((pt.y / H) * FR) | 0; for (let dy = -gr; dy <= gr; dy++) for (let dx = -gr; dx <= gr; dx++) { const dist = Math.hypot(dx, dy); if (dist > radius) continue; const f = (1 - dist / radius) ** 2; fluid.vx[fluid.fi(cx + dx, cy + dy)] += pt.vx * f * force * ffm; fluid.vy[fluid.fi(cx + dx, cy + dy)] += pt.vy * f * force * ffm; } } fluid.step(); if (!isStatic() && frame++ % 8 === 0) phase = (phase + 1) % 255; gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, videoTex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video); for (let i = 0; i < FN; i++) { fluidData[i * 2] = fluid.vx[i]; fluidData[i * 2 + 1] = fluid.vy[i]; } gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, fluidTex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG32F, FC, FR, 0, gl.RG, gl.FLOAT, fluidData); tpBuf.fill(0); tlBuf.fill(0); for (let i = 0; i < trail.length; i++) { const pt = trail[i]; tpBuf[i * 4] = pt.x; tpBuf[i * 4 + 1] = pt.y; tpBuf[i * 4 + 2] = pt.vx; tpBuf[i * 4 + 3] = pt.vy; tlBuf[i] = 1 - (ts - pt.b) / TL; } gl.uniform4fv(uTP, tpBuf); gl.uniform1fv(uTLoc, tlBuf); gl.uniform1i(uTrailN, trail.length); gl.uniform2f(uRes, W, H); gl.uniform1i(uPhase, phase); gl.clearColor(1, 1, 1, 1); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); canvas.style.opacity = "1"; canvas.style.pointerEvents = "auto"; }; const render = () => { if (disposed) return; try { draw(); } catch { fallback(); return; } if (!isStatic()) rafId = requestAnimationFrame(render); }; const syncMotion = () => { cancelAnimationFrame(rafId); trail.length = 0; if (isStatic()) video.pause(); else video.play().catch(fallback); render(); }; observer = new ResizeObserver(() => { resize(); if (isStatic()) render(); }); observer.observe(canvas.parentElement); listen(video, "loadeddata", () => { if (isStatic()) render(); }); listen(video, "error", fallback); listen(motion, "change", syncMotion); syncMotion(); } catch { fallback(); } return cleanup; }, [videoSrc, paused]); return ( <div className={cn("relative h-[28rem] w-full overflow-hidden bg-white", className)} style={style}> <div role="img" aria-label="Blue and cyan dither texture on white" className="pointer-events-none absolute inset-0" style={{ backgroundImage: "linear-gradient(135deg, transparent 12%, #2563eb 35%, #0aa3e0 55%, #5c4af2 72%, transparent 90%)", maskImage: "radial-gradient(circle, #000 1px, transparent 1.3px)", maskSize: "7px 7px", }} /> <video ref={videoRef} src={videoSrc} poster={imageSrc} aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full object-fill opacity-0" crossOrigin="anonymous" preload="auto" muted loop playsInline /> <canvas ref={ref} aria-hidden="true" className="absolute inset-0 h-full w-full opacity-0" /> </div> ); }
3

Add the supporting file

Copy into lib/utils.ts

import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }

Default demo media loads from ObsidianUI. Replace these URLs with your own assets for offline use.

Preview behavior

Move across the canvas to disturb the texture. The effect stays inside its container and respects reduced motion preferences.

Last updated on