pnpm dlx shadcn@latest add @uigrove/text-tunnel

Text Tunnel

Rows of text scroll through a WebGL tunnel that folds and stretches them toward the edges, with bloom and a spectral fringe.

Installation
pnpm dlx shadcn@latest add @uigrove/text-tunnel

Dependencies: none. Installs @uigrove/dot-canvas alongside.

First time? Add the registry to components.json
{
  "registries": {
    "@uigrove": "https://uigrove.me/r/{name}.json"
  }
}
Usage
import { TextTunnel } from '@/components/uigrove/text-tunnel'

<TextTunnel
  text="Into the
Grove"
  className="absolute inset-0"
/>
Props
NameTypeDefaultDescription
textstring'Into the Grove'Text block repeated down the tunnel, one line per row
fontFamilystring'"Instrument Serif", Georgia, serif'CSS font family, loaded by the page
fontSizenumber98Size of the text in the flat middle
fontWeightnumber400Font weight
fontStyle'normal' | 'italic''normal'Upright or italic
letterSpacingnumber-0.5Space between letters
lineHeightnumber1Line height as a multiple of the font size
gapnumber0Space between repeats of the text block
columnsnumber1Copies of the text side by side in each row
textColorstring'#ffffff'Text and glow color
backgroundstring'#000000'Background color, also the fallback without WebGL
speednumber0.84Text blocks that pass the middle each second, 0 stops
direction'down' | 'up''down'Scroll direction of the rows
radiusnumber0.16Size of the flat middle before the walls bend
depthnumber1How hard rows squeeze and fold toward the top and bottom
flarenumber0.46Horizontal stretch of the rows near the edges
bloomnumber0.88Glow around the letters, strongest in the middle
chromaticnumber0.21Spectral color fringe on the stretched rows
fadenumber0Fades the stretched walls into the background
twistnumber0Shifts the top and bottom walls in opposite directions
cursorInfluencenumber0How far the tunnel center follows the cursor
classNamestring—Classes for the root element. Use it for sizing and position.
Code
'use client'

import { useEffect, useRef, type CSSProperties } from 'react'
import { useDotCanvas } from '@/hooks/use-dot-canvas'
import { TextTunnelEngine, type TextTunnelOptions } from './text-tunnel.engine'

export interface TextTunnelProps extends Partial<TextTunnelOptions> {
  className?: string
  style?: CSSProperties
}

export const textTunnelDefaults: TextTunnelOptions = {
  text: 'Into the\nGrove',
  fontFamily: '"Instrument Serif", Georgia, serif',
  fontSize: 98,
  fontWeight: 400,
  fontStyle: 'normal',
  letterSpacing: -0.5,
  lineHeight: 1,
  gap: 0,
  columns: 1,
  textColor: '#ffffff',
  background: '#000000',
  speed: 0.84,
  direction: 'down',
  radius: 0.16,
  depth: 1,
  flare: 0.46,
  bloom: 0.88,
  chromatic: 0.21,
  fade: 0,
  twist: 0,
  cursorInfluence: 0,
}

export function TextTunnel({
  text = textTunnelDefaults.text,
  fontFamily = textTunnelDefaults.fontFamily,
  fontSize = textTunnelDefaults.fontSize,
  fontWeight = textTunnelDefaults.fontWeight,
  fontStyle = textTunnelDefaults.fontStyle,
  letterSpacing = textTunnelDefaults.letterSpacing,
  lineHeight = textTunnelDefaults.lineHeight,
  gap = textTunnelDefaults.gap,
  columns = textTunnelDefaults.columns,
  textColor = textTunnelDefaults.textColor,
  background = textTunnelDefaults.background,
  speed = textTunnelDefaults.speed,
  direction = textTunnelDefaults.direction,
  radius = textTunnelDefaults.radius,
  depth = textTunnelDefaults.depth,
  flare = textTunnelDefaults.flare,
  bloom = textTunnelDefaults.bloom,
  chromatic = textTunnelDefaults.chromatic,
  fade = textTunnelDefaults.fade,
  twist = textTunnelDefaults.twist,
  cursorInfluence = textTunnelDefaults.cursorInfluence,
  className,
  style,
}: TextTunnelProps) {
  const engineRef = useRef<TextTunnelEngine | null>(null)
  if (!engineRef.current) engineRef.current = new TextTunnelEngine(textTunnelDefaults)
  const engine = engineRef.current
  const { containerRef, canvasRef, wake } = useDotCanvas(engine, {
    pointer: cursorInfluence > 0,
    kind: 'webgl',
  })

  useEffect(() => {
    engine.setOptions({
      text,
      fontFamily,
      fontSize,
      fontWeight,
      fontStyle,
      letterSpacing,
      lineHeight,
      gap,
      columns,
      textColor,
      background,
      speed,
      direction,
      radius,
      depth,
      flare,
      bloom,
      chromatic,
      fade,
      twist,
      cursorInfluence,
    })
    wake()
  }, [
    engine,
    wake,
    text,
    fontFamily,
    fontSize,
    fontWeight,
    fontStyle,
    letterSpacing,
    lineHeight,
    gap,
    columns,
    textColor,
    background,
    speed,
    direction,
    radius,
    depth,
    flare,
    bloom,
    chromatic,
    fade,
    twist,
    cursorInfluence,
  ])

  useEffect(() => {
    const fonts = document.fonts
    if (!fonts) return
    let active = true
    const refresh = () => {
      if (!active) return
      engine.refreshText()
      wake()
    }
    fonts
      .load(`${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`, text)
      .then(refresh, () => {})
    fonts.addEventListener('loadingdone', refresh)
    return () => {
      active = false
      fonts.removeEventListener('loadingdone', refresh)
    }
  }, [engine, wake, fontFamily, fontSize, fontWeight, fontStyle, text])

  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return
    const onLost = (e: Event) => {
      e.preventDefault()
      engine.resetContext()
    }
    const onRestored = () => {
      engine.resetContext()
      wake()
    }
    canvas.addEventListener('webglcontextlost', onLost)
    canvas.addEventListener('webglcontextrestored', onRestored)
    return () => {
      canvas.removeEventListener('webglcontextlost', onLost)
      canvas.removeEventListener('webglcontextrestored', onRestored)
    }
  }, [engine, wake, canvasRef])

  return (
    <div
      ref={containerRef}
      role="img"
      aria-label={text.replace(/\s+/g, ' ').trim()}
      className={['uig-text-tunnel', 'pointer-events-none overflow-hidden', className]
        .filter(Boolean)
        .join(' ')}
      style={{ background, ...style }}
    >
      <canvas
        ref={canvasRef}
        className="uig-text-tunnel__canvas block h-full w-full"
        aria-hidden="true"
        role="presentation"
      />
    </div>
  )
}