AniUI

Carousel 3D

3D cylindrical carousel with pan gestures and snap physics.

Tap a card to rotate the cylinder

Web preview — components render natively on iOS & Android
import { Carousel3D } from "@/components/ui/carousel-3d";
<Carousel3D data={slides} onIndexChange={setIndex} />

Installation#

npx @aniui/cli add carousel-3d

Usage#

import { Carousel3D } from "@/components/ui/carousel-3d";
<Carousel3D data={slides} onIndexChange={setIndex} />

Source#

components/ui/carousel-3d.tsx
import React, { useCallback, useMemo } from "react";
import { Pressable, View, useWindowDimensions } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
  Extrapolation,
  cancelAnimation,
  interpolate,
  runOnJS,
  useAnimatedReaction,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  type SharedValue,
} from "react-native-reanimated";
import { cn } from "@/lib/utils";

export interface Carousel3DProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  data: React.ReactNode[];
  itemWidth?: number;
  gap?: number;
  perspectiveFactor?: number;
  backOpacity?: number;
  onIndexChange?: (index: number) => void;
  onPressItem?: (index: number) => void;
}

const MIN_PROJECTION_DENOMINATOR = 1;

function stepOf(count: number) {
  "worklet";
  return 360 / Math.max(count, 1);
}

function frontIndex(rotation: number, step: number, count: number) {
  "worklet";
  if (count <= 0) return 0;
  const index = Math.round(-rotation / step) % count;
  return index < 0 ? index + count : index;
}

function projectFace(angle: number, radius: number, perspective: number) {
  "worklet";
  const radians = (angle * Math.PI) / 180;
  const cos = Math.cos(radians);
  const sin = Math.sin(radians);
  const distance = Math.max(perspective, MIN_PROJECTION_DENOMINATOR);
  const depth = radius * cos;
  return {
    matrix: [cos, 0, -sin, sin / distance, 0, 1, 0, 0, sin, 0, cos, -cos / distance, sin * radius, 0, depth, 1 - depth / distance],
    depth,
    facing: depth - (radius * radius) / distance,
  };
}

export function Carousel3D({
  className,
  data,
  itemWidth: itemWidthProp,
  gap = 8,
  perspectiveFactor = 3.5,
  backOpacity = 0.4,
  onIndexChange,
  onPressItem,
  ...props
}: Carousel3DProps) {
  const { width: windowWidth } = useWindowDimensions();
  const count = data.length;
  const step = stepOf(count);
  const itemWidth = itemWidthProp ?? Math.min(windowWidth * 0.55, 220);
  const itemHeight = itemWidth * 1.25;
  const radius = Math.max((count * (itemWidth + gap)) / (2 * Math.PI), itemWidth);
  const perspective = radius * perspectiveFactor;

  const rotation = useSharedValue(0);
  const dragOrigin = useSharedValue(0);

  const handleIndex = useCallback((index: number) => onIndexChange?.(index), [onIndexChange]);

  useAnimatedReaction(
    () => frontIndex(rotation.value, step, count),
    (current, prev) => {
      if (prev !== null && current !== prev) runOnJS(handleIndex)(current);
    },
    [step, count, handleIndex]
  );

  const pan = useMemo(
    () =>
      Gesture.Pan()
        .activeOffsetX([-8, 8])
        .onBegin(() => {
          cancelAnimation(rotation);
          dragOrigin.value = rotation.value;
        })
        .onUpdate((e) => {
          const degreesPerPixel = 180 / (Math.PI * Math.max(radius, 1));
          rotation.value = dragOrigin.value + e.translationX * degreesPerPixel;
        })
        .onEnd((e) => {
          const degreesPerPixel = 180 / (Math.PI * Math.max(radius, 1));
          const velocity = e.velocityX * degreesPerPixel;
          const projected = rotation.value + velocity * 0.28;
          rotation.value = withSpring(Math.round(projected / step) * step, {
            damping: 26,
            stiffness: 110,
            mass: 0.6,
            velocity,
          });
        }),
    [rotation, dragOrigin, step, radius]
  );

  const frontScale = perspectiveFactor / Math.max(perspectiveFactor - 1, 0.1);

  return (
    <View
      className={cn("w-full items-center justify-center overflow-hidden", className)}
      style={{ height: itemHeight * frontScale + 48 }}
      accessibilityRole="adjustable"
      {...props}
    >
      <GestureDetector gesture={pan}>
        <Animated.View className="relative w-full items-center justify-center" style={{ height: itemHeight }}>
          {data.map((item, index) => (
            <Carousel3DFace
              key={index}
              index={index}
              rotation={rotation}
              step={step}
              radius={radius}
              perspective={perspective}
              backOpacity={backOpacity}
              itemWidth={itemWidth}
              itemHeight={itemHeight}
              onPress={onPressItem ? () => onPressItem(index) : undefined}
            >
              {item}
            </Carousel3DFace>
          ))}
        </Animated.View>
      </GestureDetector>
    </View>
  );
}

function Carousel3DFace({
  index,
  rotation,
  step,
  radius,
  perspective,
  backOpacity,
  itemWidth,
  itemHeight,
  onPress,
  children,
}: {
  index: number;
  rotation: SharedValue<number>;
  step: number;
  radius: number;
  perspective: number;
  backOpacity: number;
  itemWidth: number;
  itemHeight: number;
  onPress?: () => void;
  children: React.ReactNode;
}) {
  const facing = useSharedValue(1);

  const style = useAnimatedStyle(() => {
    const angle = rotation.value + index * step;
    const projected = projectFace(angle, radius, perspective);
    facing.value = projected.facing;
    return {
      opacity: interpolate(projected.depth, [-radius, radius], [backOpacity, 1], Extrapolation.CLAMP),
      zIndex: Math.round(projected.depth) + 1000,
      transform: [{ matrix: projected.matrix }],
    };
  }, [index, step, radius, perspective, backOpacity]);

  const handlePress = useCallback(() => {
    if (facing.value <= 0) return;
    onPress?.();
  }, [onPress, facing]);

  const body = (
    <Animated.View
      className="absolute overflow-hidden rounded-xl border border-border bg-card"
      style={[{ width: itemWidth, height: itemHeight }, style]}
    >
      {children}
    </Animated.View>
  );

  if (!onPress) return body;
  return (
    <Pressable onPress={handlePress} accessible={true} accessibilityRole="button">
      {body}
    </Pressable>
  );
}