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