carousel-3d

PreviousNext
Docs API Reference
uicapsuleblock

Preview

Loading preview…
/carousel-3d.tsx
import type { Group, Mesh, Texture } from "three";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
  Canvas,
  extend,
  Frameloop,
  ThreeToJSXElements,
  useFrame,
  useLoader,
  useThree,
} from "@react-three/fiber";
import { DoubleSide, SRGBColorSpace, TextureLoader } from "three";
import {
  abs,
  add,
  div,
  length,
  max,
  mix,
  mul,
  positionLocal,
  smoothstep,
  sub,
  texture,
  uv,
  vec4,
} from "three/tsl";
import * as THREE from "three/tsl";
import { NodeMaterial, WebGPURenderer } from "three/webgpu";

declare module "@react-three/fiber" {
  interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
}

extend(THREE as any);

export const ImageCarouselCanvas = ({
  backgroundColor = "#6F6D66",
  children,
}: {
  backgroundColor?: string;
  children: React.ReactNode;
}) => {
  const [frameloop, setFrameloop] = useState<Frameloop>("never");

  return (
    <Canvas
      shadows
      camera={{ position: [0, 0, 12], fov: 45 }}
      frameloop={frameloop}
      gl={async (props) => {
        const renderer = new WebGPURenderer(props as any);
        renderer.init().then(() => {
          setFrameloop("always");
        });
        return renderer;
      }}
    >
      <color attach="background" args={[backgroundColor]} />
      {children}
    </Canvas>
  );
};

type ImageCarouselProps = {
  images?: string[];
  radius?: number;
  imageWidth?: number;
  cornerRadius?: number;
  bendAmount?: number;
  centerOpacity?: number;
  adjacentOpacity?: number;
  farOpacity?: number;
  friction?: number;
  wheelSensitivity?: number;
  dragSensitivity?: number;
  enableSnapping?: boolean;
  autorotate?: boolean;
  autorotateSpeed?: number;
};

