Numeric Tunnel Loader

A numeric progress loader that turns real wait states, uploads, asset warm-ups, and initialization moments into a technical loading sequence.

Published On: June 4, 2026
Last Updated: August 17, 2026
GSAP @react-three/fiber@react-three/dreiThree.js
Numeric Tunnel

Overview

Numeric Tunnel gives a real wait a visible state: numbers move through real progress.

Use it for route latency, upload progress, asset warm-up, data loading, or WebGL initialization. Do not add a fake preloader to a fast page just to make it feel designed.

The production risk is dishonesty. Bind the loader to real state, use aria-busy where the region is updating, and never let a progress number claim completion before the content is ready.


Install Command

npx hyperiux add numeric-tunnel

Usage Code

page.jsx
import NumericTunnel from '@/components/effects/numeric-tunnel'

const page = () => {
  return (
    <NumericTunnel />
  )
}


Component Code

index.jsx
// Built using Hyperiux Vault: https://vault.hyperiux.com
'use client';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { NumericTunnelLoader } from './NumericTunnelLoader';
export default function NumericTunnel({ digitCount = 50, speed = 1, depth = 900, textColor = '#ffffff', backgroundColor = '#000000', }) {
    const [isComplete, setIsComplete] = useState(false);
    const [tunnelInstance, setTunnelInstance] = useState(0);
    const didMountRef = useRef(false);
    const tunnelConfig = useMemo(() => ({ digitCount, speed, depth, textColor, backgroundColor }), [backgroundColor, depth, digitCount, speed, textColor]);
    useEffect(() => {
        if (!didMountRef.current) {
            didMountRef.current = true;
            return;
        }
        setIsComplete(false);
        setTunnelInstance((currentInstance) => currentInstance + 1);
    }, [tunnelConfig]);
    const handleComplete = useCallback(() => {
        setIsComplete(true);
    }, []);
    const handleReplay = useCallback(() => {
        setIsComplete(false);
        setTunnelInstance((currentInstance) => currentInstance + 1);
    }, []);
    return (<NumericTunnelLoader key={tunnelInstance} onComplete={handleComplete} config={tunnelConfig}>
 <div className="h-screen w-full bg-white">
 <h2 className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-black text-[4vw] font-bold">HYPERIUX VAULT</h2>
    <button type="button" onClick={handleReplay} className={`absolute top-[calc(50%+5.5rem)] left-1/2 -translate-x-1/2 -translate-y-1/2
    rounded-full border border-black/10 bg-white/60 px-5 py-2
    text-sm font-medium text-black backdrop-blur-md

    transition-all duration-300
    hover:scale-105 hover:border-black/20 hover:bg-white/80
    active:scale-95
    ${isComplete
            ? "opacity-100"
            : "pointer-events-none opacity-0"}`}>
  ↻ Replay
    </button>
 </div>
 </NumericTunnelLoader>);
}
NumericTunnelLoader.jsx
'use client';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Canvas, createPortal, useFrame, useThree } from '@react-three/fiber';
import { Text, useFBO } from '@react-three/drei';
import * as THREE from 'three';
import gsap from 'gsap';
// ---- CONFIGURATION ----
const TUNNEL_CONFIG = {
    ringCount: 5,
    countPerRing: 10,
    ringRadius: 2,
    ringStartScale: 0.01,
    ringEndScale: 8,
    ringRotateSpeed: 1.2,
    ringFadeInPhase: 0.1,
    ringFullVisiblePhase: 0.7,
    ringFadeOutPhase: 0.9,
    ringFontSize: 0.5,
    ringLetterSpacing: -0.05,
    textColor: '#ffffff',
    crossfadeDuration: 2,
    canvasFadeDuration: 500,
    tunnelFadeOutDelay: 5,
    loaderTickInterval: 40,
    loaderStartDelay: 1200,
    backgroundColor: '#000000'
};
function clampNumber(value, min, max, fallback) {
    const numericValue = Number(value);
    if (!Number.isFinite(numericValue))
        return fallback;
    return Math.min(max, Math.max(min, numericValue));
}
function createTunnelConfig({ digitCount = TUNNEL_CONFIG.ringCount * TUNNEL_CONFIG.countPerRing, speed = 1, depth = 900, textColor = TUNNEL_CONFIG.textColor, backgroundColor = TUNNEL_CONFIG.backgroundColor, } = {}) {
    const safeDigitCount = Math.round(clampNumber(digitCount, 0, 540, TUNNEL_CONFIG.ringCount * TUNNEL_CONFIG.countPerRing));
    const ringCount = safeDigitCount > 0 ? TUNNEL_CONFIG.ringCount : 0;
    const countPerRing = ringCount ? Math.max(1, Math.ceil(safeDigitCount / ringCount)) : 0;
    const safeSpeed = clampNumber(speed, 0.1, 5, 1);
    const depthScale = clampNumber(depth, 120, 2700, 900) / 900;
    return {
        ...TUNNEL_CONFIG,
        countPerRing,
        ringEndScale: TUNNEL_CONFIG.ringEndScale * depthScale,
        ringRotateSpeed: TUNNEL_CONFIG.ringRotateSpeed * safeSpeed,
        loaderTickInterval: TUNNEL_CONFIG.loaderTickInterval / safeSpeed,
        loaderStartDelay: TUNNEL_CONFIG.loaderStartDelay / safeSpeed,
        crossfadeDuration: TUNNEL_CONFIG.crossfadeDuration / safeSpeed,
        tunnelFadeOutDelay: TUNNEL_CONFIG.tunnelFadeOutDelay / safeSpeed,
        textColor,
        backgroundColor,
    };
}
function TunnelRing({ index, loaderValue, tunnelPhaseRef, config }) {
    const groupRef = useRef(null);
    const ringContentRef = useRef(null);
    const textMaterialRef = useRef(null);
    const direction = index % 2 === 0 ? 1 : -1;
    const reduceMotionRef = useRef(typeof window !== 'undefined' &&
        (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false));
    const textMaterial = useMemo(() => new THREE.MeshBasicMaterial({
        color: new THREE.Color(config.textColor),
        transparent: true,
        opacity: 0,
        depthWrite: false,
    }), [config.textColor]);
    useEffect(() => {
        textMaterialRef.current = textMaterial;
        return () => {
            textMaterialRef.current = null;
            textMaterial.dispose();
        };
    }, [textMaterial]);
    useEffect(() => {
        const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)');
        if (!mq)
            return undefined;
        const onChange = (event) => {
            reduceMotionRef.current = event.matches;
        };
        reduceMotionRef.current = mq.matches;
        mq.addEventListener?.('change', onChange);
        return () => mq.removeEventListener?.('change', onChange);
    }, []);
    useFrame((state, delta) => {
        if (!groupRef.current || !ringContentRef.current)
            return;
        const material = textMaterialRef.current;
        if (!material)
            return;
        const time = state.clock.getElapsedTime();
        const speed = 0.25 * (config.ringRotateSpeed / TUNNEL_CONFIG.ringRotateSpeed);
        // Instead of adding index mapped offset, subtract it to delay spawn.
        // This makes rings wait their turn to start from the center.
        const rawPhase = time * speed - index / config.ringCount;
        if (rawPhase < 0) {
            groupRef.current.scale.set(0.001, 0.001, 0.001);
            material.opacity = 0;
        }
        else {
            const phase = rawPhase % 1;
            // Scale calculation
            const scale = Math.pow(phase, 3) * config.ringEndScale +
                config.ringStartScale;
            groupRef.current.scale.set(scale, scale, scale);
            // Opacity calculation by phase
            let opacity = 0;
            if (phase < config.ringFadeInPhase)
                opacity = phase / config.ringFadeInPhase;
            else if (phase <= config.ringFullVisiblePhase)
                opacity = 1;
            else if (phase <= config.ringFadeOutPhase)
                opacity = 1 - (phase - config.ringFullVisiblePhase) / (config.ringFadeOutPhase - config.ringFullVisiblePhase);
            else
                opacity = 0;
            // Fade out rings as tunnelPhase progresses
            material.opacity = opacity * (1 - tunnelPhaseRef.current);
        }
        // Reduced-motion: keep counter, skip ring rotation.
        if (!reduceMotionRef.current) {
            ringContentRef.current.rotation.z += delta * config.ringRotateSpeed * direction;
        }
    });
    return (<group ref={groupRef} position={[0, 0, -index * 0.01]}>
 <group ref={ringContentRef}>
 {Array.from({ length: config.countPerRing }).map((_, i) => {
            const angle = (i / config.countPerRing) * Math.PI * 2;
            const x = Math.cos(angle) * config.ringRadius;
            const y = Math.sin(angle) * config.ringRadius;
            return (<Text key={i} position={[x, y, 0]} rotation={[0, 0, angle - Math.PI / 2]} fontSize={config.ringFontSize} anchorX="center" anchorY="middle" letterSpacing={config.ringLetterSpacing} material={textMaterial}>
 {loaderValue.toString().padStart(2, '0')}
 </Text>);
        })}
 </group>
 </group>);
}
function TunnelScene({ loaderValue, tunnelPhaseRef, config }) {
    return (<>
 <color attach="background" args={[config.backgroundColor]}/>
 {Array.from({ length: config.ringCount }).map((_, i) => (<TunnelRing key={i} index={i} loaderValue={loaderValue} tunnelPhaseRef={tunnelPhaseRef} config={config}/>))}
 </>);
}
function WholeSceneEffect({ children, active: _active, tunnelPhaseRef }) {
    const { gl, camera, size, viewport } = useThree();
    const portalScene = useMemo(() => new THREE.Scene(), []);
    const fbo = useFBO(size.width, size.height, { samples: 0, depth: false });
    const materialRef = useRef(null);
    const reduceMotionRef = useRef(typeof window !== 'undefined' &&
        (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false));
    useEffect(() => {
        const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)');
        if (!mq)
            return undefined;
        const onChange = (event) => {
            reduceMotionRef.current = event.matches;
        };
        reduceMotionRef.current = mq.matches;
        mq.addEventListener?.('change', onChange);
        return () => mq.removeEventListener?.('change', onChange);
    }, []);
    // Pass tunnelPhase as a uniform for smooth shader transition
    const material = useMemo(() => {
        return new THREE.ShaderMaterial({
            depthTest: false,
            depthWrite: false,
            transparent: true,
            uniforms: {
                tDiffuse: { value: fbo.texture },
                uTime: { value: 0 },
                uStrength: { value: 10.5 },
                uResolution: { value: new THREE.Vector2(size.width, size.height) },
                uTunnelPhase: { value: 0 },
            },
            vertexShader: /* glsl */ `
 varying vec2 vUv;
 varying float vDeform;
 uniform float uTime;
 uniform float uStrength;
 uniform float uTunnelPhase;
 void main() {
 vUv = uv;
 vec3 pos = position;

 float isLeft = step(0.0, -pos.x);
 float deform = isLeft * (1.0 - (pos.x + 0.5) * 2.0);

 deform *= sin(uTime * 2.4 + pos.y * 7.0 + pos.x * 5.2) * 0.23 * uStrength;
 pos.x += deform * 0.28 * uTunnelPhase;
 pos.z += isLeft * sin(uTime * 1.7 + pos.x * 10.0) * 0.15 * uStrength * uTunnelPhase;

 vDeform = deform * uTunnelPhase;
 gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
 }
 `,
            fragmentShader: /* glsl */ `
 varying vec2 vUv;
 varying float vDeform;
 uniform sampler2D tDiffuse;
 uniform float uTunnelPhase;
 uniform float uTime;
 void main() {
 float aberrationAmount = vDeform * 0.04;
 vec2 offset = vec2(aberrationAmount, 0.0);

 vec4 cr = texture2D(tDiffuse, vUv + offset);
 vec4 cg = texture2D(tDiffuse, vUv);
 vec4 cb = texture2D(tDiffuse, vUv - offset);

 float alpha = max(cr.a, max(cg.a, cb.a));
 vec4 baseColor = vec4(cr.r, cg.g, cb.b, alpha);

 vec3 shiftColor = vec3(
 0.5 + 0.5 * sin(uTime * 3.0 + vUv.y * 10.0),
 0.5 + 0.5 * sin(uTime * 2.0 + vUv.x * 10.0 + 2.0),
 0.5 + 0.5 * sin(uTime * 4.0 + vUv.y * 5.0 + 4.0)
 );

 float shiftIntensity = abs(vDeform) * .2;

 vec3 finalColor = mix(
 baseColor.rgb,
 baseColor.rgb * shiftColor * 2.5 + shiftColor * alpha * 0.3,
 clamp(shiftIntensity, 0.0, 1.0)
 );

 gl_FragColor = vec4(finalColor, alpha * uTunnelPhase);
 }
 `,
        });
    }, [fbo.texture, size.width, size.height]);
    useEffect(() => {
        materialRef.current = material;
        materialRef.current.uniforms.uResolution.value.set(size.width, size.height);
    }, [material, size.width, size.height]);
    useEffect(() => {
        return () => {
            materialRef.current = null;
            material.dispose();
        };
    }, [material]);
    useFrame((state) => {
        // always render tunnel pass, then crossfade
        gl.setRenderTarget(fbo);
        gl.clear(true, true, true);
        gl.render(portalScene, camera);
        gl.setRenderTarget(null);
        const shaderMaterial = materialRef.current;
        if (!shaderMaterial)
            return;
        shaderMaterial.uniforms.uTime.value = state.clock.getElapsedTime();
        shaderMaterial.uniforms.uTunnelPhase.value = tunnelPhaseRef.current;
        // Reduced-motion: keep the crossfade, skip the warp/distortion strength ramp.
        shaderMaterial.uniforms.uStrength.value = reduceMotionRef.current
            ? 0
            : THREE.MathUtils.lerp(5.5, 1, tunnelPhaseRef.current);
    });
    return (<>
 {createPortal(children, portalScene)}
 <group>{children}</group>
 <mesh scale={[viewport.width, viewport.height, 1]} position={[0, 0, 0]} renderOrder={10}>
 <planeGeometry args={[1, 1, 48, 48]}/>
 <primitive object={material} attach="material"/>
 </mesh>
 </>);
}
function NumericTunnelCanvas({ loaderValue, onComplete, config }) {
    const [effectActive, setEffectActive] = useState(false);
    // Remove useState for canvasOpacity, use a ref instead
    const canvasRef = useRef(null);
    const didScheduleRef = useRef(false);
    const tunnelPhaseRef = useRef(0);
    useEffect(() => {
        let anim;
        let fadeTimeout;
        if (loaderValue >= 100 && !didScheduleRef.current) {
            didScheduleRef.current = true;
            setEffectActive(true);
            let t = 0;
            const duration = config.crossfadeDuration;
            const step = () => {
                t += 1 / 60;
                tunnelPhaseRef.current = Math.min(t / duration, 1);
                if (t < duration) {
                    anim = requestAnimationFrame(step);
                }
                else {
                    fadeTimeout = setTimeout(() => {
                        if (onComplete) {
                            onComplete();
                            return;
                        }
                        // Animate opacity to 0 using GSAP; remove setCanvasOpacity, use ref
                        if (canvasRef.current) {
                            gsap.to(canvasRef.current, {
                                opacity: 0,
                                duration: config.canvasFadeDuration / 1000,
                                ease: "power3.inOut",
                            });
                        }
                    }, config.tunnelFadeOutDelay);
                }
            };
            requestAnimationFrame(step);
        }
        return () => {
            if (anim !== undefined)
                cancelAnimationFrame(anim);
            if (fadeTimeout !== undefined)
                clearTimeout(fadeTimeout);
        };
    }, [loaderValue, onComplete]);
    return (<div ref={canvasRef} className="h-full w-full" style={{ backgroundColor: config.backgroundColor }}>
 <Canvas dpr={1} orthographic camera={{ zoom: 100, position: [0, 0, 10] }} gl={{ powerPreference: 'high-performance' }}>
 <color attach="background" args={[config.backgroundColor]}/>
 <WholeSceneEffect active={effectActive} tunnelPhaseRef={tunnelPhaseRef}>
 <TunnelScene loaderValue={loaderValue} tunnelPhaseRef={tunnelPhaseRef} config={config}/>
 </WholeSceneEffect>
 </Canvas>
 </div>);
}
export function NumericTunnelLoader({ children, onComplete, config: configOverrides }) {
    const [loaderValue, setLoaderValue] = useState(0);
    const [isComplete, setIsComplete] = useState(false);
    const config = useMemo(() => createTunnelConfig(configOverrides), [configOverrides]);
    useEffect(() => {
        let interval;
        const timeout = setTimeout(() => {
            interval = setInterval(() => {
                setLoaderValue((prev) => (prev >= 100 ? 100 : prev + 1));
            }, config.loaderTickInterval);
        }, config.loaderStartDelay);
        return () => {
            clearTimeout(timeout);
            if (interval)
                clearInterval(interval);
        };
    }, [config.loaderStartDelay, config.loaderTickInterval]);
    const handleComplete = () => {
        setIsComplete(true);
        if (onComplete)
            onComplete();
    };
    return (<div className="relative h-screen w-full overflow-hidden">
 {children}
 {!isComplete && (<div className="absolute inset-0 z-10">
 <NumericTunnelCanvas loaderValue={loaderValue} onComplete={handleComplete} config={config}/>
 </div>)}
 </div>);
}

