UIgrove
pnpm dlx shadcn@latest add @uigrove/dot-matrix-text

Dot Matrix Text

Text rendered as a dot matrix that assembles from scattered dots as it scrolls into view.

Installation
pnpm dlx shadcn@latest add @uigrove/dot-matrix-text

Dependencies: none. Installs @uigrove/dot-canvas alongside.

First time? Add the registry to components.json
{
  "registries": {
    "@uigrove": "https://uigrove.me/r/{name}.json"
  }
}
Usage
import { DotMatrixText } from '@/components/uigrove/dot-matrix-text'

<DotMatrixText text="UIgrove" />
Props
NameTypeDefaultDescription
textstring'UIgrove'Text
fontSizenumber120Font size
fontWeightnumber600Font weight
fontFamilystring''Font family
dotSizenumber4Dot size
gapnumber7Gap
colorstring'#ffffff'Color
align'left' | 'center' | 'right''center'Align
durationnumber1400Duration
staggernumber600Stagger
scatternumber160Scatter
order'random' | 'left' | 'center''random'Order
fitWidthbooleantrueFit width
interactivebooleantrueInteractive
radiusnumber80Cursor radius
strengthnumber14Push strength
seednumber3Seed
goonumber0Goo
squareSizenumber1Size of square points relative to a dot, for custom points
squareRadiusnumber0.25Corner radius of square points as a fraction of their size
linkWidthnumber0.4Thickness of links between points, relative to a dot
classNamestring—Classes for the root element. Use it for sizing and position.
Code
'use client'

import { useEffect, useId, useRef, type CSSProperties } from 'react'
import { useDotCanvas } from '@/hooks/use-dot-canvas'
import { DotMatrixTextEngine, type DotMatrixTextOptions } from './dot-matrix-text.engine'

export interface DotMatrixTextProps extends Partial<Omit<DotMatrixTextOptions, 'text'>> {
  text: string
  goo: number
  className?: string
  style?: CSSProperties
}

export const dotMatrixTextDefaults: DotMatrixTextOptions = {
  text: 'UIgrove',
  fontSize: 120,
  fontWeight: 600,
  fontFamily: '',
  dotSize: 4,
  gap: 7,
  color: '#ffffff',
  align: 'center',
  duration: 1400,
  stagger: 600,
  scatter: 160,
  order: 'random',
  fitWidth: true,
  interactive: true,
  radius: 80,
  strength: 14,
  seed: 3,
  linkWidth: 0.4,
  squareSize: 1,
  squareRadius: 0.25,
}

export function DotMatrixText({
  text,
  fontSize = dotMatrixTextDefaults.fontSize,
  fontWeight = dotMatrixTextDefaults.fontWeight,
  fontFamily = dotMatrixTextDefaults.fontFamily,
  dotSize = dotMatrixTextDefaults.dotSize,
  gap = dotMatrixTextDefaults.gap,
  color = dotMatrixTextDefaults.color,
  align = dotMatrixTextDefaults.align,
  duration = dotMatrixTextDefaults.duration,
  stagger = dotMatrixTextDefaults.stagger,
  scatter = dotMatrixTextDefaults.scatter,
  order = dotMatrixTextDefaults.order,
  fitWidth = dotMatrixTextDefaults.fitWidth,
  interactive = dotMatrixTextDefaults.interactive,
  radius = dotMatrixTextDefaults.radius,
  strength = dotMatrixTextDefaults.strength,
  seed = dotMatrixTextDefaults.seed,
  linkWidth = dotMatrixTextDefaults.linkWidth,
  squareSize = dotMatrixTextDefaults.squareSize,
  squareRadius = dotMatrixTextDefaults.squareRadius,
  points,
  links,
  shapes,
  goo = 0,
  className,
  style,
}: Partial<DotMatrixTextProps> & { text: string }) {
  const engineRef = useRef<DotMatrixTextEngine | null>(null)
  if (!engineRef.current) engineRef.current = new DotMatrixTextEngine(dotMatrixTextDefaults)
  const engine = engineRef.current
  const filterId = useId()
  const { containerRef, canvasRef, wake } = useDotCanvas(engine, { pointer: interactive })

  useEffect(() => {
    const container = containerRef.current
    if (!container) return
    const family = fontFamily || getComputedStyle(container).fontFamily
    const options = {
      text,
      fontSize,
      fontWeight,
      fontFamily: family,
      dotSize,
      gap,
      color,
      align,
      duration,
      stagger,
      scatter,
      order,
      fitWidth,
      interactive,
      radius,
      strength,
      seed,
      linkWidth,
      squareSize,
      squareRadius,
      points,
      links,
      shapes,
    }
    engine.setOptions(options)
    wake()
    let cancelled = false
    document.fonts?.load(`${fontWeight} ${fontSize}px ${family}`).then(() => {
      if (cancelled) return
      engine.setOptions({ ...options, seed: seed })
      engine.resize(container.clientWidth, container.clientHeight)
      wake()
    })
    return () => {
      cancelled = true
    }
  }, [
    engine,
    wake,
    containerRef,
    text,
    fontSize,
    fontWeight,
    fontFamily,
    dotSize,
    gap,
    color,
    align,
    duration,
    stagger,
    scatter,
    order,
    fitWidth,
    interactive,
    radius,
    strength,
    seed,
    linkWidth,
    squareSize,
    squareRadius,
    points,
    links,
    shapes,
  ])

  const blur = goo * dotSize * 0.6
  const slope = 12 + goo * 28
  const intercept = -slope * 0.5 + 0.5

  return (
    <div
      ref={containerRef}
      className={['uig-dot-matrix-text', 'pointer-events-none overflow-hidden', className]
        .filter(Boolean)
        .join(' ')}
      style={{ height: Math.ceil(fontSize * 1.3), ...style }}
    >
      <span className="sr-only">{text}</span>
      {goo > 0 ? (
        <svg className="absolute size-0" aria-hidden="true">
          <defs>
            <filter id={filterId} colorInterpolationFilters="sRGB">
              <feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="blur" />
              <feColorMatrix
                in="blur"
                type="matrix"
                values={`1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 ${slope} ${intercept}`}
              />
            </filter>
          </defs>
        </svg>
      ) : null}
      <canvas
        ref={canvasRef}
        className="block h-full w-full"
        aria-hidden="true"
        style={goo > 0 ? { filter: `url(#${filterId})` } : undefined}
      />
    </div>
  )
}