export const ImageCarousel = ({
  images = [],
  radius = 5,
  imageWidth = 3,
  cornerRadius = 0.15,
  bendAmount = 0.1,
  centerOpacity = 1.0,
  adjacentOpacity = 1.0,
  farOpacity = 1.0,
  friction = 0.95,
  wheelSensitivity = 0.002,
  dragSensitivity = 0.0003,
  enableSnapping = true,
  autorotate = true,
  autorotateSpeed = 0.02,
}: ImageCarouselProps) => {
  const groupRef = useRef<Group | null>(null);
  const { gl, viewport } = useThree();
  const [currentIndex, setCurrentIndex] = useState(0);

  // Smooth rotation state
  const rotationRef = useRef(0);
  const velocityRef = useRef(0);
  const isDragging = useRef(false);
  const lastMouseX = useRef(0);
  const targetRotationRef = useRef(0);
  const isSnapping = useRef(false);

  // Animation state for fade-in and spin-in
  const [isAnimatingIn, setIsAnimatingIn] = useState(true);
  const animationProgress = useRef(0);
  const initialRotation = useRef(-Math.PI * 0.5); // Start rotated 90 degrees

  const textures = useLoader(TextureLoader, images, (loader) => {
    loader.crossOrigin = "anonymous";
  }) as Texture[];

  // Set correct colorSpace for all textures
  textures.forEach((texture) => {
    texture.colorSpace = SRGBColorSpace;
  });

  // Mouse wheel handler
  const handleWheel = (event) => {
    event.preventDefault();
    // Apply wheel sensitivity properly scaled
    const wheelForce = event.deltaY * wheelSensitivity;
    velocityRef.current += wheelForce;
    isSnapping.current = false; // Stop snapping when user interacts
  };

  // Mouse drag handlers
  const handleMouseDown = (event) => {
    isDragging.current = true;
    lastMouseX.current = event.clientX;
    gl.domElement.style.cursor = "grabbing";
  };

  const handleMouseMove = (event) => {
    if (!isDragging.current) return;

    const deltaX = event.clientX - lastMouseX.current;
    // Apply drag sensitivity properly scaled
    const dragForce = deltaX * dragSensitivity;
    velocityRef.current += dragForce;
    lastMouseX.current = event.clientX;
    isSnapping.current = false; // Stop snapping when user drags
  };

  const handleMouseUp = () => {
    isDragging.current = false;
    gl.domElement.style.cursor = "grab";
  };

  // Touch handlers for mobile
  const handleTouchStart = (event) => {
    isDragging.current = true;
    lastMouseX.current = event.touches[0].clientX;
  };

  const handleTouchMove = (event) => {
    if (!isDragging.current) return;

    const deltaX = event.touches[0].clientX - lastMouseX.current;
    // Apply drag sensitivity properly scaled
    const dragForce = deltaX * dragSensitivity;
    velocityRef.current += dragForce;
    lastMouseX.current = event.touches[0].clientX;
  };

  const handleTouchEnd = () => {
    isDragging.current = false;
  };

  // Event listeners setup
  useEffect(() => {
    const canvas = gl.domElement;

    canvas.style.cursor = "grab";
    canvas.addEventListener("wheel", handleWheel, { passive: false });
    canvas.addEventListener("mousedown", handleMouseDown);
    canvas.addEventListener("mousemove", handleMouseMove);
    canvas.addEventListener("mouseup", handleMouseUp);
    canvas.addEventListener("mouseleave", handleMouseUp);

    // Touch events
    canvas.addEventListener("touchstart", handleTouchStart, { passive: true });
    canvas.addEventListener("touchmove", handleTouchMove, { passive: true });
    canvas.addEventListener("touchend", handleTouchEnd);

    return () => {
      canvas.removeEventListener("wheel", handleWheel);
      canvas.removeEventListener("mousedown", handleMouseDown);
      canvas.removeEventListener("mousemove", handleMouseMove);
      canvas.removeEventListener("mouseup", handleMouseUp);
      canvas.removeEventListener("mouseleave", handleMouseUp);
      canvas.removeEventListener("touchstart", handleTouchStart);
      canvas.removeEventListener("touchmove", handleTouchMove);
      canvas.removeEventListener("touchend", handleTouchEnd);
    };
  }, [gl.domElement]);

  // Compute responsive image plane size from viewport
  const baseAspect = 9 / 16; // 16:9 aspect ratio (height/width)
  // Scale down images on smaller viewports, keep original size on large screens
  const sizeScale = Math.min(1, viewport.width / 10);
  const planeWidth = imageWidth * sizeScale;
  const planeHeight = planeWidth * baseAspect;

  // Smooth animation with friction and snapping
  useFrame((state, delta) => {
    if (!images.length) return;
    if (groupRef.current) {
      // Handle entrance animation
      if (isAnimatingIn) {
        animationProgress.current += delta * 0.5; // 2 seconds duration

        if (animationProgress.current >= 1) {
          animationProgress.current = 1;
          setIsAnimatingIn(false);
        }

        // Smooth easing function
        const easedProgress = 1 - Math.pow(1 - animationProgress.current, 3);

        // Apply entrance animation
        groupRef.current.rotation.y =
          initialRotation.current +
          (rotationRef.current - initialRotation.current) * easedProgress;

        // Set opacity for fade-in effect
        groupRef.current.children.forEach((child: any) => {
          if (child.material) {
            child.material.opacity = easedProgress;
          }
        });

        return; // Skip normal rotation logic during entrance
      }

      // Handle autorotate
      if (autorotate && !isDragging.current && !isSnapping.current) {
        velocityRef.current += autorotateSpeed * delta;
      }

      const velocityThreshold = 0.002;

      // Apply friction using delta time for frame-rate independence
      const frictionFactor = Math.pow(friction, delta * 60); // Normalize to 60fps
      velocityRef.current *= frictionFactor;

      // Check if we should start snapping
      if (
        enableSnapping &&
        !isDragging.current &&
        !isSnapping.current &&
        Math.abs(velocityRef.current) < velocityThreshold
      ) {
        // Calculate which image we're closest to
        const anglePerImage = (Math.PI * 2) / images.length;
        const currentRotation = rotationRef.current;

        // Simple and reliable snapping logic
        const currentAngle = -currentRotation; // Convert back to positive angle
        const normalizedAngle =
          ((currentAngle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);

        // Find which image we're closest to
        const imageFloat = normalizedAngle / anglePerImage;
        const nearestImageIndex = Math.round(imageFloat) % images.length;

        // Calculate target rotation (keeping it negative as expected)
        const targetRotation = -(nearestImageIndex * anglePerImage);

        // Adjust target to be close to current rotation (avoid long rotations)
        let bestTargetRotation = targetRotation;
        const diff1 = Math.abs(targetRotation - currentRotation);
        const diff2 = Math.abs(targetRotation + Math.PI * 2 - currentRotation);
        const diff3 = Math.abs(targetRotation - Math.PI * 2 - currentRotation);

        if (diff2 < diff1 && diff2 < diff3) {
          bestTargetRotation = targetRotation + Math.PI * 2;
        } else if (diff3 < diff1 && diff3 < diff2) {
          bestTargetRotation = targetRotation - Math.PI * 2;
        }

        targetRotationRef.current = bestTargetRotation;
        isSnapping.current = true;
        velocityRef.current = 0;
      }

      // Handle snapping animation
      if (isSnapping.current) {
        let diff = targetRotationRef.current - rotationRef.current;

        // Handle wraparound - choose shortest path
        if (Math.abs(diff) > Math.PI) {
          if (diff > 0) {
            diff -= Math.PI * 2;
          } else {
            diff += Math.PI * 2;
          }
        }

        const snapSpeed = 0.15;

        if (Math.abs(diff) < 0.005) {
          rotationRef.current = targetRotationRef.current;
          isSnapping.current = false;
        } else {
          rotationRef.current += diff * snapSpeed;
        }
      } else {
        // Update rotation normally with delta time
        rotationRef.current += velocityRef.current * delta * 60; // Normalize to 60fps
      }

      // Apply smooth rotation to the group
      groupRef.current.rotation.y = rotationRef.current;

      // Update current index based on rotation
      const anglePerImage = (Math.PI * 2) / images.length;
      const currentAngle = -rotationRef.current;
      const normalizedAngle =
        ((currentAngle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
      const newIndex =
        Math.round(normalizedAngle / anglePerImage) % images.length;

      if (newIndex !== currentIndex) {
        setCurrentIndex(newIndex);
      }
    }
  });

  // Removed click-to-change functionality

  const ImagePlane = ({
    texture: imageTexture,
    index,
    total,
  }: {
    texture: Texture;
    index: number;
    total: number;
  }) => {
    const meshRef = useRef<Mesh | null>(null);
    const angle = (index * Math.PI * 2) / total;
    const x = Math.sin(angle) * radius;
    const z = Math.cos(angle) * radius;

    // Use fixed 16:9 aspect ratio for consistent appearance
    const planeHeightForImage = planeWidth * (9 / 16);

    // Calculate image position relative to current center
    const getImagePosition = () => {
      const totalImages = images.length;
      let distance = Math.abs(index - currentIndex);

      // Handle wraparound (e.g., if we have 6 images, image 0 and 5 are adjacent)
      if (distance > totalImages / 2) {
        distance = totalImages - distance;
      }

      return distance;
    };

    const getOpacity = () => {
      const position = getImagePosition();

      // Smooth opacity transitions between positions
      if (position === 0) {
        return centerOpacity; // Center image
      } else if (position === 1) {
        return adjacentOpacity; // Adjacent images
      } else {
        // Smooth interpolation for positions beyond adjacent
        const maxPosition = Math.ceil(images.length / 2);
        const normalizedPosition = Math.min(position, maxPosition);
        const t = (normalizedPosition - 1) / (maxPosition - 1);
        return adjacentOpacity + (farOpacity - adjacentOpacity) * t;
      }
    };

    useFrame(() => {
      if (meshRef.current) {
        meshRef.current.lookAt(0, meshRef.current.position.y, 0);
      }
    });

    // Create TSL material with rounded corners and cylindrical bending
    const roundedCornersMaterial = useMemo(() => {
      const material = new NodeMaterial();

      // Individual plane cylindrical bending
      const position = positionLocal;

      // Bend each plane individually to look curved
      // The bend should curve the plane inward toward the carousel center (like a cylinder segment)
      const bendStrength = mul(bendAmount, 2.0); // More pronounced curvature
      const normalizedX = div(position.x, planeWidth * 0.5); // Normalize to half-width for better curve
      const curvature = mul(mul(normalizedX, normalizedX), bendStrength);
      const bentZ = add(position.z, curvature); // Changed from sub to add for inward curve

      material.positionNode = vec4(position.x, position.y, bentZ, position.w);

      const uvCoords = uv();

      const imageColor = texture(imageTexture, uvCoords);

      // Calculate distance from center for rounded rectangle
      const center = sub(uvCoords, 0.5);
      const d = length(max(sub(abs(center), 0.5 - cornerRadius), 0.0));

      // Create smooth mask for rounded corners
      const mask = smoothstep(cornerRadius + 0.01, cornerRadius - 0.01, d);

      // Mix transparent and image color based on mask
      const finalColor = mix(vec4(0, 0, 0, 0), imageColor, mask);

      material.colorNode = finalColor;
      material.transparent = true;
      material.side = DoubleSide;

      return material;
    }, [
      imageTexture,
      cornerRadius,
      bendAmount,
      planeWidth,
      planeHeightForImage,
    ]);

    // Update opacity based on position
    roundedCornersMaterial.opacity = getOpacity();

    // Apply entrance animation opacity if still animating in
    if (isAnimatingIn) {
      roundedCornersMaterial.opacity = 0; // Start fully transparent
    }

    return (
      <mesh
        ref={meshRef}
        position={[x, 0, z]}
        material={roundedCornersMaterial}
      >
        <planeGeometry args={[planeWidth, planeHeightForImage, 32, 32]} />
      </mesh>
    );
  };

  if (!images.length) return null;

  return (
    <group ref={groupRef}>
      {images.map((image, index) => (
        <ImagePlane
          key={`${image}-${index}`}
          texture={textures[index]}
          index={index}
          total={images.length}
        />
      ))}
      <pointLight position={[0, 2, 0]} intensity={0.5} />
      <ambientLight intensity={0.3} />
    </group>
  );
};

Installation

npx shadcn@latest add @uicapsule/carousel-3d

Usage

import { Carousel3d } from "@/components/carousel-3d"
<Carousel3d />