Example Production Use Case

Use this as loading-state implementation guidance. Verify the shipped progress model, aria-busy behavior, live-region text, timer cleanup, skeleton fallback, and reduced-motion state before relying on exact props, defaults, imports, or installation steps. Determinate progress must be tied to a real signal.


Best Used For

  • Uploads, WebGL warm-up, and asset preloads where numeric progress reflects real state.
  • Technical launch flows where the loading moment can carry brand tone without lying.
  • Numeric Tunnel makes real waiting visible without manufacturing delay.

Not For

Not for fast pages, fake progress, manufactured waits, or loaders that hide usable content.


Performance Budget

Tie animation to real load state, clean up timers, and avoid blocking first paint.


Accessibility and Mobile

Use aria-busy on loading regions and concise status text. On mobile and reduced motion, prefer static progress text or skeletons.


Common Mistakes

  • Using Numeric Tunnel on a page that already loads quickly.
  • Faking determinate progress.
  • Announcing every animation frame to assistive technology.

Changelog

v1.1.0

Jul 21, 2026
Add prefers-reduced-motion support: ring rotation and the end-of-tunnel warp/distortion effect are both skipped

Props

PropTypeDefaultDescription
digitCountnumber50Number of digits rendered in the tunnel.
speednumber1Forward motion speed.
depthnumber900Visual tunnel depth.
textColorstring#ffffffDigit color.
backgroundColorstring#000000Tunnel background color.

Frequently Asked Questions

When should I use Numeric Tunnel?

Use it when a real, measurable wait — an upload, asset warm-up, or initialization — needs numbers moving through actual progress.

Request a Custom Loader Animation

Need a custom effect? Tell us what to create.