Dithered Logo
Any image or SVG turned into a dithered particle field that repels the cursor and ripples on click.
Installation
pnpm dlx shadcn@latest add @uigrove/dithered-logoDependencies: none. Installs @uigrove/dot-canvas alongside.
First time? Add the registry to components.json
{
"registries": {
"@uigrove": "https://uigrove.me/r/{name}.json"
}
}Usage
import { DitheredLogo } from '@/components/uigrove/dithered-logo'
<DitheredLogo
src="/path/to/file"
className="absolute inset-0"
/>Props
| Name | Type | Default | Description |
|---|---|---|---|
| src | string | '' | Image or SVG URL. Cross-origin images need CORS headers. |
| alt | string | '' | Accessible name. Leave empty for decorative use. |
| gridSize | number | 140 | Cells along the longer side before dithering |
| scale | number | 0.7 | Size relative to the shorter container side |
| dotScale | number | 1 | Multiplier for each particle size |
| invert | boolean | false | Fill the frame and cut the logo out |
| cornerRadius | number | 0.2 | Frame rounding when inverted |
| threshold | number | 128 | Brightness cut for the dither |
| contrast | number | 0 | Contrast |
| gamma | number | 1 | Gamma |
| blur | number | 1 | Softening in cells before sampling |
| diffusion | number | 1 | Strength of the error diffusion |
| serpentine | boolean | true | Alternate scan direction per row |
| color | string | '#ffffff' | Color |
| opacity | number | 1 | Opacity |
| radius | number | 120 | Cursor radius |
| strength | number | 22 | Push strength |
| stiffness | number | 160 | Spring stiffness |
| damping | number | 16 | Damping |
| ripple | boolean | true | Click ripple |
| rippleStrength | number | 16 | Ripple strength |
| interactive | boolean | true | Interactive |
| 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 { DitheredLogoEngine, type DitheredLogoOptions } from './dithered-logo.engine'
export interface DitheredLogoProps extends Partial<Omit<DitheredLogoOptions, 'src'>> {
src: string
alt?: string
className?: string
style?: CSSProperties
}
export const ditheredLogoDefaults: DitheredLogoOptions = {
src: '',
gridSize: 140,
scale: 0.7,
dotScale: 1,
invert: false,
cornerRadius: 0.2,
threshold: 128,
contrast: 0,
gamma: 1,
blur: 1,
diffusion: 1,
serpentine: true,
color: '#ffffff',
opacity: 1,
radius: 120,
strength: 22,
stiffness: 160,
damping: 16,
ripple: true,
rippleStrength: 16,
interactive: true,
}
export function DitheredLogo({
src,
alt = '',
gridSize = ditheredLogoDefaults.gridSize,
scale = ditheredLogoDefaults.scale,
dotScale = ditheredLogoDefaults.dotScale,
invert = ditheredLogoDefaults.invert,
cornerRadius = ditheredLogoDefaults.cornerRadius,
threshold = ditheredLogoDefaults.threshold,
contrast = ditheredLogoDefaults.contrast,
gamma = ditheredLogoDefaults.gamma,
blur = ditheredLogoDefaults.blur,
diffusion = ditheredLogoDefaults.diffusion,
serpentine = ditheredLogoDefaults.serpentine,
color = ditheredLogoDefaults.color,
opacity = ditheredLogoDefaults.opacity,
radius = ditheredLogoDefaults.radius,
strength = ditheredLogoDefaults.strength,
stiffness = ditheredLogoDefaults.stiffness,
damping = ditheredLogoDefaults.damping,
ripple = ditheredLogoDefaults.ripple,
rippleStrength = ditheredLogoDefaults.rippleStrength,
interactive = ditheredLogoDefaults.interactive,
className,
style,
}: DitheredLogoProps) {
const engineRef = useRef<DitheredLogoEngine | null>(null)
if (!engineRef.current) engineRef.current = new DitheredLogoEngine(ditheredLogoDefaults)
const engine = engineRef.current
const { containerRef, canvasRef, wake } = useDotCanvas(engine, { pointer: interactive })
useEffect(() => {
engine.setOptions({
src,
gridSize,
scale,
dotScale,
invert,
cornerRadius,
threshold,
contrast,
gamma,
blur,
diffusion,
serpentine,
color,
opacity,
radius,
strength,
stiffness,
damping,
ripple,
rippleStrength,
interactive,
})
wake()
}, [
engine,
wake,
src,
gridSize,
scale,
dotScale,
invert,
cornerRadius,
threshold,
contrast,
gamma,
blur,
diffusion,
serpentine,
color,
opacity,
radius,
strength,
stiffness,
damping,
ripple,
rippleStrength,
interactive,
])
useEffect(() => {
if (!src) {
engine.setImage(null)
wake()
return
}
const image = new Image()
image.crossOrigin = 'anonymous'
image.decoding = 'async'
let cancelled = false
image.onload = () => {
if (cancelled) return
engine.setImage(image)
wake()
}
image.onerror = () => {
if (cancelled) return
engine.setImage(null)
wake()
}
image.src = src
return () => {
cancelled = true
}
}, [engine, wake, src])
useEffect(() => {
if (!ripple || !interactive) return
const container = containerRef.current
if (!container) return
const onDown = (e: PointerEvent) => {
const rect = container.getBoundingClientRect()
engine.addRipple(e.clientX - rect.left, e.clientY - rect.top)
wake()
}
window.addEventListener('pointerdown', onDown, { passive: true })
return () => window.removeEventListener('pointerdown', onDown)
}, [engine, wake, ripple, interactive, containerRef])
return (
<div
ref={containerRef}
className={['uig-dithered-logo', 'pointer-events-none overflow-hidden', 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>
)
}