effects#tunnel#vortex
Hypnotic Tunnel
Infinite depth zoom effect — fly through a procedural tunnel that reacts to mouse position
Implementation Guide
# Hypnotic Tunnel Effect
> Infinite depth zoom effect — fly through a procedural tunnel that reacts to mouse position. Classic demo-scene aesthetic, hypnotic and immersive.
## Quick Start
```tsx
import HypnoticTunnel from '@/components/examples/effects/hypnotic-tunnel';
export default function Hero() {
return (
<div className="relative h-screen">
<HypnoticTunnel colors={['#007AFF', '#5856D6']} />
<div className="relative z-10">{/* Your content goes here */}</div>
</div>
);
}
```
## Props
| Prop | Type | Default | Description |
| -------------- | --------------------------------- | -------------------------------------------- | ---------------------------------------- |
| colors | string[] | ['#007AFF', '#5856D6', '#FF3B30', '#FF9500'] | Array of hex colors for the tunnel rings |
| speed | number | 1 | Animation speed multiplier (0.1 - 3.0) |
| ringCount | number | 30 | Number of rings in the tunnel |
| shape | 'circle' \| 'hexagon' \| 'square' | 'circle' | Shape of the tunnel rings |
| mouseInfluence | number | 0.5 | How much mouse position affects tilt |
## Full Implementation
```tsx
'use client';
import { useEffect, useRef, useCallback } from 'react';
type TunnelShape = 'circle' | 'hexagon' | 'square';
interface HypnoticTunnelProps {
colors?: string[];
speed?: number;
ringCount?: number;
shape?: TunnelShape;
mouseInfluence?: number;
}
interface Ring {
z: number;
rotation: number;
colorIndex: number;
}
export default function HypnoticTunnel({
colors = ['#007AFF', '#5856D6', '#FF3B30', '#FF9500'],
speed = 1,
ringCount = 30,
shape = 'circle',
mouseInfluence = 0.5,
}: HypnoticTunnelProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const ringsRef = useRef<Ring[]>([]);
const mouseRef = useRef({ x: 0.5, y: 0.5 });
const animationFrameRef = useRef<number>(0);
const prefersReducedMotionRef = useRef<boolean>(false);
const hexToRgb = useCallback((hex: string): { r: number; g: number; b: number } => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 122, b: 255 };
}, []);
const initializeRings = useCallback(() => {
const rings: Ring[] = [];
for (let i = 0; i < ringCount; i++) {
rings.push({
z: (i / ringCount) * 1000,
rotation: (i * Math.PI) / 12,
colorIndex: i % colors.length,
});
}
return rings;
}, [ringCount, colors.length]);
const drawShape = useCallback(
(
context: CanvasRenderingContext2D,
centerX: number,
centerY: number,
size: number,
rotation: number,
shapeType: TunnelShape
) => {
context.save();
context.translate(centerX, centerY);
context.rotate(rotation);
context.beginPath();
switch (shapeType) {
case 'hexagon': {
const sides = 6;
for (let i = 0; i <= sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
const x = Math.cos(angle) * size;
const y = Math.sin(angle) * size;
if (i === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
}
break;
}
case 'square': {
const halfSize = size * 0.8;
context.rect(-halfSize, -halfSize, halfSize * 2, halfSize * 2);
break;
}
case 'circle':
default:
context.arc(0, 0, size, 0, Math.PI * 2);
break;
}
context.restore();
},
[]
);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotionRef.current = mediaQuery.matches;
const handleMotionPreferenceChange = (event: MediaQueryListEvent) => {
prefersReducedMotionRef.current = event.matches;
};
mediaQuery.addEventListener('change', handleMotionPreferenceChange);
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
context.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
};
resizeCanvas();
ringsRef.current = initializeRings();
window.addEventListener('resize', resizeCanvas);
const handleMouseMove = (event: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouseRef.current = {
x: (event.clientX - rect.left) / rect.width,
y: (event.clientY - rect.top) / rect.height,
};
};
const handleMouseLeave = () => {
mouseRef.current = { x: 0.5, y: 0.5 };
};
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseleave', handleMouseLeave);
const animate = () => {
const rect = canvas.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
const centerX = width / 2;
const centerY = height / 2;
const isReducedMotion = prefersReducedMotionRef.current;
const motionMultiplier = isReducedMotion ? 0.1 : 1;
// Clear with dark background
context.fillStyle = '#0a0a0a';
context.fillRect(0, 0, width, height);
// Calculate mouse offset for tilt effect
const mouseOffsetX = (mouseRef.current.x - 0.5) * width * mouseInfluence;
const mouseOffsetY = (mouseRef.current.y - 0.5) * height * mouseInfluence;
const tiltCenterX = centerX + mouseOffsetX;
const tiltCenterY = centerY + mouseOffsetY;
const rings = ringsRef.current;
const maxZ = 1000;
const moveSpeed = 8 * speed * motionMultiplier;
// Sort rings by z-depth (furthest first)
const sortedRings = [...rings].sort((a, b) => b.z - a.z);
for (const ring of sortedRings) {
// Move ring toward viewer
ring.z -= moveSpeed;
ring.rotation += 0.005 * speed * motionMultiplier;
// Reset ring when it passes the camera
if (ring.z <= 0) {
ring.z = maxZ;
ring.colorIndex = (ring.colorIndex + 1) % colors.length;
}
// Calculate perspective scale (closer = larger)
const perspective = 400;
const scale = perspective / (perspective + ring.z);
const size = Math.max(0, (width * 0.6 + height * 0.6) * scale);
// Calculate alpha based on distance (fade in from far, fade out when close)
const normalizedZ = ring.z / maxZ;
let alpha = 1;
if (normalizedZ > 0.8) {
alpha = (1 - normalizedZ) * 5; // Fade in from far
} else if (normalizedZ < 0.1) {
alpha = normalizedZ * 10; // Fade out when close
}
alpha = Math.max(0, Math.min(1, alpha)) * 0.8;
// Get color
const color = colors[ring.colorIndex];
const rgb = hexToRgb(color);
// Calculate position with tilt offset (closer rings offset more)
const offsetScale = 1 - normalizedZ;
const ringX = tiltCenterX + (tiltCenterX - centerX) * offsetScale * 0.5;
const ringY = tiltCenterY + (tiltCenterY - centerY) * offsetScale * 0.5;
// Draw ring outline
context.strokeStyle = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
context.lineWidth = Math.max(1, 3 * scale);
drawShape(context, ringX, ringY, size, ring.rotation, shape);
context.stroke();
// Draw inner glow
const glowAlpha = alpha * 0.3;
context.strokeStyle = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${glowAlpha})`;
context.lineWidth = Math.max(1, 8 * scale);
drawShape(context, ringX, ringY, size * 0.95, ring.rotation, shape);
context.stroke();
}
// Draw subtle center glow
const gradient = context.createRadialGradient(
tiltCenterX,
tiltCenterY,
0,
tiltCenterX,
tiltCenterY,
50
);
gradient.addColorStop(0, 'rgba(255, 255, 255, 0.1)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
context.fillStyle = gradient;
context.beginPath();
context.arc(tiltCenterX, tiltCenterY, 50, 0, Math.PI * 2);
context.fill();
animationFrameRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
cancelAnimationFrame(animationFrameRef.current);
window.removeEventListener('resize', resizeCanvas);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseleave', handleMouseLeave);
mediaQuery.removeEventListener('change', handleMotionPreferenceChange);
};
}, [colors, speed, ringCount, shape, mouseInfluence, initializeRings, hexToRgb, drawShape]);
return (
<canvas
ref={canvasRef}
className="h-full w-full"
style={{ background: '#0a0a0a' }}
aria-hidden="true"
/>
);
}
```
## Customization
### Brand Colors
Match your brand with a custom color palette:
```tsx
<HypnoticTunnel colors={['#FF3B30', '#FF9500']} />
```
### Hexagonal Vortex
Create a sci-fi aesthetic with hexagon shapes:
```tsx
<HypnoticTunnel shape="hexagon" colors={['#00FF88', '#00AAFF']} speed={1.5} />
```
### Slow Hypnotic Mode
For a meditative, trance-like experience:
```tsx
<HypnoticTunnel speed={0.3} ringCount={50} mouseInfluence={0.2} />
```
### High Speed Warp
Intense warp-speed effect for high-energy pages:
```tsx
<HypnoticTunnel speed={2.5} ringCount={40} mouseInfluence={0.8} />
```
### Geometric Square Tunnel
Sharp, architectural aesthetic:
```tsx
<HypnoticTunnel shape="square" colors={['#FFFFFF', '#666666']} />
```
### Retro Demo-Scene
Classic 90s demo-scene vibe:
```tsx
<HypnoticTunnel
shape="hexagon"
colors={['#FF00FF', '#00FFFF', '#FFFF00']}
speed={1.2}
ringCount={35}
/>
```
## Performance Considerations
- **Reduce `ringCount`** on mobile devices (15-20 recommended)
- Lower `speed` values reduce draw calls per frame
- The effect uses hardware-accelerated canvas rendering
- Canvas is automatically scaled for retina displays using devicePixelRatio
- Sorting rings by depth is O(n log n) per frame, so keep ringCount reasonable
### Mobile Optimization Example
```tsx
import { useEffect, useState } from 'react';
function ResponsiveHypnoticTunnel() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(window.innerWidth < 768);
}, []);
return <HypnoticTunnel ringCount={isMobile ? 15 : 30} speed={isMobile ? 0.8 : 1} />;
}
```
## Accessibility
- Respects `prefers-reduced-motion` — animation speed is reduced to 10% when users prefer reduced motion
- The canvas has `aria-hidden="true"` as it is purely decorative
- Ensure content above the effect has proper contrast ratios
- Content should have `position: relative; z-index: 10` to appear above the canvas
- Consider adding a static fallback for users who disable animations entirely
## Browser Support
- Modern browsers with Canvas 2D support
- requestAnimationFrame support required
- MediaQueryList.addEventListener support (Chrome 39+, Firefox 55+, Safari 14+)
- Works in all modern browsers including Chrome, Firefox, Safari, and Edge