Carousels
Drifting Marquee
A horizontal scrolling component that accelerates and skews based on vertical page scroll velocity and optionally supports manual touch/drag interaction.
Bidirectional Typography
This example demonstrates how to create opposing motion streams using the defaultVelocity prop. By setting a positive value for one row and a negative value for the other, you create a dynamic, balanced visual flow. This technique is highly effective for full-screen hero sections or typographic backgrounds.
Forward Motion
Forward Motion
Forward Motion
Forward Motion
Forward Motion
Forward Motion
Forward Motion
Forward Motion
Forward Motion
Forward Motion
Forward Motion
Forward Motion
Backward Motion
Backward Motion
Backward Motion
Backward Motion
Backward Motion
Backward Motion
Backward Motion
Backward Motion
Backward Motion
Backward Motion
Backward Motion
Backward Motion
import { DriftingMarquee } from "@/components/ui/drifting-marquee";import { Antonio } from "next/font/google";import { cn } from "@/lib/utils";const antonio = Antonio({ subsets: ["latin"], weight: ["700"] });export default function DemoDriftingMarqueeOpposite() {return ( <div className="relative flex h-[500px] w-full flex-col items-center justify-center overflow-hidden bg-background"> <div className={cn(antonio.className, "flex w-full flex-col gap-4 font-bold uppercase tracking-tighter")}> {/* Row 1: Moving Right */} <DriftingMarquee defaultVelocity={3} className="text-6xl leading-tight text-foreground opacity-30 md:text-9xl" > Forward Motion </DriftingMarquee> {/* Row 2: Moving Left */} <DriftingMarquee defaultVelocity={-3} className="text-6xl leading-tight text-foreground md:text-9xl" > Backward Motion </DriftingMarquee> </div> </div>);}Interactive Gallery with Hover Effects
A more complex implementation showcasing the component's interactive capabilities. This demo features:
slowOnHover: The top row decelerates and pauses when the user hovers over an image, allowing for detailed inspection.seekThroughSwipe: Enables touch and mouse dragging, giving the user manual control over the scroll position.- Rich Content: Demonstrates that the marquee can handle complex React components (like the Polaroid cards) just as easily as text.
Urban Geometry
Neon Nights
Misty Pines
Desert Winds
Oceanic
Urban Geometry
Neon Nights
Misty Pines
Desert Winds
Oceanic
Urban Geometry
Neon Nights
Misty Pines
Desert Winds
Oceanic
Urban Geometry
Neon Nights
Misty Pines
Desert Winds
Oceanic
Urban Geometry
Neon Nights
Misty Pines
Desert Winds
Oceanic
Urban Geometry
Neon Nights
Misty Pines
Desert Winds
Oceanic
Capture The Moment—Preserve The Memory—
Capture The Moment—Preserve The Memory—
Capture The Moment—Preserve The Memory—
Capture The Moment—Preserve The Memory—
import { DriftingMarquee } from "@/components/ui/drifting-marquee";const images = [{ title: "Urban Geometry", src: "https://images.unsplash.com/photo-1486718448742-163732cd1544?q=80&w=600&auto=format&fit=crop" },{ title: "Neon Nights", src: "https://images.unsplash.com/photo-1674038316942-cd7c80cf0057?w=500&auto=format&fit=crop" },{ title: "Misty Pines", src: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=600&auto=format&fit=crop" },{ title: "Desert Winds", src: "https://images.unsplash.com/photo-1473580044384-7ba9967e16a0?q=80&w=600&auto=format&fit=crop" },{ title: "Oceanic", src: "https://images.unsplash.com/photo-1760528543054-f2a5e1146565?w=500&auto=format&fit=crop" },];const Polaroid = ({ src, title }: { src: string; title: string }) => (<div className="group relative flex h-[450px] w-[280px] shrink-0 flex-col bg-card shadow-sm transition-all hover:shadow-md"> <div className="relative h-[80%] w-full overflow-hidden bg-muted"> <img src={src} alt={title} className="h-full w-full object-cover grayscale transition-all duration-500 group-hover:grayscale-0" /> </div> <div className="flex h-[20%] w-full items-center justify-center bg-card p-4"> <span className="font-mono text-sm font-medium uppercase tracking-[0.2em] text-card-foreground/90">{title}</span> </div></div>);export default function DemoDriftingMarqueeMixed() {return ( <div className="relative flex w-full flex-col gap-12 overflow-hidden bg-background py-16"> {/* Row 1: Images */} <DriftingMarquee defaultVelocity={2} numCopies={3} seekThroughSwipe slowOnHover className="w-full"> <div className="flex select-none gap-10 px-5"> {images.map((item, i) => ( <Polaroid key={i} src={item.src} title={item.title} /> ))} </div> </DriftingMarquee> {/* Row 2: Text */} <DriftingMarquee defaultVelocity={-3} numCopies={2} seekThroughSwipe className="w-full font-display"> <div className="flex select-none items-center gap-4 text-5xl font-black uppercase tracking-tighter text-foreground/10 md:text-9xl"> <span>Capture The Moment</span> <span className="text-foreground/80">—</span> <span>Preserve The Memory</span> <span className="text-foreground/80">—</span> </div> </DriftingMarquee> </div>);}Installation
CLI (Recommended)
npx shadcn@latest add https://satisui.xyz/r/drifting-marquee.jsonManual
npm install motion
# or
yarn add motion
# or
pnpm add motion'use client';
import React, { useEffect, useRef, useState } from 'react';
import {
motion,
useScroll,
useSpring,
useTransform,
useMotionValue,
useVelocity,
useAnimationFrame,
animate,
PanInfo,
} from 'motion/react';
import { cn } from '@/lib/utils';
interface DriftingMarqueeProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
/** Base speed of the scroll in pixels per frame (approximate). */
defaultVelocity?: number;
/** Class for the wrapper div. */
className?: string;
/** Spring damping for velocity smoothing. */
damping?: number;
/** Spring stiffness for velocity smoothing. */
stiffness?: number;
/** Number of times to duplicate children to ensure infinite scrolling covers the screen. */
numCopies?: number;
/** Input (scroll velocity) to Output (acceleration factor) mapping. */
velocityMapping?: { input: [number, number]; output: [number, number] };
/** Class for the inner motion div. */
parallaxClassName?: string;
/** Class for the individual content items. */
scrollerClassName?: string;
/** Starting index offset for the scroll position. */
startFromIndex?: number;
/** Distance to scroll during the startup animation. */
startupScrollDistance?: number;
/** Duration of the startup animation. */
startupDuration?: number;
/** Delay before the startup animation begins. */
startupDelay?: number;
/** Maximum skew angle in degrees applied during high velocity. */
maxSkew?: number;
/** Velocity threshold required to reach maximum skew. */
skewSensitivity?: number;
/** Enables touch/mouse dragging to control the scroll. */
seekThroughSwipe?: boolean;
/** Slows down (stops) the scroll when hovered. */
slowOnHover?: boolean;
}
/**
* Utility to wrap a number within a range (min to max), handling negative values correctly.
* Essential for the infinite loop calculation.
*/
function wrap(min: number, max: number, v: number) {
const rangeSize = max - min;
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
}
/**
* A horizontal scrolling component that accelerates based on vertical page scroll velocity
* and optionally supports manual touch/drag interaction.
*/
export function DriftingMarquee({
children,
defaultVelocity = 5,
className,
damping = 50,
stiffness = 400,
numCopies = 6,
velocityMapping = { input: [0, 1000], output: [0, 5] },
parallaxClassName,
scrollerClassName,
startFromIndex = 0,
startupScrollDistance = 0,
startupDuration = 1.5,
startupDelay = 0,
maxSkew = 15,
skewSensitivity = 2500,
seekThroughSwipe = false,
slowOnHover = false,
...props
}: DriftingMarqueeProps) {
const baseX = useMotionValue(0);
const entryX = useMotionValue(0);
const masterVelocity = useMotionValue(0);
// Used to smoothly decelerate/accelerate on hover
const hoverSpeed = useMotionValue(1);
const smoothHoverSpeed = useSpring(hoverSpeed, {
damping: 50,
stiffness: 300,
});
const smoothSkewVelocity = useSpring(masterVelocity, {
damping: 50,
stiffness: 300,
});
const { scrollY } = useScroll();
const scrollVelocity = useVelocity(scrollY);
const smoothVerticalVelocity = useSpring(scrollVelocity, {
damping: damping,
stiffness: stiffness,
});
const velocityFactor = useTransform(
smoothVerticalVelocity,
velocityMapping.input,
velocityMapping.output,
{ clamp: false },
);
const skewX = useTransform(
smoothSkewVelocity,
[-skewSensitivity, 0, skewSensitivity],
[-maxSkew, 0, maxSkew],
{ clamp: true },
);
const directionFactor = useRef<number>(1);
const [itemWidth, setItemWidth] = useState(0);
const [isReady, setIsReady] = useState(false);
const isDragging = useRef(false);
const [isGrabbing, setIsGrabbing] = useState(false);
const swipeVelocity = useRef(0);
const dragVelocity = useRef(0);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleResize = () => {
if (contentRef.current) {
const width = contentRef.current.offsetWidth;
setItemWidth(width);
if (width > 0) setIsReady(true);
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [children, numCopies]);
useEffect(() => {
if (itemWidth > 0 && startFromIndex > 0) {
const initialOffset = (startFromIndex / numCopies) * -itemWidth;
baseX.set(initialOffset);
}
}, [itemWidth, startFromIndex, numCopies, baseX]);
useEffect(() => {
if (itemWidth > 0 && startupScrollDistance !== 0) {
const targetPixels = (startupScrollDistance / 100) * itemWidth;
const controls = animate(entryX, targetPixels, {
delay: startupDelay,
duration: startupDuration,
ease: 'circOut',
});
return () => controls.stop();
}
}, [itemWidth, startupScrollDistance, startupDuration, startupDelay, entryX]);
useAnimationFrame((t, delta) => {
// Arbitrary scale (100) ensures defaultVelocity=5 results in a perceivable pixel speed
let moveBy =
directionFactor.current * defaultVelocity * (delta / 1000) * 100;
if (velocityFactor.get() < 0) directionFactor.current = -1;
else if (velocityFactor.get() > 0) directionFactor.current = 1;
moveBy += directionFactor.current * moveBy * velocityFactor.get();
// Apply hover slowdown factor if enabled
if (slowOnHover) {
moveBy *= smoothHoverSpeed.get();
}
if (isDragging.current) {
masterVelocity.set(dragVelocity.current);
} else {
// Momentum decay
if (Math.abs(swipeVelocity.current) > 0.5) {
moveBy += swipeVelocity.current;
swipeVelocity.current *= 0.95; // Friction factor
} else {
swipeVelocity.current = 0;
}
baseX.set(baseX.get() + moveBy);
// Scale up moveBy for the visual skew effect so it remains responsive
masterVelocity.set(moveBy * 50);
}
});
const x = useTransform([baseX, entryX], (latest: number[]) => {
const base = latest[0];
const entry = latest[1];
if (itemWidth === 0) return '0px';
return `${wrap(0, -itemWidth, base + entry)}px`;
});
const handlePanStart = () => {
if (!seekThroughSwipe) return;
isDragging.current = true;
setIsGrabbing(true);
swipeVelocity.current = 0; // Stop momentum when catching
};
const handlePan = (
_: MouseEvent | TouchEvent | PointerEvent,
info: PanInfo,
) => {
if (!seekThroughSwipe) return;
baseX.set(baseX.get() + info.delta.x);
dragVelocity.current = info.velocity.x;
};
const handlePanEnd = (
_: MouseEvent | TouchEvent | PointerEvent,
info: PanInfo,
) => {
if (!seekThroughSwipe) return;
isDragging.current = false;
setIsGrabbing(false);
// Dampen the initial fling velocity so it doesn't spin indefinitely
swipeVelocity.current = info.velocity.x * 0.015;
};
const handleMouseEnter = () => {
if (slowOnHover) hoverSpeed.set(0);
};
const handleMouseLeave = () => {
if (slowOnHover) hoverSpeed.set(1);
};
return (
<div
className={cn('w-full overflow-hidden whitespace-nowrap', className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
style={{
cursor: seekThroughSwipe ? (isGrabbing ? 'grabbing' : 'grab') : 'auto',
// 'pan-y' allows browser vertical scroll handling while JS handles horizontal swipe
touchAction: seekThroughSwipe ? 'pan-y' : 'auto',
// Prevent text selection during drag interactions
userSelect: seekThroughSwipe ? 'none' : 'auto',
WebkitUserSelect: seekThroughSwipe ? 'none' : 'auto',
MozUserSelect: seekThroughSwipe ? 'none' : 'auto',
}}
>
<motion.div
className={cn('flex flex-nowrap', parallaxClassName)}
style={{ x }}
initial={{ opacity: 0 }}
animate={{ opacity: isReady ? 1 : 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
onPanStart={handlePanStart}
onPan={handlePan}
onPanEnd={handlePanEnd}
>
<div ref={contentRef} className='flex shrink-0'>
{[...Array(numCopies)].map((_, i) => (
<motion.div
key={`g1-${i}`}
className={cn('flex shrink-0 items-center', scrollerClassName)}
style={{ skewX }}
>
{children}
</motion.div>
))}
</div>
<div className='flex shrink-0'>
{[...Array(numCopies)].map((_, i) => (
<motion.div
key={`g2-${i}`}
className={cn('flex shrink-0 items-center', scrollerClassName)}
style={{ skewX }}
>
{children}
</motion.div>
))}
</div>
</motion.div>
</div>
);
}Usage
The marquee automatically scrolls horizontally and accelerates based on the user's vertical page scroll speed.
import { DriftingMarquee } from '@/components/ui/drifting-marquee';
const logos = [
'https://cdn.worldvectorlogo.com/logos/react-2.svg',
'https://cdn.worldvectorlogo.com/logos/next-js.svg',
'https://cdn.worldvectorlogo.com/logos/typescript.svg',
'https://cdn.worldvectorlogo.com/logos/tailwindcss.svg',
'https://cdn.worldvectorlogo.com/logos/framer-motion.svg',
];
export function MarqueeDemo() {
return (
<div className='flex flex-col gap-12 py-10 overflow-hidden'>
{/* 1. Basic Text Marquee */}
<DriftingMarquee defaultVelocity={3}>
<span className='text-4xl font-bold mx-8'>VELOCITY</span>
<span className='text-4xl font-bold mx-8'>SCROLL</span>
<span className='text-4xl font-bold mx-8'>EFFECT</span>
</DriftingMarquee>
{/* 2. Interactive Image Marquee (Draggable + Stops on Hover) */}
<DriftingMarquee
defaultVelocity={2}
seekThroughSwipe
slowOnHover
className='py-4'
>
{logos.map((src, i) => (
<div
key={i}
className='mx-6 flex items-center justify-center h-16 w-16 grayscale opacity-50 hover:grayscale-0 hover:opacity-100 transition-all duration-300'
>
<img
src={src}
alt='Logo'
className='w-full h-full object-contain'
/>
</div>
))}
</DriftingMarquee>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | Required | The content to be scrolled. |
defaultVelocity | number | 5 | Base speed of the scroll in pixels per frame (approximate). |
className | string | - | Class for the wrapper div. |
damping | number | 50 | Spring damping for velocity smoothing. |
stiffness | number | 400 | Spring stiffness for velocity smoothing. |
numCopies | number | 6 | Number of times to duplicate children to ensure infinite scrolling covers the screen. |
velocityMapping | { input: [number, number]; output: [number, number] } | { input: [0, 1000], output: [0, 5] } | Input (scroll velocity) to Output (acceleration factor) mapping. |
parallaxClassName | string | - | Class for the inner motion div. |
scrollerClassName | string | - | Class for the individual content items. |
startFromIndex | number | 0 | Starting index offset for the scroll position. |
startupScrollDistance | number | 0 | Distance to scroll during the startup animation. |
startupDuration | number | 1.5 | Duration of the startup animation. |
startupDelay | number | 0 | Delay before the startup animation begins. |
maxSkew | number | 15 | Maximum skew angle in degrees applied during high velocity. |
skewSensitivity | number | 2500 | Velocity threshold required to reach maximum skew. |
seekThroughSwipe | boolean | false | Enables touch/mouse dragging to control the scroll. |
slowOnHover | boolean | false | Slows down (stops) the scroll when hovered. |