UIcraft / Craft

Craft / Animation

Dot Matrix Ticker

Turn ordinary text into a configurable LED display with one mask and a stepped loop.

Demo

Interactive
Display onlineDM–01
PlaygroundDisplay controls

Display controls

Tune the message, matrix, and movement.

Color
Matrix shape

Copy this component and the keyframe below. The enclosure, glass, and matrix are CSS. Duration is measured from the track so the stepped loop stays aligned with the dots.

dot-matrix-ticker.tsx
"use client"
 
import {
  type CSSProperties,
  useCallback,
  useEffect,
  useRef,
  useState,
} from "react"
 
const COLORS = ["#4ade80", "#fb923c", "#f87171", "#60a5fa", "#f8fafc"]
 
type DotShape = "circle" | "square" | "diamond"
 
type TickerStyle = CSSProperties & {
  "--ticker-color": string
  "--ticker-duration": string
  "--ticker-steps": number
  "--ticker-glow-shadow": string
}
 
export function DotMatrixTicker({
  text,
  speed = 1.5,
  steps = 200,
  color,
  glow = 1,
  dotShape = "circle",
  dotSize = 3,
  dotSpacing = 1,
  className = "",
}: {
  text: string
  speed?: number
  steps?: number
  color?: string
  glow?: number
  dotShape?: DotShape
  dotSize?: number
  dotSpacing?: number
  className?: string
}) {
  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%)`,
  }
 
  return (
    <div
      role={canChangeColor ? "button" : "img"}
      tabIndex={canChangeColor ? 0 : undefined}
      aria-label={`Ticker displaying “${text}”.`}
      onClick={() => canChangeColor && setColorIndex((current) => (current + 1) % COLORS.length)}
      style={{
        "--ticker-color": currentColor,
        "--ticker-duration": `${duration}s`,
        "--ticker-steps": Math.max(1, Math.round(steps)),
        "--ticker-glow-shadow": glowShadow,
      } as TickerStyle}
      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 ${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)] 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 bg-black"
            style={{
              maskImage: masks[dotShape],
              WebkitMaskImage: masks[dotShape],
              maskSize: `${size + spacing}px ${size + spacing}px`,
              WebkitMaskSize: `${size + spacing}px ${size + spacing}px`,
            }}
          />
        </div>
      </div>
    </div>
  )
}
globals.css
@theme inline {
  --animate-dot-matrix-ticker: dot-matrix-ticker-scroll var(--ticker-duration)
    steps(var(--ticker-steps), end) infinite;
}
 
@keyframes dot-matrix-ticker-scroll {
  to { transform: translate3d(-50%, 0, 0); }
}