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.
'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
CLI (Recommended)
npx shadcn@latest add https://satisui.xyz/r/3d-drifting-marquee.jsonManual
Install the required dependencies:
npm install motionCopy the source code into 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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
images | MarqueeImage[] | Required | Array of image objects containing src and alt. |
className | string | undefined | Optional className for the wrapper container. |
cardClassName | string | undefined | Optional className for the individual cards. |
cardWidth | number | 384 | Width of the card in pixels. |
cardHeight | number | 288 | Height of the card in pixels. |
gap | number | -160 | Gap between cards. Use negative values for overlap. |
defaultVelocity | number | 1.0 | Base automatic scroll speed in pixels per frame. |
maxSkew | number | 15 | Maximum skew angle in degrees based on velocity. |
dragFactor | number | 1.2 | Sensitivity multiplier for manual dragging. |
pauseOnHover | boolean | true | Pauses the automatic loop when hovering over a card. |
enableEntry | boolean | false | If true, enables a cinematic entry animation where cards slide in from off-screen. |
entryAnimationDelay | number | 1 | Delay in seconds before the entry animation begins. |
entryAnimationDuration | number | 2.5 | Duration in seconds of the entry animation. |
entryDistance | number | 3000 | Starting pixel distance for the entry animation. |
Tilt Card
A self-contained card that provides a 3D "lift and tilt" effect on hover, complete with a customizable, mouse-tracking aurora glow.
Drifting Marquee
A horizontal scrolling component that accelerates and skews based on vertical page scroll velocity and optionally supports manual touch/drag interaction.