Bayer Dither and CRT Mask React Component

Typescript

Retro CRT aesthetic with dithering and animated masking

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 &mdash; 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 dithering
  • levels selects the per-channel quantization depth
  • animate triggers the CRT shadow mask
  • flickerFps adjusts animation frame rate
  • crtStrength and crtJitter fine-tune the mask appearance

Everything runs entirely in the browser with no external dependencies.

Enjoy building retro vibes.

Published on June 15, 2025

7 min read