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

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.
npx hyperiux add numeric-tunnelimport NumericTunnel from '@/components/effects/numeric-tunnel'
const page = () => {
return (
<NumericTunnel />
)
}
// 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>);
}
'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>);
}
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.
Not for fast pages, fake progress, manufactured waits, or loaders that hide usable content.
Tie animation to real load state, clean up timers, and avoid blocking first paint.
Use aria-busy on loading regions and concise status text. On mobile and reduced motion, prefer static progress text or skeletons.
| Prop | Type | Default | Description |
|---|---|---|---|
digitCount | number | 50 | Number of digits rendered in the tunnel. |
speed | number | 1 | Forward motion speed. |
depth | number | 900 | Visual tunnel depth. |
textColor | string | #ffffff | Digit color. |
backgroundColor | string | #000000 | Tunnel background color. |
Use it when a real, measurable wait — an upload, asset warm-up, or initialization — needs numbers moving through actual progress.
Determinate loaders should always map to real progress. Fake progress damages trust.
Use aria-busy on the loading region and concise status text in a polite live region when useful.
Use skeletons when the layout is known and content streams into place.
Use static progress text, a simple bar, or skeleton content instead of looping motion.
Yes. A custom version should define motion rules, visual treatment, performance budget, fallback behavior, source handoff, and implementation notes for the specific page or launch context.
Need a custom effect? Tell us what to create.