Carousels

3D Drifting Marquee

A physics-based 3D infinite scroll component with drag interaction, scroll velocity acceleration, and cinematic entry animations.

High Velocity & Skew

This example shows that by increasing the defaultVelocity and maxSkew, we create a high-energy, warping effect that simulates rapid movement. It is perfect for travel, adventure, or sports-themed sections where you want to convey speed and excitement. Drag and throw the carousel to see it flow.

Road Trip
Switzerland
Travel
Adventure
Explore
Road Trip
Switzerland
Travel
Adventure
Explore
'use client';import ThreeDDriftingMarquee from '@/components/ui/3d-drifting-marquee';const images = [{ src: 'https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?q=80&w=1000&auto=format&fit=crop', alt: 'Road Trip' },{ src: 'https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=1000&auto=format&fit=crop', alt: 'Switzerland' },{ src: 'https://images.unsplash.com/photo-1504609773096-104ff2c73ba4?q=80&w=1000&auto=format&fit=crop', alt: 'Travel' },{ src: 'https://images.unsplash.com/photo-1539635278303-d4002c07eae3?q=80&w=1000&auto=format&fit=crop', alt: 'Adventure' },{ src: 'https://images.unsplash.com/photo-1519904981063-b0cf448d479e?q=80&w=1000&auto=format&fit=crop', alt: 'Explore' },{ src: 'https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?q=80&w=1000&auto=format&fit=crop', alt: 'Road Trip' },{ src: 'https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=1000&auto=format&fit=crop', alt: 'Switzerland' },{ src: 'https://images.unsplash.com/photo-1504609773096-104ff2c73ba4?q=80&w=1000&auto=format&fit=crop', alt: 'Travel' },{ src: 'https://images.unsplash.com/photo-1539635278303-d4002c07eae3?q=80&w=1000&auto=format&fit=crop', alt: 'Adventure' },{ src: 'https://images.unsplash.com/photo-1519904981063-b0cf448d479e?q=80&w=1000&auto=format&fit=crop', alt: 'Explore' },];export default function DriftingMarqueeVelocity() {return (  <div className="w-full h-[350px] lg:h-[500px] flex items-center justify-center bg-background rounded-xl overflow-hidden relative border border-border">    {/* Pattern overlay for texture */}    <div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/stardust.png')] opacity-20 pointer-events-none mix-blend-difference" />        {/*       High velocity and maxSkew settings demonstrate the "warping" effect      that simulates high-speed motion.    */}    <ThreeDDriftingMarquee      images={images}      defaultVelocity={2.5} // Very fast base speed      maxSkew={35} // Extreme skew for dramatic effect      dragFactor={1.5} // Responsive dragging      cardWidth={400}      cardHeight={250}      gap={-100}      enableEntry      entryDistance={5000}    />  </div>);}

Installation

command to install
npx shadcn@latest add https://satisui.xyz/r/3d-drifting-marquee.json

Manual

Install the required dependencies:

install motion
npm install motion

Copy the source code into components/satisui/3d-drifting-marquee.tsx:

components/satisui/3d-drifting-marquee.tsx
'use client';

import React, { useRef, useEffect, useState } from 'react';
import Image from 'next/image';
import {
  motion,
  useMotionValue,
  useTransform,
  useSpring,
  useAnimationFrame,
  useVelocity,
  useScroll,
  PanInfo,
  animate,
} from 'motion/react';
import { cn } from '@/lib/utils'; // Standard Shadcn utility

// --- UTILS ---
function wrap(min: number, max: number, v: number) {
  const rangeSize = max - min;
  return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
}

// --- TYPES ---
export interface MarqueeImage {
  src: string;
  alt: string;
}

export interface ThreeDDriftingMarqueeProps {
  images: MarqueeImage[];
  className?: string;
  cardClassName?: string;
  /** Width of the card in px (default: 384) */
  cardWidth?: number;
  /** Height of the card in px (default: 288) */
  cardHeight?: number;
  /** Negative gap creates overlap (default: -160) */
  gap?: number;
  /** Base automatic scroll speed (default: 1.0) */
  defaultVelocity?: number;
  /** Max skew angle in degrees (default: 15) */
  maxSkew?: number;
  /** Factor to scale drag speed (default: 1.2) */
  dragFactor?: number;
  /** If true, pauses the loop when hovering a card (default: true) */
  pauseOnHover?: boolean;
  /** Cinematic entry animation (default: false) */
  enableEntry?: boolean;
  /** Delay before entry animation starts (seconds) */
  entryAnimationDelay?: number;
  /** Duration of entry animation (seconds) */
  entryAnimationDuration?: number;
  /** Starting pixel distance for entry (default: 3000) */
  entryDistance?: number;
}

const ThreeDDriftingMarquee: React.FC<ThreeDDriftingMarqueeProps> = ({
  images,
  className,
  cardClassName,
  cardWidth = 384,
  cardHeight = 288,
  gap = -160,
  defaultVelocity = 1.0,
  maxSkew = 15,
  dragFactor = 1.2,
  pauseOnHover = true,
  enableEntry = false,
  entryAnimationDelay = 1,
  entryAnimationDuration = 2.5,
  entryDistance = 3000,
}) => {
  // 1. SETUP GEOMETRY
  const cardStep = cardWidth + gap;
  const totalWidth = images.length * cardStep;
  const min = -totalWidth / 2;
  const max = totalWidth / 2;

  // 2. MOTION VALUES
  const baseX = useMotionValue(0);
  const masterVelocity = useMotionValue(0);
  const hoverSpeed = useMotionValue(1);
  const smoothHover = useSpring(hoverSpeed, { damping: 40, stiffness: 100 });

  // --- ENTRY ANIMATION ---
  const entryX = useMotionValue(enableEntry ? entryDistance : 0);
  const entryVelocity = useVelocity(entryX);
  const [isEntryComplete, setIsEntryComplete] = useState(!enableEntry);

  useEffect(() => {
    if (enableEntry) {
      const controls = animate(entryX, 0, {
        duration: entryAnimationDuration,
        ease: 'circOut',
        delay: entryAnimationDelay,
        onComplete: () => setIsEntryComplete(true),
      });
      return () => controls.stop();
    }
  }, [
    enableEntry,
    entryX,
    entryAnimationDelay,
    entryAnimationDuration,
    entryDistance,
  ]);

  // 3. SCROLL INTEGRATION
  const { scrollY } = useScroll();
  const scrollVelocity = useVelocity(scrollY);
  const smoothScrollVelocity = useSpring(scrollVelocity, {
    damping: 50,
    stiffness: 400,
  });

  const smoothSkewVelocity = useSpring(masterVelocity, {
    damping: 60,
    stiffness: 200,
  });

  const skewX = useTransform(
    smoothSkewVelocity,
    [-1000, 1000],
    [maxSkew * 2, -maxSkew * 2],
    { clamp: true },
  );

  // 4. PHYSICS STATE
  const isDragging = useRef(false);
  const directionFactor = useRef<number>(1);
  const swipeVelocity = useRef(0);

  // 5. ANIMATION LOOP
  useAnimationFrame((t, delta) => {
    let moveBy = defaultVelocity * (delta / 16);

    // Scroll Boost
    const scrollBoost = smoothScrollVelocity.get() * 0.02;
    if (scrollBoost < 0) directionFactor.current = -1;
    else if (scrollBoost > 0) directionFactor.current = 1;

    moveBy += Math.abs(scrollBoost);
    moveBy *= directionFactor.current;

    // Hover Brake
    moveBy *= smoothHover.get();

    const currentEntryVel = entryVelocity.get();

    if (isDragging.current) {
      masterVelocity.set(swipeVelocity.current * 10);
    } else {
      // Momentum Phase
      if (Math.abs(swipeVelocity.current) > 0.05) {
        baseX.set(baseX.get() + swipeVelocity.current);
        swipeVelocity.current *= 0.975;
        masterVelocity.set(swipeVelocity.current * 10);
      } else {
        // Auto-scroll Phase
        swipeVelocity.current = 0;
        baseX.set(baseX.get() - moveBy);
        // Velocity Merge (Loop + Entry)
        masterVelocity.set(-moveBy * 5 + currentEntryVel * 0.05);
      }
    }
  });

  const handlePanStart = () => {
    isDragging.current = true;
    swipeVelocity.current = 0;
  };

  const handlePan = (_: any, info: PanInfo) => {
    baseX.set(baseX.get() + info.delta.x * dragFactor);
    swipeVelocity.current = info.velocity.x * 0.02;
  };

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

  const setHover = (isActive: boolean) => {
    if (pauseOnHover) {
      hoverSpeed.set(isActive ? 0 : 1);
    }
  };

  return (
    <div
      className={cn(
        'flex w-full items-center justify-center overflow-visible select-none',
        // Default perspective if not overridden
        '[perspective:5000px]',
        className,
      )}
    >
      <motion.div
        className='relative flex items-center justify-center [transform-style:preserve-3d]'
        style={{
          width: '100%',
          height: cardHeight + 100, // Safety buffer for tilt
          rotateY: 50,
          rotateX: -15,
          cursor: 'grab',
        }}
        whileTap={{ cursor: 'grabbing' }}
        onPanStart={handlePanStart}
        onPan={handlePan}
        onPanEnd={handlePanEnd}
      >
        {images.map((image, index) => (
          <Card3D
            key={index}
            index={index}
            image={image}
            baseX={baseX}
            entryX={entryX}
            cardStep={cardStep}
            totalWidth={totalWidth}
            min={min}
            max={max}
            cardWidth={cardWidth}
            cardHeight={cardHeight}
            skewX={skewX}
            totalItems={images.length}
            setHover={setHover}
            cardClassName={cardClassName}
          />
        ))}
      </motion.div>
    </div>
  );
};

// --- INTERNAL CARD COMPONENT ---

interface Card3DProps {
  index: number;
  image: MarqueeImage;
  baseX: any;
  entryX: any;
  cardStep: number;
  totalWidth: number;
  min: number;
  max: number;
  cardWidth: number;
  cardHeight: number;
  skewX: any;
  totalItems: number;
  setHover: (active: boolean) => void;
  cardClassName?: string;
}

const Card3D: React.FC<Card3DProps> = ({
  index,
  image,
  baseX,
  entryX,
  cardStep,
  totalWidth,
  min,
  max,
  cardWidth,
  cardHeight,
  skewX,
  totalItems,
  setHover,
  cardClassName,
}) => {
  const x = useTransform([baseX, entryX], ([latestBaseX, latestEntryX]) => {
    const baseOffset = index * cardStep;
    const centeredOffset = baseOffset - totalWidth / 2;
    const infiniteX = wrap(min, max, (latestBaseX as number) + centeredOffset);
    return infiniteX + (latestEntryX as number);
  });

  const zIndex = totalItems - index;

  return (
    <motion.div
      className={cn(
        'group absolute left-1/2 top-1/2 overflow-hidden bg-background shadow-2xl',
        cardClassName,
      )}
      style={{
        x,
        y: '-50%',
        width: cardWidth,
        height: cardHeight,
        rotateY: -90,
        skewX: skewX,
        zIndex,
        transformStyle: 'preserve-3d',
        marginLeft: -cardWidth / 2,
      }}
      onHoverStart={() => setHover(true)}
      onHoverEnd={() => setHover(false)}
      whileHover={{
        z: 100,
        scale: 1.1,
        transition: { duration: 0.3, ease: 'easeOut' },
      }}
    >
      <Image
        src={image.src}
        alt={image.alt}
        fill
        draggable={false}
        className='pointer-events-none object-cover grayscale transition-all duration-500 ease-in-out group-hover:grayscale-0'
      />
      {/* Overlay for better depth/lighting integration */}
      <div className='absolute inset-0 bg-background/20 transition-colors duration-500 pointer-events-none group-hover:bg-transparent' />
    </motion.div>
  );
};

export default ThreeDDriftingMarquee;

Usage

To use the ThreeDDriftingMarquee, import it and provide an array of images. You can customize the physics, dimensions, and enable a cinematic entry animation.

usage
import ThreeDDriftingMarquee from '@/components/ui/3d-drifting-marquee';

const images = [
  {
    src: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb',
    alt: 'Yosemite',
  },
  {
    src: 'https://images.unsplash.com/photo-1511576661531-b34d7da5d0bb',
    alt: 'Mountains',
  },
  {
    src: 'https://images.unsplash.com/photo-1472214103451-9374bd1c798e',
    alt: 'Hills',
  },
  {
    src: 'https://images.unsplash.com/photo-1501785888041-af3ef285b470',
    alt: 'Coast',
  },
  {
    src: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e',
    alt: 'Forest',
  },
];

export function Hero() {
  return (
    <div className='h-[600px] w-full bg-neutral-950 flex items-center justify-center overflow-hidden'>
      <ThreeDDriftingMarquee
        images={images}
        // Customizing physics and dimensions
        defaultVelocity={0.8}
        cardWidth={350}
        cardHeight={450}
        gap={-120} // Negative gap creates the heavy overlap look
        // Cinematic Entry
        enableEntry={true}
        entryAnimationDelay={0.5}
        // Styling overrides
        className='[perspective:4000px]'
        cardClassName='rounded-xl border-0'
      />
    </div>
  );
}

Props

PropTypeDefaultDescription
imagesMarqueeImage[]RequiredArray of image objects containing src and alt.
classNamestringundefinedOptional className for the wrapper container.
cardClassNamestringundefinedOptional className for the individual cards.
cardWidthnumber384Width of the card in pixels.
cardHeightnumber288Height of the card in pixels.
gapnumber-160Gap between cards. Use negative values for overlap.
defaultVelocitynumber1.0Base automatic scroll speed in pixels per frame.
maxSkewnumber15Maximum skew angle in degrees based on velocity.
dragFactornumber1.2Sensitivity multiplier for manual dragging.
pauseOnHoverbooleantruePauses the automatic loop when hovering over a card.
enableEntrybooleanfalseIf true, enables a cinematic entry animation where cards slide in from off-screen.
entryAnimationDelaynumber1Delay in seconds before the entry animation begins.
entryAnimationDurationnumber2.5Duration in seconds of the entry animation.
entryDistancenumber3000Starting pixel distance for the entry animation.