ASCII Effect
An image or video rendered as live ASCII characters with flow, glitch and cursor ripple modes.
Installation
pnpm dlx shadcn@latest add @uigrove/ascii-effectDependencies: none. Installs @uigrove/dot-canvas alongside.
First time? Add the registry to components.json
{
"registries": {
"@uigrove": "https://uigrove.me/r/{name}.json"
}
}Usage
import { AsciiEffect } from '@/components/uigrove/ascii-effect'
<AsciiEffect
src="/path/to/file"
className="absolute inset-0"
/>Props
| Name | Type | Default | Description |
|---|---|---|---|
| src | string | '' | Image URL, or a video URL ending in .mp4 or .webm |
| alt | string | '' | Accessible name. Leave empty for decorative use. |
| variant | 'image' | 'flow' | 'glitch' | 'flow' | Variant |
| fit | 'contain' | 'cover' | 'contain' | How the source fills the box |
| cellSize | number | 11 | Character height, cells are 0.6 as wide |
| chars | string | ' .:-=+*#%@' | Ramp from darkest to brightest |
| color | string | '#ffffff' | Color |
| colorMode | 'mono' | 'source' | 'mono' | Single color, or the source pixel colors |
| invert | boolean | false | Invert |
| contrast | number | 0.1 | Contrast |
| brightness | number | 0 | Brightness |
| posterize | number | 0 | Levels, 0 turns it off |
| dither | 'none' | 'floyd-steinberg' | 'bayer' | 'none' | Dither |
| speed | number | 1 | Speed |
| mouseRadius | number | 150 | Cursor radius |
| mouseStrength | number | 0.5 | Cursor strength |
| glitchIntensity | number | 0.65 | Glitch intensity |
| seed | number | 1 | Seed |
| fontFamily | string | '' | Empty inherits the container font |
| fontWeight | number | 500 | Font weight |
| className | string | — | Classes for the root element. Use it for sizing and position. |
Code
'use client'
import { useEffect, useRef, type CSSProperties } from 'react'
import { useDotCanvas } from '@/hooks/use-dot-canvas'
import { AsciiEffectEngine, type AsciiEffectOptions } from './ascii-effect.engine'
export interface AsciiEffectProps extends Partial<Omit<AsciiEffectOptions, 'src'>> {
src: string
alt?: string
className?: string
style?: CSSProperties
}
export const asciiEffectDefaults: AsciiEffectOptions = {
src: '',
variant: 'flow',
fit: 'contain',
cellSize: 11,
chars: ' .:-=+*#%@',
color: '#ffffff',
colorMode: 'mono',
invert: false,
contrast: 0.1,
brightness: 0,
posterize: 0,
dither: 'none',
speed: 1,
mouseRadius: 150,
mouseStrength: 0.5,
glitchIntensity: 0.65,
seed: 1,
fontFamily: '',
fontWeight: 500,
}
const VIDEO_PATTERN = /\.(mp4|webm|mov|ogv|m4v)(\?|#|$)/i
export function AsciiEffect({
src,
alt = '',
variant = asciiEffectDefaults.variant,
fit = asciiEffectDefaults.fit,
cellSize = asciiEffectDefaults.cellSize,
chars = asciiEffectDefaults.chars,
color = asciiEffectDefaults.color,
colorMode = asciiEffectDefaults.colorMode,
invert = asciiEffectDefaults.invert,
contrast = asciiEffectDefaults.contrast,
brightness = asciiEffectDefaults.brightness,
posterize = asciiEffectDefaults.posterize,
dither = asciiEffectDefaults.dither,
speed = asciiEffectDefaults.speed,
mouseRadius = asciiEffectDefaults.mouseRadius,
mouseStrength = asciiEffectDefaults.mouseStrength,
glitchIntensity = asciiEffectDefaults.glitchIntensity,
seed = asciiEffectDefaults.seed,
fontFamily = asciiEffectDefaults.fontFamily,
fontWeight = asciiEffectDefaults.fontWeight,
className,
style,
}: AsciiEffectProps) {
const engineRef = useRef<AsciiEffectEngine | null>(null)
if (!engineRef.current) engineRef.current = new AsciiEffectEngine(asciiEffectDefaults)
const engine = engineRef.current
const { containerRef, canvasRef, wake } = useDotCanvas(engine, { pointer: mouseStrength > 0 })
useEffect(() => {
const container = containerRef.current
const family = fontFamily || (container ? getComputedStyle(container).fontFamily : '')
engine.setOptions({
src,
variant,
fit,
cellSize,
chars: chars || ' ',
color,
colorMode,
invert,
contrast,
brightness,
posterize,
dither,
speed,
mouseRadius,
mouseStrength,
glitchIntensity,
seed,
fontFamily: family,
fontWeight,
})
wake()
}, [
engine,
wake,
containerRef,
src,
variant,
fit,
cellSize,
chars,
color,
colorMode,
invert,
contrast,
brightness,
posterize,
dither,
speed,
mouseRadius,
mouseStrength,
glitchIntensity,
seed,
fontFamily,
fontWeight,
])
useEffect(() => {
if (!src) {
engine.setSource(null)
wake()
return
}
let cancelled = false
if (VIDEO_PATTERN.test(src)) {
const video = document.createElement('video')
video.crossOrigin = 'anonymous'
video.muted = true
video.loop = true
video.playsInline = true
video.preload = 'auto'
video.src = src
const onReady = () => {
if (cancelled) return
engine.setSource(video)
video.play().catch(() => {})
wake()
}
video.addEventListener('loadeddata', onReady)
video.load()
return () => {
cancelled = true
video.removeEventListener('loadeddata', onReady)
video.pause()
video.removeAttribute('src')
video.load()
}
}
const image = new Image()
image.crossOrigin = 'anonymous'
image.decoding = 'async'
image.onload = () => {
if (cancelled) return
engine.setSource(image)
wake()
}
image.onerror = () => {
if (cancelled) return
engine.setSource(null)
wake()
}
image.src = src
return () => {
cancelled = true
}
}, [engine, wake, src])
return (
<div
ref={containerRef}
className={['uig-ascii-effect', 'pointer-events-none overflow-hidden font-mono', className]
.filter(Boolean)
.join(' ')}
style={style}
role={alt ? 'img' : undefined}
aria-label={alt || undefined}
aria-hidden={alt ? undefined : true}
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
)
}