shadimage
uicapsule
blockcarousel-3dsource: ok · hit

Carousel 3d

npx shadcn add "https://uicapsule.com/r/carousel-3d.json"
free

Visual style

3d

Interaction

carousel

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · heavythree
webgl@react-three/fiberthree

Files2 · source cached 10/15/2026, 4:43:47 AM

/carousel-3d.tsx
import { useEffect, useMemo, useRef } from "react";
import type { ReactNode, RefObject } from "react";
import type { BufferGeometry, Group, Mesh as MeshType, Texture } from "three";

import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
import { DoubleSide, Mesh, SRGBColorSpace, TextureLoader } from "three";
import {
  abs,
  add,
  div,
  length,
  max,
  mix,
  mul,
  positionLocal,
  smoothstep,
  sub,
  texture,
  uv,
  vec3,
  vec4,
} from "three/tsl";
import { NodeMaterial, WebGPURenderer } from "three/webgpu";

/** Cards are laid out on the ring at a fixed 16:9 ratio regardless of source image. */
const IMAGE_ASPECT = 9 / 16;
/** Viewport width (world units) at and above which cards render at their full `imageWidth`. */
const FULL_SIZE_VIEWPORT_WIDTH = 10;
/** Ring starts a quarter turn away and unwinds to 0 during the entrance. */
const INITIAL_ROTATION = -Math.PI * 0.5;
const ENTRANCE_DURATION_SECONDS = 2;
/** Friction/velocity are tuned per 60fps frame, then rescaled by the real delta. */
const REFERENCE_FPS = 60;
/** Below this angular velocity the ring gives up and snaps to the nearest card. */
const VELOCITY_THRESHOLD = 0.002;
/** Fraction of the remaining snap distance consumed each frame. */
const SNAP_SPEED = 0.15;
/** Snap is considered complete once within this many radians of the target. */
const SNAP_EPSILON = 0.005;
const TAU = Math.PI * 2;

// Nearest image angle, expressed in whichever full turn is closest to the
// current rotation so the snap never travels the long way round.
const closestSnapRotation = (currentRotation: number, anglePerImage: number, count: number) => {
  const normalizedAngle = ((-currentRotation % TAU) + TAU) % TAU;
  const nearestImageIndex = Math.round(normalizedAngle / anglePerImage) % count;
  const targetRotation = -(nearestImageIndex * anglePerImage);

  const diff1 = Math.abs(targetRotation - currentRotation);
  const diff2 = Math.abs(targetRotation + TAU - currentRotation);
  const diff3 = Math.abs(targetRotation - TAU - currentRotation);

  if (diff2 < diff1 && diff2 < diff3) {
    return targetRotation + TAU;
  }
  if (diff3 < diff1 && diff3 < diff2) {
    return targetRotation - TAU;
  }
  return targetRotation;
};

const setPlaneOpacity = (group: Group, opacity: number) => {
  for (const child of group.children) {
    if (child instanceof Mesh && child.material instanceof NodeMaterial) {
      child.material.opacity = opacity;
    }
  }
};
/** Extra curvature applied on top of `bendAmount`. */
const BEND_MULTIPLIER = 2;
/** Half-width of the smoothstep band that antialiases the rounded-corner mask. */
const CORNER_MASK_FEATHER = 0.01;

export const ImageCarouselCanvas = ({
  backgroundColor = "#6F6D66",
  children,
}: {
  backgroundColor?: string;
  children: ReactNode;
}) => (
  <Canvas
    shadows
    camera={{ fov: 45, position: [0, 0, 12] }}
    className="cursor-grab active:cursor-grabbing"
    gl={async (props) => {
      if (!(props.canvas instanceof HTMLCanvasElement)) {
        throw new Error("ImageCarouselCanvas requires an HTML canvas");
      }
      const renderer = new WebGPURenderer({
        alpha: props.alpha,
        antialias: props.antialias,
        canvas: props.canvas,
        depth: props.depth,
        stencil: props.stencil,
      });
      await renderer.init();
      return renderer;
    }}
  >
    <color attach="background" args={[backgroundColor]} />
    {children}
  </Canvas>
);

interface ImagePlaneProps {
  texture: Texture;
  index: number;
  total: number;
  radius: number;
  planeWidth: number;
  cornerRadius: number;
  bendAmount: number;
  centerOpacity: number;
  adjacentOpacity: number;
  farOpacity: number;
  /** Read per-frame rather than as a prop so ring rotation never re-renders the tree. */
  currentIndexRef: RefObject<number>;
}

