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-tunnelDependencies: 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
| Name | Type | Default | Description |
|---|---|---|---|
| text | string | 'Into the Grove' | Text block repeated down the tunnel, one line per row |
| fontFamily | string | '"Instrument Serif", Georgia, serif' | CSS font family, loaded by the page |
| fontSize | number | 98 | Size of the text in the flat middle |
| fontWeight | number | 400 | Font weight |
| fontStyle | 'normal' | 'italic' | 'normal' | Upright or italic |
| letterSpacing | number | -0.5 | Space between letters |
| lineHeight | number | 1 | Line height as a multiple of the font size |
| gap | number | 0 | Space between repeats of the text block |
| columns | number | 1 | Copies of the text side by side in each row |
| textColor | string | '#ffffff' | Text and glow color |
| background | string | '#000000' | Background color, also the fallback without WebGL |
| speed | number | 0.84 | Text blocks that pass the middle each second, 0 stops |
| direction | 'down' | 'up' | 'down' | Scroll direction of the rows |
| radius | number | 0.16 | Size of the flat middle before the walls bend |
| depth | number | 1 | How hard rows squeeze and fold toward the top and bottom |
| flare | number | 0.46 | Horizontal stretch of the rows near the edges |
| bloom | number | 0.88 | Glow around the letters, strongest in the middle |
| chromatic | number | 0.21 | Spectral color fringe on the stretched rows |
| fade | number | 0 | Fades the stretched walls into the background |
| twist | number | 0 | Shifts the top and bottom walls in opposite directions |
| cursorInfluence | number | 0 | How far the tunnel center follows the cursor |
| className | string | — | 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>
)
}