01The idea

Glitch tools hand you a dice roll. I wanted dials.

Most glitch generators work like a slot machine. You mash a randomize button until something looks great, and the moment you touch anything else, that look is gone. The point of the tool was to keep the chaos reachable once you had found it.

GlitchFilter treats every artifact as a parameter. RGB channels shift by the pixel. Scanlines have spacing, thickness, and opacity. The block corruption that reads as random is driven by a seeded linear congruential generator, so seed 412 produces the same damage on every machine.

That one decision (randomness as a parameter, not an event) carries the rest of the tool. Presets, share links, and the ten-frame GIF export all depend on it, with each GIF frame a controlled variation of the same seed instead of a reroll.

Field note · the whole trick, two lines
// glitch-blocks: a seeded LCG stands in for Math.random()
next = seed * 1664525 + 1013904223

// gifExport: frame n re-runs the stack, nudged
frameSeed = seed + frameIndex * 13

02The effects system

Every effect is one file that describes itself

Effects live in src/effects, one file each, in a plugin-style registry keyed by id and grouped into five categories: distortion, vintage, overlay, noise, and color. An effect exports a single object: id, label, category, icon, default params, a controls array, and an apply function that takes ImageData and returns ImageData.

The controls array is the interesting part. Each entry declares a slider, select, or toggle with its min, max, step, and unit, and the controls panel renders itself from that metadata. Adding an effect means writing one file (every effect so far fits in under 70 lines) and the UI shows up on its own.

Effects stack. The pipeline is an ordered array of instances, each apply feeding the next, so scanlines can run over corrupted blocks, or twice over themselves: the Laser Grid preset stacks two scanline instances. Instances reorder by drag and drop and toggle on and off individually.

Data specimen · one effect, as registered
{
  id: 'glitch-blocks',
  label: 'Glitch Blocks',
  category: 'distortion',
  defaultParams: { /* one value per control */ },
  controls: [
    { param: 'count', type: 'slider', min: 1, max: 60 },
    { param: 'maxShift', type: 'slider', max: 200, unit: 'px' },
    { param: 'colorGlitch', type: 'toggle' },
    { param: 'seed', type: 'slider', min: 0, max: 999 }
  ],
  apply(imageData, params) { /* pixels in, pixels out */ }
}
  • RGB Shift

    distortion

    Six per-channel offsets (red, green, and blue on both axes, -100 to 100px), resampled from a pristine copy. Radial mode scales the shift by distance from center.

  • Scanlines

    overlay

    Spacing 2-20px, thickness 1-4px, 0-100% opacity, horizontal or vertical. Darkens rows by multiplying the channels down.

  • Glitch Blocks

    distortion

    1 to 60 displaced slabs with height and probability controls, shifts up to 200px, an optional XOR pass over the red channel, and a 0-999 seed. Fully reproducible.

  • Noise / Grain

    noise

    0-100% amount with chunky per-cell grain from 1 to 8px, in luminance or full color.

  • VHS / Tape

    vintage · benched

    Noise, color bleed, jitter, chroma shift, warp, and opacity. Built, then pulled from the registry over color dominance issues. More in section 07.

03The render pipeline

One pristine buffer, cloned every render

The renderer runs on Canvas 2D and raw ImageData bytes, with typed arrays and arithmetic you can read in the source. No WebGL and no workers.

Pristine ImageData is Cloned to a fresh Uint8ClampedArray runs through Enabled effects, in order then putImageData · full resolution

A 120ms debounce does the heavy lifting

Slider drags collapse into a single render at the end of the debounce window, with a spinner while it waits. The preview feels live without re-running the whole stack for every tick of the drag. The spinner tells the user why the output has not updated yet, the kind of signal argued for in micro-interactions that earn their keep.

Zoom never touches a pixel

10% to 400% zoom is a CSS transform on the stage, so it costs nothing. Fit mode re-fits through a ResizeObserver when the window resizes; Original is an exact 100%.

Before / After / Split

A second canvas holds the untouched source underneath the output. The compare modes cross-fade the two with opacity, including a half-and-half split.

Survey noteThe source buffer is never mutated. Every look, no matter how destroyed, is one state change away from the original.

04The GIF encoder

An animated GIF, encoded in the tab

