Skip to content

Instantly share code, notes, and snippets.

@kiritocode1
Last active February 4, 2026 13:54
Show Gist options
  • Select an option

  • Save kiritocode1/98b21dcaa87ccaf43f42edfdd6b4c2aa to your computer and use it in GitHub Desktop.

Select an option

Save kiritocode1/98b21dcaa87ccaf43f42edfdd6b4c2aa to your computer and use it in GitHub Desktop.
"use client"
import React from "react"
import { useRef, useEffect, useCallback, useState, type HTMLAttributes } from "react"
import { cn } from "@/lib/utils"
// Constants for wave animation behavior
const WAVE_THRESH = 6
const CHAR_MULT = 2
const ANIM_STEP = 45
const WAVE_BUF = 10
interface CharPosition {
x: number
y: number
char: string
index: number
}
interface Wave {
x: number
y: number
startTime: number
id: number
}
interface ASCIITextProps extends Omit<HTMLAttributes<HTMLParagraphElement>, "children"> {
children: string
/** Animation duration in ms (auto-scales with text length if not set) */
duration?: number
/** Character set to use for scrambling */
chars?: string
/** Whether to preserve spaces during animation */
preserveSpaces?: boolean
/** Wave speed - pixels per second (higher = faster ripple) */
waveSpeed?: number
/** Render as a different element (span, h1, etc) */
as?: "p" | "span" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div" | "label"
}
export function ASCIIText({
children,
duration: durationProp,
chars = '.,·-─~+:;=*π""┐┌┘┴┬╗╔╝╚╬╠╣╩╦║░▒▓█▄▀▌▐■!?&#$@0123456789*',
preserveSpaces = true,
waveSpeed: waveSpeedProp,
as: Component = "p",
className,
...props
}: ASCIITextProps) {
const containerRef = useRef<HTMLParagraphElement>(null)
const measureRef = useRef<HTMLSpanElement>(null)
const [displayText, setDisplayText] = useState(children)
const [isAnimating, setIsAnimating] = useState(false)
const wavesRef = useRef<Wave[]>([])
const charPositionsRef = useRef<CharPosition[]>([])
const isHoverRef = useRef(false)
const animIdRef = useRef<number | null>(null)
const cursorRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
const origChars = children.split("")
// Auto-scale duration based on text length
const textLength = children.length
const duration = durationProp ?? Math.max(1500, Math.min(textLength * 12, 3500))
const waveSpeed = waveSpeedProp ?? 150 // pixels per second
/**
* Measure character positions using a hidden span with individual character spans
*/
const measureCharPositions = useCallback(() => {
if (!containerRef.current) return
const container = containerRef.current
const rect = container.getBoundingClientRect()
const style = window.getComputedStyle(container)
// Create a temporary measuring element that matches the container's styles
const measurer = document.createElement("span")
measurer.style.cssText = `
position: absolute;
visibility: hidden;
white-space: pre-wrap;
word-wrap: break-word;
width: ${rect.width}px;
font: ${style.font};
letter-spacing: ${style.letterSpacing};
line-height: ${style.lineHeight};
padding: ${style.padding};
`
document.body.appendChild(measurer)
const positions: CharPosition[] = []
// Wrap each character in a span to measure its position
measurer.innerHTML = origChars
.map((char, i) => `<span data-idx="${i}">${char === " " ? "&nbsp;" : char}</span>`)
.join("")
const charSpans = measurer.querySelectorAll("span")
charSpans.forEach((span, i) => {
const charRect = span.getBoundingClientRect()
const measurerRect = measurer.getBoundingClientRect()
positions.push({
x: charRect.left - measurerRect.left + charRect.width / 2,
y: charRect.top - measurerRect.top + charRect.height / 2,
char: origChars[i],
index: i
})
})
document.body.removeChild(measurer)
charPositionsRef.current = positions
}, [origChars])
/**
* Clean up expired waves
*/
const cleanupWaves = useCallback((t: number) => {
wavesRef.current = wavesRef.current.filter((w) => t - w.startTime < duration)
}, [duration])
/**
* Calculate 2D distance between two points
*/
const distance2D = (x1: number, y1: number, x2: number, y2: number) => {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
}
/**
* Calculates wave effect for a character at given position using 2D distance
*/
const calcWaveEffect = useCallback((charPos: CharPosition, t: number): { shouldAnim: boolean; char: string } => {
let shouldAnim = false
let resultChar = charPos.char
for (const w of wavesRef.current) {
const age = t - w.startTime
const prog = Math.min(age / duration, 1)
// Calculate 2D distance from wave origin to character
const dist = distance2D(charPos.x, charPos.y, w.x, w.y)
// Wave radius expands over time (in pixels)
const maxRadius = Math.max(
containerRef.current?.getBoundingClientRect().width ?? 500,
containerRef.current?.getBoundingClientRect().height ?? 200
) * 1.5
const rad = prog * (maxRadius + WAVE_BUF * 10) * (waveSpeed / 100)
if (dist <= rad) {
shouldAnim = true
const intens = Math.max(0, rad - dist)
// Characters in the wave zone shift through character sequence
const threshold = WAVE_THRESH * 10 // Scale threshold for pixel distances
if (intens <= threshold && intens > 0) {
const idx = (Math.floor(dist / 8) * CHAR_MULT + Math.floor(age / ANIM_STEP)) % chars.length
resultChar = chars[idx]
}
}
}
return { shouldAnim, char: resultChar }
}, [duration, waveSpeed, chars])
/**
* Generates scrambled text based on current waves using 2D positions
*/
const genScrambledTxt = useCallback((t: number): string => {
if (charPositionsRef.current.length === 0) return children
return charPositionsRef.current
.map((pos) => {
if (preserveSpaces && pos.char === " ") return " "
const res = calcWaveEffect(pos, t)
return res.shouldAnim ? res.char : pos.char
})
.join("")
}, [children, preserveSpaces, calcWaveEffect])
/**
* Stops the animation and resets to original text
*/
const stop = useCallback(() => {
setDisplayText(children)
setIsAnimating(false)
}, [children])
/**
* Start the animation loop
*/
const start = useCallback(() => {
if (animIdRef.current !== null) return
// Measure character positions before starting animation
measureCharPositions()
setIsAnimating(true)
const animate = () => {
const t = Date.now()
cleanupWaves(t)
if (wavesRef.current.length === 0) {
animIdRef.current = null
stop()
return
}
setDisplayText(genScrambledTxt(t))
animIdRef.current = requestAnimationFrame(animate)
}
animIdRef.current = requestAnimationFrame(animate)
}, [cleanupWaves, genScrambledTxt, stop, measureCharPositions])
/**
* Updates cursor position based on mouse move (now tracks actual pixel position)
*/
const updateCursorPos = useCallback((e: React.MouseEvent) => {
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
cursorRef.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}, [])
/**
* Starts a new wave animation from current cursor position
*/
const startWave = useCallback(() => {
wavesRef.current.push({
x: cursorRef.current.x,
y: cursorRef.current.y,
startTime: Date.now(),
id: Math.random()
})
if (animIdRef.current === null) {
start()
}
}, [start])
const handleEnter = useCallback((e: React.MouseEvent) => {
isHoverRef.current = true
updateCursorPos(e)
startWave()
}, [updateCursorPos, startWave])
const handleMove = useCallback((e: React.MouseEvent) => {
if (!isHoverRef.current) return
const oldX = cursorRef.current.x
const oldY = cursorRef.current.y
updateCursorPos(e)
// Start new wave if cursor moved significantly
const moved = distance2D(oldX, oldY, cursorRef.current.x, cursorRef.current.y)
if (moved > 8) {
startWave()
}
}, [updateCursorPos, startWave])
const handleLeave = useCallback(() => {
isHoverRef.current = false
}, [])
// Cleanup on unmount
useEffect(() => {
return () => {
if (animIdRef.current !== null) {
cancelAnimationFrame(animIdRef.current)
}
}
}, [])
// Update display text when children change
useEffect(() => {
if (!isAnimating) {
setDisplayText(children)
}
}, [children, isAnimating])
// Re-measure on resize
useEffect(() => {
const handleResize = () => {
if (isAnimating) {
measureCharPositions()
}
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [isAnimating, measureCharPositions])
return (
<Component
ref={containerRef}
className={cn(
"cursor-pointer select-none",
className
)}
onMouseEnter={handleEnter}
onMouseMove={handleMove}
onMouseLeave={handleLeave}
aria-label={children}
{...props}
>
{displayText}
</Component>
)
}
@kiritocode1
Copy link
Author

#usecase

import { ASCIIText } from "@/components/ascii-text"

export default function Page() {
  // ASCII Fluid Text Demo
  return (
    <main className="min-h-screen bg-[#121211] text-[#f9f9f7] font-mono">
      <div className="max-w-2xl mx-auto px-6 py-12 space-y-12">
        {/* Header */}
        <header className="space-y-2">
          <ASCIIText
            as="h1"
            className="text-2xl font-normal tracking-tight"
            duration={800}
            spread={0.2}
          >
            ASCII Fluid Text Component
          </ASCIIText>
          <ASCIIText
            as="p"
            className="text-[#bdbdbd] text-sm"
            duration={600}
          >
            Hover over any text to see the wave ripple effect
          </ASCIIText>
        </header>

        {/* Example paragraphs - 2D wave effect works on multi-line text */}
        <section className="space-y-6">
          <ASCIIText className="text-base leading-relaxed">
            The universe is made of stories, not of atoms. Each moment we spend exploring the cosmos reveals new mysteries waiting to be unraveled. The stars whisper secrets to those who listen, painting the night sky with ancient light that has traveled millions of years to reach our eyes.
          </ASCIIText>

          <ASCIIText
            className="text-base leading-relaxed"
            duration={2000}
            waveSpeed={120}
          >
            In the depths of digital space, characters dance and transform, creating ripples of meaning that cascade through the void of the screen. Each keystroke echoes through the silicon corridors, leaving traces of thought encoded in the fabric of the machine.
          </ASCIIText>

          <ASCIIText
            className="text-lg leading-relaxed font-medium"
            chars="01"
            duration={1500}
            waveSpeed={200}
          >
            Binary dreams pulse through silicon veins. The code awakens at midnight, speaking in tongues of logic and loops.
          </ASCIIText>
        </section>

        {/* Book list example */}
        <section className="space-y-4">
          <ASCIIText as="h2" className="text-lg font-medium border-b border-[#333] pb-2">
            Recommended Reading
          </ASCIIText>

          <ul className="space-y-3">
            {[
              "Roadside Picnic — Arkady & Boris Strugatsky",
              "The City & the City — China Miéville",
              "Parable of the Sower — Octavia E. Butler",
              "The Fifth Head of Cerberus — Gene Wolfe",
              "Riddley Walker — Russell Hoban",
              "His Master's Voice — Stanisław Lem",
              "The Left Hand of Darkness — Ursula K. Le Guin",
            ].map((book) => (
              <li key={book} className="flex items-center gap-3">
                <span className="w-2 h-px bg-[#f9f9f7]" />
                <ASCIIText
                  as="span"
                  className="text-sm hover:text-white transition-colors"
                  duration={1000}
                  waveSpeed={180}
                >
                  {book}
                </ASCIIText>
              </li>
            ))}
          </ul>
        </section>

        {/* Different heading sizes */}
        <section className="space-y-4">
          <ASCIIText as="h2" className="text-lg font-medium border-b border-[#333] pb-2">
            Typography Examples
          </ASCIIText>

          <div className="space-y-3">
            <ASCIIText as="h1" className="text-3xl tracking-tight">
              Heading One
            </ASCIIText>
            <ASCIIText as="h2" className="text-2xl tracking-tight">
              Heading Two
            </ASCIIText>
            <ASCIIText as="h3" className="text-xl">
              Heading Three
            </ASCIIText>
            <ASCIIText as="h4" className="text-lg">
              Heading Four
            </ASCIIText>
            <ASCIIText className="text-base text-[#bdbdbd]">
              Regular paragraph text with muted color
            </ASCIIText>
            <ASCIIText as="span" className="text-sm text-[#888]">
              Small inline span element
            </ASCIIText>
          </div>
        </section>

        {/* Custom character sets */}
        <section className="space-y-4">
          <ASCIIText as="h2" className="text-lg font-medium border-b border-[#333] pb-2">
            Custom Character Sets
          </ASCIIText>

          <div className="space-y-3">
            <ASCIIText
              chars="░▒▓█"
              duration={1200}
              waveSpeed={150}
              className="text-base"
            >
              Block characters only
            </ASCIIText>

            <ASCIIText
              chars="╔╗╚╝║═╬╣╠╩╦"
              duration={1400}
              waveSpeed={140}
              className="text-base"
            >
              Box drawing characters
            </ASCIIText>

            <ASCIIText
              chars="@#$%&*"
              duration={1000}
              waveSpeed={200}
              className="text-base"
            >
              Symbol explosion mode
            </ASCIIText>

            <ASCIIText
              chars="·•○●◦◌"
              duration={1500}
              waveSpeed={130}
              className="text-base"
            >
              Dot matrix style
            </ASCIIText>
          </div>
        </section>

        {/* Footer */}
        <footer className="pt-8 border-t border-[#333]">
          <ASCIIText
            as="p"
            className="text-xs text-[#666] text-center"
            duration={1200}
            spread={1}
          >
            ASCII Fluid Text Component — React/TypeScript
          </ASCIIText>
        </footer>
      </div>
    </main>
  )
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment