{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["cards-grids","colorful"],"dependencies":["@react-three/fiber","three"],"description":"WebGPU 3D image carousel — cards ride a bent ring; scroll or drag to spin it with momentum, then it snaps to the nearest card as side cards fade back.","devDependencies":["@types/three"],"files":[{"content":"import { useEffect, useMemo, useRef } from \"react\";\nimport type { ReactNode, RefObject } from \"react\";\nimport type { BufferGeometry, Group, Mesh as MeshType, Texture } from \"three\";\n\nimport { Canvas, useFrame, useLoader, useThree } from \"@react-three/fiber\";\nimport { DoubleSide, Mesh, SRGBColorSpace, TextureLoader } from \"three\";\nimport {\n  abs,\n  add,\n  div,\n  length,\n  max,\n  mix,\n  mul,\n  positionLocal,\n  smoothstep,\n  sub,\n  texture,\n  uv,\n  vec3,\n  vec4,\n} from \"three/tsl\";\nimport { NodeMaterial, WebGPURenderer } from \"three/webgpu\";\n\n/** Cards are laid out on the ring at a fixed 16:9 ratio regardless of source image. */\nconst IMAGE_ASPECT = 9 / 16;\n/** Viewport width (world units) at and above which cards render at their full `imageWidth`. */\nconst FULL_SIZE_VIEWPORT_WIDTH = 10;\n/** Ring starts a quarter turn away and unwinds to 0 during the entrance. */\nconst INITIAL_ROTATION = -Math.PI * 0.5;\nconst ENTRANCE_DURATION_SECONDS = 2;\n/** Friction/velocity are tuned per 60fps frame, then rescaled by the real delta. */\nconst REFERENCE_FPS = 60;\n/** Below this angular velocity the ring gives up and snaps to the nearest card. */\nconst VELOCITY_THRESHOLD = 0.002;\n/** Fraction of the remaining snap distance consumed each frame. */\nconst SNAP_SPEED = 0.15;\n/** Snap is considered complete once within this many radians of the target. */\nconst SNAP_EPSILON = 0.005;\nconst TAU = Math.PI * 2;\n\n// Nearest image angle, expressed in whichever full turn is closest to the\n// current rotation so the snap never travels the long way round.\nconst closestSnapRotation = (currentRotation: number, anglePerImage: number, count: number) => {\n  const normalizedAngle = ((-currentRotation % TAU) + TAU) % TAU;\n  const nearestImageIndex = Math.round(normalizedAngle / anglePerImage) % count;\n  const targetRotation = -(nearestImageIndex * anglePerImage);\n\n  const diff1 = Math.abs(targetRotation - currentRotation);\n  const diff2 = Math.abs(targetRotation + TAU - currentRotation);\n  const diff3 = Math.abs(targetRotation - TAU - currentRotation);\n\n  if (diff2 < diff1 && diff2 < diff3) {\n    return targetRotation + TAU;\n  }\n  if (diff3 < diff1 && diff3 < diff2) {\n    return targetRotation - TAU;\n  }\n  return targetRotation;\n};\n\nconst setPlaneOpacity = (group: Group, opacity: number) => {\n  for (const child of group.children) {\n    if (child instanceof Mesh && child.material instanceof NodeMaterial) {\n      child.material.opacity = opacity;\n    }\n  }\n};\n/** Extra curvature applied on top of `bendAmount`. */\nconst BEND_MULTIPLIER = 2;\n/** Half-width of the smoothstep band that antialiases the rounded-corner mask. */\nconst CORNER_MASK_FEATHER = 0.01;\n\nexport const ImageCarouselCanvas = ({\n  backgroundColor = \"#6F6D66\",\n  children,\n}: {\n  backgroundColor?: string;\n  children: ReactNode;\n}) => (\n  <Canvas\n    shadows\n    camera={{ fov: 45, position: [0, 0, 12] }}\n    className=\"cursor-grab active:cursor-grabbing\"\n    gl={async (props) => {\n      if (!(props.canvas instanceof HTMLCanvasElement)) {\n        throw new Error(\"ImageCarouselCanvas requires an HTML canvas\");\n      }\n      const renderer = new WebGPURenderer({\n        alpha: props.alpha,\n        antialias: props.antialias,\n        canvas: props.canvas,\n        depth: props.depth,\n        stencil: props.stencil,\n      });\n      await renderer.init();\n      return renderer;\n    }}\n  >\n    <color attach=\"background\" args={[backgroundColor]} />\n    {children}\n  </Canvas>\n);\n\ninterface ImagePlaneProps {\n  texture: Texture;\n  index: number;\n  total: number;\n  radius: number;\n  planeWidth: number;\n  cornerRadius: number;\n  bendAmount: number;\n  centerOpacity: number;\n  adjacentOpacity: number;\n  farOpacity: number;\n  /** Read per-frame rather than as a prop so ring rotation never re-renders the tree. */\n  currentIndexRef: RefObject<number>;\n}\n\nconst ImagePlane = ({\n  texture: imageTexture,\n  index,\n  total,\n  radius,\n  planeWidth,\n  cornerRadius,\n  bendAmount,\n  centerOpacity,\n  adjacentOpacity,\n  farOpacity,\n  currentIndexRef,\n}: ImagePlaneProps) => {\n  const meshRef = useRef<MeshType<BufferGeometry, NodeMaterial> | null>(null);\n\n  const angle = (index * TAU) / total;\n  const x = Math.sin(angle) * radius;\n  const z = Math.cos(angle) * radius;\n  const planeHeight = planeWidth * IMAGE_ASPECT;\n\n  const roundedCornersMaterial = useMemo(() => {\n    const material = new NodeMaterial();\n\n    const position = positionLocal;\n\n    const bendStrength = mul(bendAmount, BEND_MULTIPLIER);\n    const normalizedX = div(position.x, planeWidth * 0.5);\n    const curvature = mul(mul(normalizedX, normalizedX), bendStrength);\n    const bentZ = add(position.z, curvature);\n\n    material.positionNode = vec3(position.x, position.y, bentZ);\n\n    const uvCoords = uv();\n\n    const imageColor = texture(imageTexture, uvCoords);\n\n    const center = sub(uvCoords, 0.5);\n    const d = length(max(sub(abs(center), 0.5 - cornerRadius), 0));\n\n    const mask = smoothstep(\n      cornerRadius + CORNER_MASK_FEATHER,\n      cornerRadius - CORNER_MASK_FEATHER,\n      d,\n    );\n\n    const finalColor = mix(vec4(0, 0, 0, 0), imageColor, mask);\n\n    material.colorNode = finalColor;\n    material.transparent = true;\n    material.side = DoubleSide;\n\n    return material;\n  }, [imageTexture, bendAmount, planeWidth, cornerRadius]);\n\n  useEffect(() => () => roundedCornersMaterial.dispose(), [roundedCornersMaterial]);\n\n  useFrame(() => {\n    const mesh = meshRef.current;\n    if (!mesh) {\n      return;\n    }\n\n    mesh.lookAt(0, mesh.position.y, 0);\n\n    // Ring distance to the centred card, wrapping around the seam.\n    const rawDistance = Math.abs(index - currentIndexRef.current);\n    const distance = rawDistance > total / 2 ? total - rawDistance : rawDistance;\n\n    if (distance === 0) {\n      mesh.material.opacity = centerOpacity;\n      return;\n    }\n    if (distance === 1) {\n      mesh.material.opacity = adjacentOpacity;\n      return;\n    }\n    // Smooth interpolation for positions beyond adjacent\n    const maxDistance = Math.ceil(total / 2);\n    const t = maxDistance > 1 ? (Math.min(distance, maxDistance) - 1) / (maxDistance - 1) : 0;\n    mesh.material.opacity = adjacentOpacity + (farOpacity - adjacentOpacity) * t;\n  });\n\n  return (\n    <mesh ref={meshRef} position={[x, 0, z]} material={roundedCornersMaterial}>\n      <planeGeometry args={[planeWidth, planeHeight, 32, 32]} />\n    </mesh>\n  );\n};\n\nconst EMPTY_IMAGES: string[] = [];\n\ninterface ImageCarouselProps {\n  images?: string[];\n  radius?: number;\n  imageWidth?: number;\n  cornerRadius?: number;\n  bendAmount?: number;\n  centerOpacity?: number;\n  adjacentOpacity?: number;\n  farOpacity?: number;\n  friction?: number;\n  wheelSensitivity?: number;\n  dragSensitivity?: number;\n  enableSnapping?: boolean;\n  autorotate?: boolean;\n  autorotateSpeed?: number;\n}\n\nexport const ImageCarousel = ({\n  images = EMPTY_IMAGES,\n  radius = 5,\n  imageWidth = 3,\n  cornerRadius = 0.15,\n  bendAmount = 0.1,\n  centerOpacity = 1,\n  adjacentOpacity = 1,\n  farOpacity = 1,\n  friction = 0.95,\n  wheelSensitivity = 0.002,\n  dragSensitivity = 0.0003,\n  enableSnapping = true,\n  autorotate = true,\n  autorotateSpeed = 0.02,\n}: ImageCarouselProps) => {\n  const groupRef = useRef<Group | null>(null);\n  const { gl, viewport } = useThree();\n\n  const rotationRef = useRef(0);\n  const velocityRef = useRef(0);\n  const isDragging = useRef(false);\n  const lastPointerX = useRef(0);\n  const targetRotationRef = useRef(0);\n  const isSnapping = useRef(false);\n  const currentIndexRef = useRef(0);\n\n  const isAnimatingIn = useRef(true);\n  const animationProgress = useRef(0);\n\n  const sourceTextures = useLoader(TextureLoader, images, (loader) => {\n    loader.crossOrigin = \"anonymous\";\n  });\n\n  const textures = useMemo(\n    () =>\n      sourceTextures.map((source) => {\n        const cloned = source.clone();\n        cloned.colorSpace = SRGBColorSpace;\n        return cloned;\n      }),\n    [sourceTextures],\n  );\n\n  useEffect(\n    () => () => {\n      for (const cloned of textures) {\n        cloned.dispose();\n      }\n    },\n    [textures],\n  );\n\n  useEffect(() => {\n    const canvas = gl.domElement;\n\n    const handleWheel = (event: WheelEvent) => {\n      event.preventDefault();\n      velocityRef.current += event.deltaY * wheelSensitivity;\n      isSnapping.current = false;\n    };\n\n    const startDrag = (clientX: number) => {\n      isDragging.current = true;\n      lastPointerX.current = clientX;\n    };\n\n    const moveDrag = (clientX: number) => {\n      if (!isDragging.current) {\n        return;\n      }\n      velocityRef.current += (clientX - lastPointerX.current) * dragSensitivity;\n      lastPointerX.current = clientX;\n      isSnapping.current = false;\n    };\n\n    const handleMouseDown = (event: MouseEvent) => {\n      startDrag(event.clientX);\n    };\n\n    const handleMouseMove = (event: MouseEvent) => {\n      moveDrag(event.clientX);\n    };\n\n    const handleMouseUp = () => {\n      isDragging.current = false;\n    };\n\n    const handleTouchStart = (event: TouchEvent) => {\n      const [touch] = event.touches;\n      if (touch) {\n        startDrag(touch.clientX);\n      }\n    };\n\n    const handleTouchMove = (event: TouchEvent) => {\n      const [touch] = event.touches;\n      if (touch) {\n        moveDrag(touch.clientX);\n      }\n    };\n\n    const handleTouchEnd = () => {\n      isDragging.current = false;\n    };\n\n    canvas.addEventListener(\"wheel\", handleWheel, { passive: false });\n    canvas.addEventListener(\"mousedown\", handleMouseDown);\n    canvas.addEventListener(\"mousemove\", handleMouseMove);\n    canvas.addEventListener(\"mouseup\", handleMouseUp);\n    canvas.addEventListener(\"mouseleave\", handleMouseUp);\n\n    canvas.addEventListener(\"touchstart\", handleTouchStart, { passive: true });\n    canvas.addEventListener(\"touchmove\", handleTouchMove, { passive: true });\n    canvas.addEventListener(\"touchend\", handleTouchEnd);\n\n    return () => {\n      canvas.removeEventListener(\"wheel\", handleWheel);\n      canvas.removeEventListener(\"mousedown\", handleMouseDown);\n      canvas.removeEventListener(\"mousemove\", handleMouseMove);\n      canvas.removeEventListener(\"mouseup\", handleMouseUp);\n      canvas.removeEventListener(\"mouseleave\", handleMouseUp);\n      canvas.removeEventListener(\"touchstart\", handleTouchStart);\n      canvas.removeEventListener(\"touchmove\", handleTouchMove);\n      canvas.removeEventListener(\"touchend\", handleTouchEnd);\n    };\n  }, [gl.domElement, wheelSensitivity, dragSensitivity]);\n\n  // Compute responsive image plane size from viewport.\n  // Scale down images on smaller viewports, keep original size on large screens.\n  const sizeScale = Math.min(1, viewport.width / FULL_SIZE_VIEWPORT_WIDTH);\n  const planeWidth = imageWidth * sizeScale;\n\n  useFrame((state, delta) => {\n    if (!images.length) {\n      return;\n    }\n    const group = groupRef.current;\n    if (!group) {\n      return;\n    }\n\n    if (isAnimatingIn.current) {\n      animationProgress.current = Math.min(\n        1,\n        animationProgress.current + delta / ENTRANCE_DURATION_SECONDS,\n      );\n      if (animationProgress.current >= 1) {\n        isAnimatingIn.current = false;\n      }\n\n      const easedProgress = 1 - (1 - animationProgress.current) ** 3;\n\n      group.rotation.y =\n        INITIAL_ROTATION + (rotationRef.current - INITIAL_ROTATION) * easedProgress;\n\n      // Overwrite the per-plane opacity written earlier this frame with the fade-in value.\n      setPlaneOpacity(group, easedProgress);\n\n      return;\n    }\n\n    if (autorotate && !isDragging.current && !isSnapping.current) {\n      velocityRef.current += autorotateSpeed * delta;\n    }\n\n    // Apply friction using delta time for frame-rate independence\n    velocityRef.current *= friction ** (delta * REFERENCE_FPS);\n\n    const anglePerImage = TAU / images.length;\n\n    if (\n      enableSnapping &&\n      !isDragging.current &&\n      !isSnapping.current &&\n      Math.abs(velocityRef.current) < VELOCITY_THRESHOLD\n    ) {\n      targetRotationRef.current = closestSnapRotation(\n        rotationRef.current,\n        anglePerImage,\n        images.length,\n      );\n      isSnapping.current = true;\n      velocityRef.current = 0;\n    }\n\n    if (isSnapping.current) {\n      let diff = targetRotationRef.current - rotationRef.current;\n\n      // Handle wraparound - choose shortest path\n      if (Math.abs(diff) > Math.PI) {\n        diff += diff > 0 ? -TAU : TAU;\n      }\n\n      if (Math.abs(diff) < SNAP_EPSILON) {\n        rotationRef.current = targetRotationRef.current;\n        isSnapping.current = false;\n      } else {\n        rotationRef.current += diff * SNAP_SPEED;\n      }\n    } else {\n      rotationRef.current += velocityRef.current * delta * REFERENCE_FPS;\n    }\n\n    group.rotation.y = rotationRef.current;\n\n    const normalizedAngle = ((-rotationRef.current % TAU) + TAU) % TAU;\n    currentIndexRef.current = Math.round(normalizedAngle / anglePerImage) % images.length;\n  });\n\n  if (!images.length) {\n    return null;\n  }\n\n  return (\n    <group ref={groupRef}>\n      {textures.map((imageTexture, index) => (\n        <ImagePlane\n          key={imageTexture.uuid}\n          texture={imageTexture}\n          index={index}\n          total={images.length}\n          radius={radius}\n          planeWidth={planeWidth}\n          cornerRadius={cornerRadius}\n          bendAmount={bendAmount}\n          centerOpacity={centerOpacity}\n          adjacentOpacity={adjacentOpacity}\n          farOpacity={farOpacity}\n          currentIndexRef={currentIndexRef}\n        />\n      ))}\n      <pointLight position={[0, 2, 0]} intensity={0.5} />\n      <ambientLight intensity={0.3} />\n    </group>\n  );\n};\n","path":"/carousel-3d.tsx","target":"uicapsule/carousel-3d/carousel-3d.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { ImageCarousel, ImageCarouselCanvas } from \"./carousel-3d\";\n\nconst rootUrl = \"https://d24l2zb4cwkekfpl.public.blob.vercel-storage.com/carousel-3d\";\n\nconst cards = [\n  { src: `${rootUrl}/1.webp` },\n  { src: `${rootUrl}/2.webp` },\n  { src: `${rootUrl}/3.webp` },\n  { src: `${rootUrl}/4.webp` },\n  { src: `${rootUrl}/5.webp` },\n  { src: `${rootUrl}/6.webp` },\n  { src: `${rootUrl}/7.webp` },\n  { src: `${rootUrl}/8.webp` },\n];\n\nconst Preview = () => (\n  <ImageCarouselCanvas>\n    <ImageCarousel images={cards.map((card) => card.src)} />\n  </ImageCarouselCanvas>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/carousel-3d/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/carousel-3d","name":"carousel-3d","registryDependencies":[],"title":"3D Carousel","type":"registry:block"}