Export needs no server and no upload. gifenc plus 71 lines of glue in gifExport.js turn a static effect stack into a looping animation.

  1. Downscale first

    The source runs through two offscreen canvases until its longest side is 640px or less. GIF is a heavy format; the cap keeps exports shareable instead of enormous.

  2. Re-run the stack, ten times

    Each of the 10 frames re-runs the entire effect pipeline. Any effect with a seed gets seed + frameIndex * 13, so glitch blocks jump deterministically from frame to frame, while noise varies on its own.

  3. Quantize per frame

    Every frame gets its own 256-color palette through gifenc's quantize and applyPalette.

  4. Yield between frames

    The encoder hands control back to the UI after every frame and reports progress, so the export button reads "Frame 3/10" instead of the tab freezing.

  5. Deliver and clean up

    Frames encode at a 100ms delay (10fps, infinite loop), land in a Blob, download as glitchfilter-export.gif, and the object URL is revoked behind them.

05Privacy

The whole app runs client-side

There is no upload. A dropped file goes URL.createObjectURL to an Image to an offscreen canvas to getImageData, and lives in React state from then on. The src tree contains zero network calls: no fetch, no XMLHttpRequest, no axios, no beacons, no sockets.

A share link compresses the effect stack (index-based effect ids, only the params that differ from defaults, select values as option indexes) into a base64 #preset= hash that carries your settings, not your pixels. Paste a link or a bare code into the Import box and the exact look reproduces on any machine.

The only outbound touches in the whole app: navigator.clipboard.writeText when you copy a share link, and localStorage for the theme.

Grep check · network calls in src/
$ grep -rE "fetch\(|XMLHttpRequest|axios|sendBeacon|WebSocket" src/
exit 1 · no matches

06The workbench

Details that make it feel like a tool

The shell is three panes: the effect stack on the left, the canvas in the middle, the auto-generated controls on the right. Around it, the parts a tool owes its user:

  • 12 presets

    Signal Lost, Retrowave, Data Rot, TV Snow: each preset is a saved stack of two or three fully parameterized effect instances.

  • Randomize, with guardrails

    Picks 3 to 6 effects and rolls every slider within its declared range, honoring per-control randomMax caps so "random" stays usable instead of turning the canvas to soup.

  • Undo that respects you

    Up to 20 history snapshots on Ctrl+Z, Ctrl+Y, and Ctrl+Shift+Z.

  • Full-resolution exports

    PNG, JPEG, and WebP at the source's native size from a split button in the header, plus the animated GIF.

  • Drop anything

    Drag and drop or click to load JPG, PNG, WebP, or GIF, decoded straight to ImageData. Dark and light themes persist to localStorage.

  • One reducer, no sprawl

    All state lives in a single React Context with useReducer: 16 action types in 233 lines, no Redux or state library.

07Margin of error

Known limits

GlitchFilter is at v0.2.0.

  • The VHS effect is benched

    Six parameters of tape damage, built and working, then hidden from the registry over color dominance issues (tracked as FEATURES.md #143). A fifth effect that muddies every image would drag down the four that work, so it stays benched.

  • Main thread, on purpose

    Every render walks the full-resolution byte array on the main thread: no WebGL, no workers. The 120ms debounce keeps it feeling live, and the 640px GIF cap keeps exports quick. If stacks grow much deeper, a worker is the natural escape hatch.

  • Live on GitHub Pages

    The site is hosted on GitHub Pages behind a custom domain, published by a GitHub Actions workflow. Live at glitchfilter.com (opens in a new tab).

Stack

  • React 18
  • Vite 5
  • JavaScript + JSX
  • Canvas 2D
  • gifenc
  • Bootstrap 5
  • GitHub Actionsdeploys toGitHub Pages

By the numbers

  • 12 presets
  • 120ms render debounce
  • 10 frames per GIF export
  • 71 lines in the GIF encoder
  • 0 network requests, ever

10Plates

Survey plates

Four captures from the live tool: the workbench mid-edit, and three exports straight out of the encoder.

The GlitchFilter workbench: effect stack on the left, a glitched cat photo on the canvas, and Glitch Blocks controls on the right
Plate 01 · Workbench · effect stack, canvas, self-rendering controls
An exported GlitchFilter image: a dog portrait cut by scanlines, RGB shift, and glitch-block displacement
Plate 02 · Export sample · a portrait through scanlines, RGB shift, and glitch blocks
An exported GlitchFilter image: Hokusai's Great Wave hue-shifted into neon pinks and teals, textured with scanlines and grain
Plate 03 · Export sample · The Great Wave in a vaporwave palette, scanlines and grain
An exported GlitchFilter image: the dog portrait again, its color channels split into rainbow offsets over soft scanlines
Plate 04 · Export sample · the same dog, RGB channels pulled apart

11Next traverse

Need a browser app that feels like a native tool?

GlitchFilter is the kind of canvas and motion work I enjoy most. The GIF encoder is gifenc plus 71 lines of glue, and the share link carries the current settings instead of the image. If your product needs a browser-side editor or live preview, I build those from the Chicago area and remotely.