# Dot Matrix Ticker

Turn plain text into a configurable LED matrix, one Tailwind layer at a time.

Published: 9th September 2026

## Customize


## Tailwind animation

```ts showLineNumbers
// tailwind.config.ts
export default {
  theme: {
    extend: {
      keyframes: {
        'dot-matrix-ticker-scroll': {
          to: { transform: 'translate3d(-50%, 0, 0)' },
        },
      },
      animation: {
        'dot-matrix-ticker':
          'dot-matrix-ticker-scroll var(--ticker-duration) steps(var(--ticker-steps), end) infinite',
      },
    },
  },
}
```

## Ready component

```tsx showLineNumbers
'use client'

import {
  type CSSProperties,
  useCallback,
  useEffect,
  useRef,
  useState,
} from 'react'

const COLORS = ['#4ade80', '#fb923c', '#f87171', '#60a5fa', '#f8fafc']

type DotShape = 'circle' | 'square' | 'diamond'

type DotMatrixTickerProps = {
  text: string
  speed?: number
  steps?: number
  color?: string
  glow?: number
  dotShape?: DotShape
  dotSize?: number
  dotSpacing?: number
  className?: string
}

type TickerStyle = CSSProperties & {
  '--ticker-color': string
  '--ticker-duration': string
  '--ticker-steps': number
  '--ticker-glow-shadow': string
}

export function DotMatrixTicker({
  text,
  speed = 2,
  steps = 200,
  color,
  glow = 1,
  dotShape = 'circle',
  dotSize = 3,
  dotSpacing = 1,
  className = '',
}: DotMatrixTickerProps) {
  const trackRef = useRef<HTMLDivElement>(null)
  const [duration, setDuration] = useState(12)
  const [colorIndex, setColorIndex] = useState(0)
  const displayText = `${text.trim().toUpperCase() || 'DOT MATRIX TICKER'} `

  const updateDuration = useCallback(() => {
    const track = trackRef.current
    if (!track) return

    const normalizedSpeed = Math.min(Math.max(speed, 0), 10)
    if (normalizedSpeed === 0) return

    setDuration(track.scrollWidth / 2 / (normalizedSpeed * 100))
  }, [speed])

  useEffect(() => {
    updateDuration()
    const track = trackRef.current
    if (!track) return

    const observer = new ResizeObserver(updateDuration)
    observer.observe(track)
    return () => observer.disconnect()
  }, [displayText, updateDuration])

  const canChangeColor = color === undefined
  const currentColor = color ?? COLORS[colorIndex]
  const glowStrength = Math.min(Math.max(glow, 0), 3)
  const glowShadow = glowStrength === 0
    ? 'none'
    : `0 0 ${4 * glowStrength}px currentColor,
       0 0 ${9 * glowStrength}px currentColor,
       0 0 ${16 * glowStrength}px currentColor`

  const size = Math.min(Math.max(dotSize, 1), 6)
  const spacing = Math.min(Math.max(dotSpacing, 0.5), 4)
  const center = size / 2
  const masks: Record<DotShape, string> = {
    circle: `radial-gradient(circle at ${center}px ${center}px,
      transparent 0 ${center}px, #000 ${center + 0.2}px)`,
    square: `conic-gradient(from 90deg at ${size}px ${size}px,
      transparent 25%, #000 0)`,
    diamond: `linear-gradient(45deg, #000 0 32%, transparent 32% 68%, #000 68%),
      linear-gradient(-45deg, #000 0 32%, transparent 32% 68%, #000 68%)`,
  }

  const maskStyle: CSSProperties = {
    backgroundColor: '#000',
    maskImage: masks[dotShape],
    WebkitMaskImage: masks[dotShape],
    maskSize: `${size + spacing}px ${size + spacing}px`,
    WebkitMaskSize: `${size + spacing}px ${size + spacing}px`,
  }

  const style: TickerStyle = {
    '--ticker-color': currentColor,
    '--ticker-duration': `${duration}s`,
    '--ticker-steps': Math.max(1, Math.round(steps)),
    '--ticker-glow-shadow': glowShadow,
  }

  const cycleColor = () => {
    if (canChangeColor) {
      setColorIndex((current) => (current + 1) % COLORS.length)
    }
  }

  return (
    <div
      role={canChangeColor ? 'button' : 'img'}
      tabIndex={canChangeColor ? 0 : undefined}
      aria-label={`Ticker displaying “${text}”.`}
      onClick={cycleColor}
      onKeyDown={(event) => {
        if (event.key === 'Enter' || event.key === ' ') {
          event.preventDefault()
          cycleColor()
        }
      }}
      style={style}
      className={`relative w-full overflow-hidden rounded-xl border
        border-white/10 bg-black shadow-[inset_0_0_16px_rgba(0,0,0,0.9),
        0_8px_30px_rgba(0,0,0,0.35)] outline-none
        focus-visible:ring-2 focus-visible:ring-white/70 ${className}`}
    >
      <div className="relative overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_12%,black_88%,transparent)]">
        <div className="pointer-events-none absolute inset-0 z-20 bg-[linear-gradient(to_bottom,rgba(255,255,255,0.16),transparent_35%,transparent_65%,rgba(255,255,255,0.08))]" />
        <div className="pointer-events-none absolute -left-[10%] -top-[80%] z-20 h-[220%] w-full -skew-x-[55deg] bg-[linear-gradient(10deg,transparent_35%,rgba(255,255,255,0.16),transparent_65%)] blur-xl" />

        <div className="relative py-3 sm:py-4">
          <div
            ref={trackRef}
            aria-hidden="true"
            className={`flex w-max animate-dot-matrix-ticker whitespace-nowrap
              text-[var(--ticker-color)] will-change-transform
              motion-reduce:animate-none
              ${speed <= 0 ? '[animation-play-state:paused]' : ''}`}
          >
            {[0, 1].map((copy) => (
              <span
                key={copy}
                className="pr-8 font-mono text-3xl font-medium leading-none tracking-[0.08em] [text-shadow:var(--ticker-glow-shadow)] sm:text-4xl"
              >
                {displayText}
              </span>
            ))}
          </div>

          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 z-10"
            style={maskStyle}
          />
        </div>
      </div>
    </div>
  )
}
```

Original page: https://faisalhusa.in/craft/dot-matrix-ticker