const ImagePlane = ({
  texture: imageTexture,
  index,
  total,
  radius,
  planeWidth,
  cornerRadius,
  bendAmount,
  centerOpacity,
  adjacentOpacity,
  farOpacity,
  currentIndexRef,
}: ImagePlaneProps) => {
  const meshRef = useRef<MeshType<BufferGeometry, NodeMaterial> | null>(null);

  const angle = (index * TAU) / total;
  const x = Math.sin(angle) * radius;
  const z = Math.cos(angle) * radius;
  const planeHeight = planeWidth * IMAGE_ASPECT;

  const roundedCornersMaterial = useMemo(() => {
    const material = new NodeMaterial();

    const position = positionLocal;

    const bendStrength = mul(bendAmount, BEND_MULTIPLIER);
    const normalizedX = div(position.x, planeWidth * 0.5);
    const curvature = mul(mul(normalizedX, normalizedX), bendStrength);
    const bentZ = add(position.z, curvature);

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

    const uvCoords = uv();

    const imageColor = texture(imageTexture, uvCoords);

    const center = sub(uvCoords, 0.5);
    const d = length(max(sub(abs(center), 0.5 - cornerRadius), 0));

    const mask = smoothstep(
      cornerRadius + CORNER_MASK_FEATHER,
      cornerRadius - CORNER_MASK_FEATHER,
      d,
    );

    const finalColor = mix(vec4(0, 0, 0, 0), imageColor, mask);

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

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

  useEffect(() => () => roundedCornersMaterial.dispose(), [roundedCornersMaterial]);

  useFrame(() => {
    const mesh = meshRef.current;
    if (!mesh) {
      return;
    }

    mesh.lookAt(0, mesh.position.y, 0);

    // Ring distance to the centred card, wrapping around the seam.
    const rawDistance = Math.abs(index - currentIndexRef.current);
    const distance = rawDistance > total / 2 ? total - rawDistance : rawDistance;

    if (distance === 0) {
      mesh.material.opacity = centerOpacity;
      return;
    }
    if (distance === 1) {
      mesh.material.opacity = adjacentOpacity;
      return;
    }
    // Smooth interpolation for positions beyond adjacent
    const maxDistance = Math.ceil(total / 2);
    const t = maxDistance > 1 ? (Math.min(distance, maxDistance) - 1) / (maxDistance - 1) : 0;
    mesh.material.opacity = adjacentOpacity + (farOpacity - adjacentOpacity) * t;
  });

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

const EMPTY_IMAGES: string[] = [];

interface 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 = EMPTY_IMAGES,
  radius = 5,
  imageWidth = 3,
  cornerRadius = 0.15,
  bendAmount = 0.1,
  centerOpacity = 1,
  adjacentOpacity = 1,
  farOpacity = 1,
  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 rotationRef = useRef(0);
  const velocityRef = useRef(0);
  const isDragging = useRef(false);
  const lastPointerX = useRef(0);
  const targetRotationRef = useRef(0);
  const isSnapping = useRef(false);
  const currentIndexRef = useRef(0);

  const isAnimatingIn = useRef(true);
  const animationProgress = useRef(0);

  const sourceTextures = useLoader(TextureLoader, images, (loader) => {
    loader.crossOrigin = "anonymous";
  });

  const textures = useMemo(
    () =>
      sourceTextures.map((source) => {
        const cloned = source.clone();
        cloned.colorSpace = SRGBColorSpace;
        return cloned;
      }),
    [sourceTextures],
  );

  useEffect(
    () => () => {
      for (const cloned of textures) {
        cloned.dispose();
      }
    },
    [textures],
  );

  useEffect(() => {
    const canvas = gl.domElement;

    const handleWheel = (event: WheelEvent) => {
      event.preventDefault();
      velocityRef.current += event.deltaY * wheelSensitivity;
      isSnapping.current = false;
    };

    const startDrag = (clientX: number) => {
      isDragging.current = true;
      lastPointerX.current = clientX;
    };

    const moveDrag = (clientX: number) => {
      if (!isDragging.current) {
        return;
      }
      velocityRef.current += (clientX - lastPointerX.current) * dragSensitivity;
      lastPointerX.current = clientX;
      isSnapping.current = false;
    };

    const handleMouseDown = (event: MouseEvent) => {
      startDrag(event.clientX);
    };

    const handleMouseMove = (event: MouseEvent) => {
      moveDrag(event.clientX);
    };

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

    const handleTouchStart = (event: TouchEvent) => {
      const [touch] = event.touches;
      if (touch) {
        startDrag(touch.clientX);
      }
    };

    const handleTouchMove = (event: TouchEvent) => {
      const [touch] = event.touches;
      if (touch) {
        moveDrag(touch.clientX);
      }
    };

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

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

    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, wheelSensitivity, dragSensitivity]);

  // Compute responsive image plane size from viewport.
  // Scale down images on smaller viewports, keep original size on large screens.
  const sizeScale = Math.min(1, viewport.width / FULL_SIZE_VIEWPORT_WIDTH);
  const planeWidth = imageWidth * sizeScale;

  useFrame((state, delta) => {
    if (!images.length) {
      return;
    }
    const group = groupRef.current;
    if (!group) {
      return;
    }

    if (isAnimatingIn.current) {
      animationProgress.current = Math.min(
        1,
        animationProgress.current + delta / ENTRANCE_DURATION_SECONDS,
      );
      if (animationProgress.current >= 1) {
        isAnimatingIn.current = false;
      }

      const easedProgress = 1 - (1 - animationProgress.current) ** 3;

      group.rotation.y =
        INITIAL_ROTATION + (rotationRef.current - INITIAL_ROTATION) * easedProgress;

      // Overwrite the per-plane opacity written earlier this frame with the fade-in value.
      setPlaneOpacity(group, easedProgress);

      return;
    }

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

    // Apply friction using delta time for frame-rate independence
    velocityRef.current *= friction ** (delta * REFERENCE_FPS);

    const anglePerImage = TAU / images.length;

    if (
      enableSnapping &&
      !isDragging.current &&
      !isSnapping.current &&
      Math.abs(velocityRef.current) < VELOCITY_THRESHOLD
    ) {
      targetRotationRef.current = closestSnapRotation(
        rotationRef.current,
        anglePerImage,
        images.length,
      );
      isSnapping.current = true;
      velocityRef.current = 0;
    }

    if (isSnapping.current) {
      let diff = targetRotationRef.current - rotationRef.current;

      // Handle wraparound - choose shortest path
      if (Math.abs(diff) > Math.PI) {
        diff += diff > 0 ? -TAU : TAU;
      }

      if (Math.abs(diff) < SNAP_EPSILON) {
        rotationRef.current = targetRotationRef.current;
        isSnapping.current = false;
      } else {
        rotationRef.current += diff * SNAP_SPEED;
      }
    } else {
      rotationRef.current += velocityRef.current * delta * REFERENCE_FPS;
    }

    group.rotation.y = rotationRef.current;

    const normalizedAngle = ((-rotationRef.current % TAU) + TAU) % TAU;
    currentIndexRef.current = Math.round(normalizedAngle / anglePerImage) % images.length;
  });

  if (!images.length) {
    return null;
  }

  return (
    <group ref={groupRef}>
      {textures.map((imageTexture, index) => (
        <ImagePlane
          key={imageTexture.uuid}
          texture={imageTexture}
          index={index}
          total={images.length}
          radius={radius}
          planeWidth={planeWidth}
          cornerRadius={cornerRadius}
          bendAmount={bendAmount}
          centerOpacity={centerOpacity}
          adjacentOpacity={adjacentOpacity}
          farOpacity={farOpacity}
          currentIndexRef={currentIndexRef}
        />
      ))}
      <pointLight position={[0, 2, 0]} intensity={0.5} />
      <ambientLight intensity={0.3} />
    </group>
  );
};
/preview.tsx
"use client";

import { ImageCarousel, ImageCarouselCanvas } from "./carousel-3d";

const rootUrl = "https://d24l2zb4cwkekfpl.public.blob.vercel-storage.com/carousel-3d";

const cards = [
  { src: `${rootUrl}/1.webp` },
  { src: `${rootUrl}/2.webp` },
  { src: `${rootUrl}/3.webp` },
  { src: `${rootUrl}/4.webp` },
  { src: `${rootUrl}/5.webp` },
  { src: `${rootUrl}/6.webp` },
  { src: `${rootUrl}/7.webp` },
  { src: `${rootUrl}/8.webp` },
];

const Preview = () => (
  <ImageCarouselCanvas>
    <ImageCarousel images={cards.map((card) => card.src)} />
  </ImageCarouselCanvas>
);

export default Preview;

More from uicapsule

Browse all components in this library →