shadimage
skecher-ui
ui3d-wheel-carouselsource: ok · refreshed

3D Wheel Carousel

A reusable, drag-controlled 3D card carousel with auto-rotation, keyboard navigation, blur depth, and reduced-motion support.

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

Where it goes

kanban

Visual style

glass-blur3d

Interaction

carouseldrag

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

Dependencies

Bundle weight · medium
animationmotion

Files1 · source cached 10/8/2026, 4:45:09 AM

components/ui-components/3d-wheel-carousel.tsx
"use client";

import {
  animate,
  type MotionValue,
  motion,
  useAnimationFrame,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useSpring,
  useTransform,
  useVelocity,
} from "motion/react";
import {
  type ComponentPropsWithoutRef,
  type CSSProperties,
  type KeyboardEvent,
  type ReactNode,
  useEffect,
  useRef,
  useState,
} from "react";

import { cn } from "@/lib/utils";

export type WheelCarouselItem = {
  id: string;
  content: ReactNode;
  label?: string;
};

export type WheelCarouselProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
  items: readonly WheelCarouselItem[];
  autoRotateSpeed?: number;
  cardClassName?: string;
  cardHeight?: number;
  cardWidth?: number;
  dragSensitivity?: number;
  direction?: "left" | "right";
  initialIndex?: number;
  maxBlur?: number;
  maxMotionBlur?: number;
  /** Scale applied below the `sm` breakpoint. Desktop always renders at full size. */
  mobileScale?: number;
  motionBlurExitDelay?: number;
  motionBlurExitDuration?: number;
  onActiveIndexChange?: (index: number) => void;
  pauseOnHover?: boolean;
  paused?: boolean;
  radius?: number;
  spring?: {
    damping?: number;
    mass?: number;
    stiffness?: number;
  };
};

const wrap = (value: number, length: number) => ((value % length) + length) % length;

export function WheelCarousel({
  "aria-label": ariaLabel = "3D carousel",
  autoRotateSpeed = 10,
  cardClassName,
  cardHeight = 380,
  cardWidth = 280,
  className,
  direction = "left",
  dragSensitivity = 0.35,
  initialIndex = 0,
  items,
  maxBlur = 12,
  maxMotionBlur = 40,
  mobileScale = 0.68,
  motionBlurExitDelay = 0.7,
  motionBlurExitDuration = 1.2,
  onActiveIndexChange,
  pauseOnHover = true,
  paused = false,
  radius = 560,
  spring = {
    damping: 90,
    mass: 1,
    stiffness: 200,
  },
  ...props
}: WheelCarouselProps) {
  const itemCount = items.length;
  const safeItemCount = Math.max(itemCount, 1);
  const angleStep = itemCount > 0 ? 360 / itemCount : 0;
  const reduceMotion = useReducedMotion();
  const rotation = useMotionValue(-wrap(initialIndex, safeItemCount) * angleStep);
  const smoothRotation = useSpring(rotation, spring);
  const rotationVelocity = useVelocity(smoothRotation);
  const smoothVelocity = useSpring(rotationVelocity, {
    stiffness: 200,
    damping: 30,
  });
  const dragging = useRef(false);
  const hovered = useRef(false);
  const travelDirection = useRef(direction === "left" ? -1 : 1);
  const lastActiveIndex = useRef(wrap(initialIndex, safeItemCount));
  const [isDragging, setIsDragging] = useState(false);

  useEffect(() => {
    travelDirection.current = direction === "left" ? -1 : 1;
  }, [direction]);

  useAnimationFrame((_, delta) => {
    if (
      itemCount < 2 ||
      paused ||
      reduceMotion ||
      dragging.current ||
      (pauseOnHover && hovered.current)
    ) {
      return;
    }
    const seconds = delta / 1000;
    const rotationChange = autoRotateSpeed * travelDirection.current * seconds;
    rotation.set(rotation.get() + rotationChange);
  });

  useMotionValueEvent(smoothRotation, "change", (latest) => {
    if (itemCount === 0) {
      return;
    }
    const next = wrap(Math.round(-latest / angleStep), itemCount);
    if (next !== lastActiveIndex.current) {
      lastActiveIndex.current = next;
      onActiveIndexChange?.(next);
    }
  });

  const rotateBy = (steps: number) => {
    if (itemCount < 2) {
      return;
    }
    rotation.set(rotation.get() - steps * angleStep);
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "ArrowRight") {
      event.preventDefault();
      rotateBy(1);
    } else if (event.key === "ArrowLeft") {
      event.preventDefault();
      rotateBy(-1);
    } else if (event.key === "Home") {
      event.preventDefault();
      rotation.set(0);
    } else if (event.key === "End") {
      event.preventDefault();
      rotation.set(-(itemCount - 1) * angleStep);
    }
  };

  if (itemCount === 0) {
    return null;
  }

  return (
    <section
      aria-label={ariaLabel}
      aria-roledescription="carousel"
      className={cn(
        "relative flex min-h-96 w-full items-center justify-center overflow-hidden outline-none perspective-[5000px] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:min-h-[32rem]",
        className,
      )}
      onBlur={() => {
        hovered.current = false;
      }}
      onFocus={() => {
        hovered.current = true;
      }}
      onKeyDown={handleKeyDown}
      onMouseEnter={() => {
        hovered.current = true;
      }}
      onMouseLeave={() => {
        hovered.current = false;
      }}
      // The carousel surface receives focus for its arrow-key controls.
      // biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard-operated carousel region
      tabIndex={0}
      {...props}
    >
      <motion.div
        className={cn(
          "relative origin-center touch-none select-none [scale:var(--wheel-mobile-scale)] sm:[scale:1]",
          isDragging ? "cursor-grabbing" : "cursor-grab",
        )}
        onPan={(_event, info) => {
          const delta = info.delta.x * dragSensitivity;
          if (delta > 0) {
            travelDirection.current = 1;
          } else if (delta < 0) {
            travelDirection.current = -1;
          }
          rotation.set(rotation.get() + delta);
        }}
        onPanStart={() => {
          dragging.current = true;
          setIsDragging(true);
        }}
        onPanEnd={() => {
          dragging.current = false;
          setIsDragging(false);
        }}
        style={
          {
            height: cardHeight,
            rotateY: smoothRotation,
            transformStyle: "preserve-3d",
            width: cardWidth,
            "--wheel-mobile-scale": mobileScale,
          } as CSSProperties
        }
      >
        {items.map((item, index) => (
          <WheelCarouselCard
            angle={angleStep * index}
            cardClassName={cardClassName}
            index={index}
            isDragging={isDragging}
            item={item}
            itemCount={itemCount}
            key={item.id}
            maxBlur={maxBlur}
            maxMotionBlur={maxMotionBlur}
            motionBlurExitDelay={motionBlurExitDelay}
            motionBlurExitDuration={motionBlurExitDuration}
            radius={radius}
            rotation={smoothRotation}
            velocity={smoothVelocity}
          />
        ))}
      </motion.div>

      <p className="sr-only">Use the left and right arrow keys to move between slides.</p>
    </section>
  );
}

