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

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 = "/cdn/effects/dither-canvas/dither-canvas-video.mp4?v=3", imageSrc = "/cdn/effects/dither-canvas/dither-canvas-poster.webp?v=3", 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)) }

Preview behavior

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

Last updated on