Craft / Animation
Dot Matrix Ticker
Turn ordinary text into a configurable LED display with one mask and a stepped loop.
Demo
InteractiveDisplay 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.
"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>
)
}@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); }
}