Glitch
Broadcast glitch bursts tear this page into shifted slices with RGB splits and corrupted blocks, then let it settle back to a clean read. Tune the timing, tear strength, and noise. This page is the demo.
Demo image
Photos make the tears and RGB splits easy to see. Drop the interval to 0 for a constant broadcast failure.
Install
npx shadcn@latest add @canvas-ui/glitch-reactOr copy the source below into your project. Prefer WGSL? Switch the renderer above to the WebGPU build.
Dependencies
The install command above adds these automatically. If you copy the source by hand, install them yourself.
# No dependencies for the WebGL build.Code
"use client";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
const canvasDimensionLimits = new WeakMap<WebGLRenderingContext | WebGL2RenderingContext, number>();
/** Keep the existing DPR budget, then account for browser pinch zoom. */
function getCanvasPixelRatio(
element: HTMLElement,
context?: WebGLRenderingContext | WebGL2RenderingContext,
): number {
const scale = window.visualViewport?.scale || 1;
const requested = Math.min(window.devicePixelRatio || 1, 2) * scale;
const width = Math.max(element.clientWidth, 1);
const height = Math.max(element.clientHeight, 1);
let maxDimension = 8192;
if (context) {
let limit = canvasDimensionLimits.get(context);
if (limit === undefined) {
limit = Math.min(
maxDimension,
context.getParameter(context.MAX_TEXTURE_SIZE),
context.getParameter(context.MAX_RENDERBUFFER_SIZE),
);
canvasDimensionLimits.set(context, limit);
}
maxDimension = limit;
}
// Rendering the full element preserves its CSS position and native input
// geometry while panning. Bound allocations when zooming large canvases.
return Math.min(
requested,
maxDimension / Math.max(width, height),
Math.sqrt(16_777_216 / (width * height)),
);
}
/** Pinch zoom changes visualViewport without resizing the element's CSS box. */
function createCanvasResizeObserver(onResize: () => void) {
const viewport = window.visualViewport;
let frame = 0;
let disconnected = false;
const schedule = () => {
if (frame || disconnected) return;
frame = requestAnimationFrame(() => {
frame = 0;
onResize();
});
};
const observer = new ResizeObserver(schedule);
window.addEventListener("resize", schedule, { passive: true });
viewport?.addEventListener("resize", schedule, { passive: true });
return {
observe(element: Element, options?: ResizeObserverOptions) {
observer.observe(element, options);
},
unobserve(element: Element) {
observer.unobserve(element);
},
disconnect() {
disconnected = true;
observer.disconnect();
cancelAnimationFrame(frame);
window.removeEventListener("resize", schedule);
viewport?.removeEventListener("resize", schedule);
},
};
}
type HtmlInCanvasContext = CanvasRenderingContext2D & {
drawElementImage: (element: Element, x: number, y: number) => DOMMatrix | void;
};
type HtmlInCanvasElement = HTMLCanvasElement & {
updateElementGeometry?: (
element: Element,
options: { canvasTransform: DOMMatrix },
) => void;
};
/** Configure the capture subtree for both generations of the experimental API. */
function prepareHtmlInCanvas(source: HTMLCanvasElement, content: HTMLElement) {
if ("content" in source) {
source.setAttribute("content", "drawable");
content.setAttribute("drawable", "");
} else {
source.setAttribute("layoutsubtree", "");
}
}
/** Capture pixels and keep the native DOM hit-test region aligned with them. */
function drawHtmlInCanvas(
source: HTMLCanvasElement,
context: CanvasRenderingContext2D,
content: HTMLElement,
) {
const transform = (context as HtmlInCanvasContext).drawElementImage(content, 0, 0);
const canvas = source as HtmlInCanvasElement;
// Chrome 154 decoupled geometry from drawing before enabling automatic 2D
// synchronization. Older versions have no update method; newer versions
// synchronize automatically and return void. Use the returned matrix as-is:
// drawElementImage already accounts for the canvas's pixel density.
if (transform && typeof canvas.updateElementGeometry === "function") {
canvas.updateElementGeometry(content, { canvasTransform: transform });
}
}
export interface GlitchOptions {
/** Overall strength of the glitch (0 to 2). */
intensity?: number;
/** Seconds between glitch bursts. 0 keeps the glitch running constantly. */
interval?: number;
/** How long each burst lasts in seconds. */
duration?: number;
/** Number of horizontal slices the tear snaps to. Lower is chunkier. */
slices?: number;
/** How far the torn slices shift sideways, in CSS pixels. */
shift?: number;
/** Chromatic RGB split during bursts, in CSS pixels. */
rgbShift?: number;
/** Amount of corrupted block artifacts during bursts (0 to 1). */
blocks?: number;
/** Analog noise and scanline flicker during bursts (0 to 1). */
noise?: number;
}
export interface GlitchElements {
/** Canvas with layoutsubtree that hosts the HTML content. */
source: HTMLCanvasElement;
/** The element inside the source canvas that gets captured. */
content: HTMLElement;
/** Canvas the WebGL effect renders to. */
output: HTMLCanvasElement;
}
export interface GlitchInstance {
/** Update effect options live. */
setOptions: (options: GlitchOptions) => void;
/** Fire a glitch burst right now. */
burst: () => void;
/** Re-read canvas size. Call when the element is resized. */
resize: () => void;
/** Stop the loop and release all GPU resources. */
destroy: () => void;
}
const DEFAULTS: Required<GlitchOptions> = {
intensity: 1,
interval: 3,
duration: 0.4,
slices: 24,
shift: 30,
rgbShift: 4,
blocks: 0.5,
noise: 0.35,
};
type PaintableCanvas = HTMLCanvasElement & {
onpaint?: (() => void) | null;
requestPaint?: () => void;
};
type ElementImageContext = CanvasRenderingContext2D & {
drawElementImage?: (element: Element, x: number, y: number) => void;
};
const VERT = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
out vec2 vUv;
void main () {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const FRAG = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uContent;
uniform vec2 uResolution;
uniform float uSeed;
uniform float uAmp;
uniform float uSlices;
uniform float uShift;
uniform float uRgbShift;
uniform float uBlocks;
uniform float uNoise;
uniform float uMaxX;
float hash12 (vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
vec4 page (vec2 p) {
p.x = clamp(p.x, 0.0005, uMaxX - 0.0005);
p.y = clamp(p.y, 0.0005, 0.9995);
return texture(uContent, vec2(p.x, 1.0 - p.y));
}
void main () {
vec2 uv = vUv;
if (uv.x > uMaxX) {
outColor = vec4(0.0);
return;
}
float e = uAmp;
vec2 guv = uv;
if (e > 0.001) {
float band = floor(uv.y * uSlices);
float pick = hash12(vec2(band, uSeed));
float tear = step(1.0 - 0.3 * min(e, 1.0), pick);
float dir = hash12(vec2(band, uSeed + 13.0)) * 2.0 - 1.0;
guv.x += tear * dir * e * uShift / uResolution.x;
float sub = floor(uv.y * uSlices * 7.0);
float micro = hash12(vec2(sub, uSeed + 29.0));
guv.x += (micro - 0.5) * e * uNoise * 3.0 / uResolution.x;
vec2 cell = floor(guv * vec2(10.0, uSlices * 0.5));
float br = hash12(cell + uSeed * 0.0173);
if (br > 1.0 - 0.14 * uBlocks * min(e, 1.0)) {
vec2 jump = vec2(
hash12(cell + uSeed + 3.1) - 0.5,
hash12(cell + uSeed + 7.7) - 0.5
);
guv += jump * vec2(0.08, 0.02) * e;
}
}
float split = uRgbShift * e / uResolution.x;
vec4 c = page(guv);
float r = page(guv + vec2(split, 0.0)).r;
float b = page(guv - vec2(split, 0.0)).b;
vec4 col = vec4(r, c.g, b, c.a);
if (e > 0.001 && uNoise > 0.001) {
float grain = hash12(vUv * uResolution + uSeed * 5.3) - 0.5;
float row = floor(vUv.y * uResolution.y);
float flicker = hash12(vec2(row, uSeed + 41.0));
float lines = step(0.985 - 0.01 * uNoise * e, flicker);
col.rgb += (grain * 0.22 + lines * 0.35) * uNoise * min(e, 1.0) * col.a;
}
outColor = vec4(clamp(col.rgb, 0.0, 1.0) * col.a, col.a);
}`;
export function supportsHtmlInCanvas(): boolean {
if (typeof document === "undefined") return false;
const probe = document.createElement("canvas") as PaintableCanvas;
const ctx = probe.getContext("2d") as ElementImageContext | null;
return Boolean(
ctx &&
typeof ctx.drawElementImage === "function" &&
typeof probe.requestPaint === "function",
);
}
export function createGlitch(
elements: GlitchElements,
options: GlitchOptions = {},
): GlitchInstance | null {
const config = { ...DEFAULTS, ...options };
const { source, content, output } = elements;
const gl = output.getContext("webgl2", {
alpha: true,
depth: false,
stencil: false,
antialias: false,
premultipliedAlpha: true,
});
if (!gl || gl.isContextLost()) return null;
const sourceCtx = source.getContext("2d") as ElementImageContext | null;
const paintable = source as PaintableCanvas;
const htmlInCanvas = Boolean(
sourceCtx &&
typeof sourceCtx.drawElementImage === "function" &&
typeof paintable.requestPaint === "function",
);
if (htmlInCanvas) prepareHtmlInCanvas(source, content);
let contentDirty = false;
let wake = () => {};
if (htmlInCanvas) {
paintable.onpaint = () => {
try {
sourceCtx!.reset();
drawHtmlInCanvas(source, sourceCtx!, content);
contentDirty = true;
wake();
} catch {}
};
}
function compile(type: number, text: string): WebGLShader {
const shader = gl!.createShader(type)!;
gl!.shaderSource(shader, text);
gl!.compileShader(shader);
if (!gl!.getShaderParameter(shader, gl!.COMPILE_STATUS)) {
console.error("Glitch shader error:", gl!.getShaderInfoLog(shader));
}
return shader;
}
const vertexShader = compile(gl.VERTEX_SHADER, VERT);
const fragmentShader = compile(gl.FRAGMENT_SHADER, FRAG);
const program = gl.createProgram()!;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
const uniforms: Record<string, WebGLUniformLocation> = {};
const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < count; i++) {
const info = gl.getActiveUniform(program, i)!;
uniforms[info.name] = gl.getUniformLocation(program, info.name)!;
}
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const contentTexture = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, contentTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 0]),
);
let contentMaxX = 1;
function syncCanvasSize() {
const dpr = getCanvasPixelRatio(output, gl!);
const width = Math.max(1, Math.round(output.clientWidth * dpr));
const height = Math.max(1, Math.round(output.clientHeight * dpr));
if (output.width !== width || output.height !== height) {
output.width = width;
output.height = height;
}
contentMaxX = Math.min(
1,
Math.max(0.05, content.clientWidth / Math.max(output.clientWidth, 1)),
);
if (htmlInCanvas) {
const sourceWidth = Math.max(1, Math.round(source.clientWidth * dpr));
const sourceHeight = Math.max(1, Math.round(source.clientHeight * dpr));
if (source.width !== sourceWidth || source.height !== sourceHeight) {
source.width = sourceWidth;
source.height = sourceHeight;
}
paintable.requestPaint!();
}
}
syncCanvasSize();
function uploadContent() {
if (!htmlInCanvas || !contentDirty) return;
contentDirty = false;
gl!.bindTexture(gl!.TEXTURE_2D, contentTexture);
gl!.texImage2D(
gl!.TEXTURE_2D,
0,
gl!.RGBA,
gl!.RGBA,
gl!.UNSIGNED_BYTE,
source,
);
}
let time = 0;
let burstAt = 0.6;
let burstSeed = 1;
let envelope = 0;
function advanceTimeline(delta: number) {
time += delta;
if (config.interval <= 0) {
envelope = 1;
return;
}
const sinceBurst = time - burstAt;
const duration = Math.max(config.duration, 0.05);
if (sinceBurst >= 0 && sinceBurst < duration) {
const tail = 1 - Math.pow(sinceBurst / duration, 2);
envelope = tail * (0.7 + 0.3 * hash(burstSeed + Math.floor(time * 24)));
} else {
envelope = 0;
if (sinceBurst >= duration) {
burstAt = time + Math.max(config.interval, 0.3) * (0.75 + 0.5 * Math.random());
burstSeed = Math.floor(Math.random() * 1000);
}
}
}
function hash(n: number) {
const s = Math.sin(n * 127.1) * 43758.5453;
return s - Math.floor(s);
}
function render() {
uploadContent();
const dpr = output.width / Math.max(output.clientWidth, 1);
const amp = envelope * Math.max(config.intensity, 0);
gl!.useProgram(program);
gl!.activeTexture(gl!.TEXTURE0);
gl!.bindTexture(gl!.TEXTURE_2D, contentTexture);
gl!.uniform1i(uniforms.uContent, 0);
gl!.uniform2f(uniforms.uResolution, output.width, output.height);
gl!.uniform1f(uniforms.uSeed, Math.floor(time * 24) + burstSeed);
gl!.uniform1f(uniforms.uAmp, amp);
gl!.uniform1f(uniforms.uSlices, Math.max(config.slices, 3));
gl!.uniform1f(uniforms.uShift, Math.max(config.shift, 0) * dpr);
gl!.uniform1f(uniforms.uRgbShift, Math.max(config.rgbShift, 0) * dpr);
gl!.uniform1f(uniforms.uBlocks, Math.min(Math.max(config.blocks, 0), 1));
gl!.uniform1f(uniforms.uNoise, Math.min(Math.max(config.noise, 0), 1));
gl!.uniform1f(uniforms.uMaxX, contentMaxX);
gl!.bindFramebuffer(gl!.FRAMEBUFFER, null);
gl!.viewport(0, 0, output.width, output.height);
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
}
let raf = 0;
let lastTime = performance.now();
let destroyed = false;
let running = false;
let visible = true;
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
let reducedMotion = motionQuery.matches;
function frame(now: number) {
if (destroyed) return;
if (!visible) {
running = false;
return;
}
const delta = Math.min(Math.max((now - lastTime) / 1000, 0), 1 / 30);
lastTime = now;
const wasActive = envelope > 0;
if (!reducedMotion) advanceTimeline(delta);
else envelope = 0;
if (envelope > 0 || wasActive || contentDirty) render();
if (reducedMotion && !contentDirty) {
running = false;
return;
}
raf = requestAnimationFrame(frame);
}
function start() {
if (destroyed || running || !visible) return;
running = true;
lastTime = performance.now();
raf = requestAnimationFrame(frame);
}
wake = start;
start();
function onMotionChange() {
reducedMotion = motionQuery.matches;
start();
}
motionQuery.addEventListener("change", onMotionChange);
const observer = createCanvasResizeObserver(() => {
syncCanvasSize();
start();
});
observer.observe(output);
observer.observe(content);
const intersection = new IntersectionObserver((entries) => {
visible = entries[entries.length - 1]?.isIntersecting ?? true;
if (visible) start();
});
intersection.observe(output);
return {
setOptions(next) {
if (
!Object.entries(next).some(
([key, value]) => config[key as keyof GlitchOptions] !== value,
)
)
return;
Object.assign(config, next);
start();
},
burst() {
burstAt = time;
burstSeed = Math.floor(Math.random() * 1000);
start();
},
resize() {
syncCanvasSize();
start();
},
destroy() {
destroyed = true;
cancelAnimationFrame(raf);
observer.disconnect();
intersection.disconnect();
motionQuery.removeEventListener("change", onMotionChange);
gl!.deleteTexture(contentTexture);
gl!.deleteProgram(program);
gl!.deleteShader(vertexShader);
gl!.deleteShader(fragmentShader);
gl!.deleteBuffer(quad);
if (htmlInCanvas) paintable.onpaint = null;
},
};
}
export interface GlitchProps extends GlitchOptions {
children: ReactNode;
className?: string;
style?: React.CSSProperties;
}
const emptySubscribe = () => () => {};
export function Glitch({ children, className, style, ...options }: GlitchProps) {
const sourceRef = useRef<HTMLCanvasElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const outputRef = useRef<HTMLCanvasElement>(null);
const instanceRef = useRef<GlitchInstance | null>(null);
const [initialOptions] = useState(options);
const [failed, setFailed] = useState(false);
const supported = useSyncExternalStore(
emptySubscribe,
supportsHtmlInCanvas,
() => false,
);
const native = supported && !failed;
useEffect(() => {
const source = sourceRef.current;
const content = contentRef.current;
const output = outputRef.current;
if (!source || !content || !output) return;
instanceRef.current = createGlitch(
{ source, content, output },
initialOptions,
);
if (native && !instanceRef.current) setFailed(true);
return () => {
instanceRef.current?.destroy();
instanceRef.current = null;
};
}, [initialOptions, native]);
useEffect(() => {
instanceRef.current?.setOptions(options);
});
return (
<div className={className} style={{ position: "relative", ...style }}>
<canvas
ref={sourceRef}
// @ts-expect-error experimental html-in-canvas attribute
layoutsubtree="true"
suppressHydrationWarning
style={
native
? { position: "absolute", inset: 0, width: "100%", height: "100%" }
: { display: "none" }
}
>
{native ? (
<div
ref={contentRef}
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "auto",
}}
>
{children}
</div>
) : null}
</canvas>
{!native ? (
<div
ref={contentRef}
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "auto",
}}
>
{children}
</div>
) : null}
<canvas
ref={outputRef}
aria-hidden
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
/>
</div>
);
}
export default Glitch;
API reference
| Property | Type | Default |
|---|---|---|
intensityOverall strength of the glitch (0 to 2). | number | 1 |
intervalSeconds between glitch bursts. 0 keeps the glitch running constantly. | number | 3 |
durationHow long each burst lasts in seconds. | number | 0.4 |
slicesNumber of horizontal slices the tear snaps to. Lower is chunkier. | number | 24 |
shiftHow far the torn slices shift sideways, in CSS pixels. | number | 30 |
rgbShiftChromatic RGB split during bursts, in CSS pixels. | number | 4 |
blocksAmount of corrupted block artifacts during bursts (0 to 1). | number | 0.5 |
noiseAnalog noise and scanline flicker during bursts (0 to 1). | number | 0.35 |
classNameClasses applied to the wrapper element. | string |