type WheelCarouselCardProps = {
  angle: number;
  cardClassName?: string;
  index: number;
  isDragging: boolean;
  item: WheelCarouselItem;
  itemCount: number;
  maxBlur: number;
  maxMotionBlur: number;
  motionBlurExitDelay: number;
  motionBlurExitDuration: number;
  radius: number;
  rotation: MotionValue<number>;
  velocity: MotionValue<number>;
};

function WheelCarouselCard({
  angle,
  cardClassName,
  index,
  isDragging,
  item,
  itemCount,
  maxBlur,
  maxMotionBlur,
  motionBlurExitDelay,
  motionBlurExitDuration,
  radius,
  rotation,
  velocity,
}: WheelCarouselCardProps) {
  const angleStep = 360 / itemCount;
  const distance = useTransform(rotation, (current) => {
    const frontIndex = wrap(Math.round(-current / angleStep), itemCount);
    const direct = Math.abs(index - frontIndex);
    return Math.min(direct, itemCount - direct);
  });

  const positionBlur = useTransform(distance, (value) => {
    if (value <= 1) {
      return 0;
    }
    const furthest = Math.max(Math.floor(itemCount / 2), 1);
    const availableSteps = Math.max(furthest - 1, 1);
    const progress = (value - 1) / availableSteps;
    return progress * maxBlur;
  });

  const motionBlur = useMotionValue(0);

  useMotionValueEvent(velocity, "change", (currentVelocity) => {
    if (!isDragging) {
      return;
    }
    const speed = Math.abs(currentVelocity);
    const blur = Math.min(speed / 70, maxMotionBlur);
    motionBlur.set(blur);
  });

  useEffect(() => {
    if (isDragging) {
      return;
    }
    const controls = animate(motionBlur, 0, {
      delay: motionBlurExitDelay,
      duration: motionBlurExitDuration,
      ease: [0.23, 1, 0.32, 1],
    });
    return () => {
      controls.stop();
    };
  }, [isDragging, motionBlur, motionBlurExitDelay, motionBlurExitDuration]);

  const totalBlur = useTransform(
    [positionBlur, motionBlur],
    ([depthBlur, velocityBlur]) => Number(depthBlur) + Number(velocityBlur),
  );

  const filter = useTransform(totalBlur, (blur) => `blur(${blur}px)`);

  return (
    <motion.div
      aria-label={item.label ?? `Slide ${index + 1} of ${itemCount}`}
      aria-roledescription="slide"
      className={cn(
        "absolute inset-0 overflow-hidden rounded-[var(--wheel-card-radius,1rem)] ",
        cardClassName,
      )}
      role="group"
      style={{
        filter,
        transform: `
          rotateY(${angle}deg)
          translateZ(${radius}px)
        `,
      }}
    >
      {item.content}
    </motion.div>
  );
}

export default WheelCarousel;

More from skecher-ui

Browse all components in this library →