I wanted a fun dither/CRT effect for the site, but after searching GitHub, realized there wasn't an easy-to-use component to pull from. Shout out to OpenAI's o3 for helping me shape an idea quickly.
Dither
The following is what is implemented on this site. It's a simple TypeScript module that converts any bitmap into a Bayer 4×4 ordered-dithered version and then applies a staggered RGB shadow mask to imitate a cathode-ray tube.
Animate
A React component wires these steps to a <canvas>
so you can drop the effect into any Next.js page. The full source is two files, a lib and a component — both shown in full below.
Feel free to steal.
Bayer ordered dithering
Ordered dithering maps a continuous tone to a small palette through a spatial threshold pattern. The 4×4 Bayer matrix is a classic pattern with pleasing noise characteristics.
The algorithm divides the incoming pixel value by the palette step, adds a location-dependent offset from the Bayer matrix, and rounds to the nearest discrete level.
The matrix is tiled over the image so the effect repeats every four pixels in each axis.
applyBayer4x4Dither
/* lib/dither.ts */
export interface DitherOptions {
monochrome?: boolean
levels?: number
}
// 4×4 Bayer matrix (ordered dithering)
const BAYER_4X4 = [
0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,
] as const
// Pre-compute threshold lookup as normalized offsets in range (−0.5 .. 0.5)
const BAYER_THRESHOLDS = BAYER_4X4.map((v) => (v + 0.5) / 16 - 0.5)
export function applyBayer4x4Dither(
imageData: ImageData,
options: DitherOptions = {}
) {
const { monochrome = false, levels = 4 } = options
const w = imageData.width
const h = imageData.height
const data = imageData.data
const L = Math.max(2, levels)
const maxLevelIndex = L - 1
const invLevelsMinus1 = 1 / maxLevelIndex
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = (y * w + x) * 4
const bayerIndex = (y & 3) * 4 + (x & 3)
const thresholdOffset = BAYER_THRESHOLDS[bayerIndex] // −0.5 … 0.5
if (monochrome) {
// grayscale path (perceived luminance)
const r = data[idx]
const g = data[idx + 1]
const b = data[idx + 2]
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
const normalized = luminance / 255 + thresholdOffset
const quant = normalized < 0.5 ? 0 : 1
const color = quant * 255
data[idx] = color
data[idx + 1] = color
data[idx + 2] = color
} else {
// colour path — each channel quantised independently
for (let c = 0; c < 3; c++) {
const val = data[idx + c]
const normalized = val / 255 + thresholdOffset
const quantLevel = Math.min(
maxLevelIndex,
Math.max(0, Math.round(normalized * maxLevelIndex))
)
data[idx + c] = Math.round(quantLevel * (255 * invLevelsMinus1))
}
}
// alpha untouched
}
}
return imageData
}
// Convenience wrapper operating on a CanvasRenderingContext2D
export function ditherCanvas(
ctx: CanvasRenderingContext2D,
options?: DitherOptions
) {
const { width, height } = ctx.canvas
const src = ctx.getImageData(0, 0, width, height)
const dst = applyBayer4x4Dither(src, options)
ctx.putImageData(dst, 0, 0)
return dst
}
The helper ditherCanvas
grabs the current raster from a <canvas>
, applies the transformation in-place, and writes back the result. That makes it trivial to plug into any drawing pipeline.
CRT shadow mask
Real CRTs use triads of phosphors masked by a thin metal grille.
The code below approximates the optics by periodically attenuating two sub-pixels while letting the third pass at full intensity.
Horizontal staggering every alternate scan line imitates the slot mask used in many monitors. A slight random noise per pixel introduces an analogue shimmer.
applyCRTMask
/* lib/dither.ts continued */
export interface CRTMaskOptions {
strength?: number // 0..1
jitter?: number // 0..1
}
export function applyCRTMask(
imageData: ImageData,
frame = 0,
options: CRTMaskOptions = {}
) {
const { data, width, height } = imageData
const strength = Math.min(1, Math.max(0, options.strength ?? 0.6))
const jitterAmp = Math.min(1, Math.max(0, options.jitter ?? 0.15))
const baseInactive = 1 - strength
for (let y = 0; y < height; y++) {
const rowShift = y & 1 // stagger every scan line
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4
const subPixel = (x + rowShift + frame) % 3 // 0:R 1:G 2:B
let r = data[idx]
let g = data[idx + 1]
let b = data[idx + 2]
// pseudorandom jitter based on coordinates
const randSeed =
((x * 374761393 + y * 668265263 + frame * 12345) >>> 0) & 0xffff
const noise = (randSeed / 0xffff - 0.5) * jitterAmp
const dim = Math.min(1, Math.max(0, baseInactive + noise))
switch (subPixel) {
case 0: // red active
g = Math.round(g * dim)
b = Math.round(b * dim)
break
case 1: // green active
r = Math.round(r * dim)
b = Math.round(b * dim)
break
case 2: // blue active
r = Math.round(r * dim)
g = Math.round(g * dim)
break
}
data[idx] = r
data[idx + 1] = g
data[idx + 2] = b
}
}
return imageData
}
The function accepts a frame
parameter so the active sub-pixel lane shifts horizontally over time, producing the familiar micro-glint when refreshed every frame.
React component
The Dither
component brings everything together. It loads an image, draws it to an off-screen canvas, applies the ordered dithering, and then optionally animates the CRT mask.
/* components/dither.tsx */
'use client'
import { applyCRTMask, ditherCanvas } from '@/lib/dither'
import { cn } from '@/lib/utils'
import React, { useEffect, useRef } from 'react'
export interface DitherProps {
src: string
alt?: string
width?: number
height?: number
className?: string
style?: React.CSSProperties
animate?: boolean
flickerFps?: number
crtStrength?: number
monochrome?: boolean
levels?: number
crtJitter?: number
}
export const Dither: React.FC<DitherProps> = ({
src,
alt = '',
width,
height,
className,
style,
animate,
flickerFps = 60,
crtStrength,
monochrome,
levels,
crtJitter,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
let rafId: number | undefined
const img = new window.Image()
img.crossOrigin = 'anonymous'
img.src = src
img.onload = () => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return
const w = width ?? img.naturalWidth
const h = height ?? img.naturalHeight
canvas.width = w
canvas.height = h
ctx.drawImage(img, 0, 0, w, h)
const base = ditherCanvas(ctx, { monochrome, levels })
if (animate) {
let frame = 0
let last = performance.now()
const loop = () => {
const now = performance.now()
const interval = 1000 / flickerFps
if (now - last >= interval) {
last = now - ((now - last) % interval)
const copy = new ImageData(
new Uint8ClampedArray(base.data),
base.width,
base.height
)
applyCRTMask(copy, frame++, {
strength: crtStrength,
jitter: crtJitter,
})
ctx.putImageData(copy, 0, 0)
}
rafId = requestAnimationFrame(loop)
}
rafId = requestAnimationFrame(loop)
}
}
return () => {
if (rafId !== undefined) cancelAnimationFrame(rafId)
}
}, [
src,
width,
height,
animate,
flickerFps,
crtStrength,
crtJitter,
monochrome,
levels,
])
return (
<canvas
ref={canvasRef}
aria-label={alt}
role="img"
className={cn('max-w-full', className)}
style={style}
/>
)
}
export default Dither
Usage
<Dither
src="/photos/2024-porto/bridge.jpg"
width={400}
animate
monochrome={false}
levels={4}
/>
Optional props adjust the behaviour:
monochrome
toggles greyscale ordered ditheringlevels
selects the per-channel quantization depthanimate
triggers the CRT shadow maskflickerFps
adjusts animation frame ratecrtStrength
andcrtJitter
fine-tune the mask appearance
Everything runs entirely in the browser with no external dependencies.
Enjoy building retro vibes.