Dot Matrix Ticker

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

User Avatar

Faisal Husain

Published

Ship interfaces that feel fast.

Plain text

Begin with one readable monospace sentence.

Customize

Display online
DM–01

Display controls

Tune the message, matrix, and movement.

Color
Matrix shape

Tailwind animation

// 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

'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>
  )
}

Let's work together

Have a product problem worth solving?

I help teams build fast, dependable products with thoughtful interfaces and strong engineering foundations.