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 &nbsp;      </DriftingMarquee>      {/* Row 2: Moving Left */}      <DriftingMarquee        defaultVelocity={-3}        className="text-6xl leading-tight text-foreground md:text-9xl"      >        Backward Motion &nbsp;      </DriftingMarquee>    </div>          </div>);}

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
Urban Geometry
Neon Nights
Neon Nights
Misty Pines
Misty Pines
Desert Winds
Desert Winds
Oceanic
Oceanic
Urban Geometry
Urban Geometry
Neon Nights
Neon Nights
Misty Pines
Misty Pines
Desert Winds
Desert Winds
Oceanic
Oceanic
Urban Geometry
Urban Geometry
Neon Nights
Neon Nights
Misty Pines
Misty Pines
Desert Winds
Desert Winds
Oceanic
Oceanic
Urban Geometry
Urban Geometry
Neon Nights
Neon Nights
Misty Pines
Misty Pines
Desert Winds
Desert Winds
Oceanic
Oceanic
Urban Geometry
Urban Geometry
Neon Nights
Neon Nights
Misty Pines
Misty Pines
Desert Winds
Desert Winds
Oceanic
Oceanic
Urban Geometry
Urban Geometry
Neon Nights
Neon Nights
Misty Pines
Misty Pines
Desert Winds
Desert Winds
Oceanic
Oceanic
Capture The MomentPreserve The Memory
Capture The MomentPreserve The Memory
Capture The MomentPreserve The Memory
Capture The MomentPreserve 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">&mdash;</span>        <span>Preserve The Memory</span>        <span className="text-foreground/80">&mdash;</span>      </div>    </DriftingMarquee>     </div>);}

Installation

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

Manual

install motion
npm install motion
# or
yarn add motion
# or
pnpm add motion
components/satisui/drifting-marquee.tsx
'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.

demo.tsx
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

PropTypeDefaultDescription
childrenReact.ReactNodeRequiredThe content to be scrolled.
defaultVelocitynumber5Base speed of the scroll in pixels per frame (approximate).
classNamestring-Class for the wrapper div.
dampingnumber50Spring damping for velocity smoothing.
stiffnessnumber400Spring stiffness for velocity smoothing.
numCopiesnumber6Number 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.
parallaxClassNamestring-Class for the inner motion div.
scrollerClassNamestring-Class for the individual content items.
startFromIndexnumber0Starting index offset for the scroll position.
startupScrollDistancenumber0Distance to scroll during the startup animation.
startupDurationnumber1.5Duration of the startup animation.
startupDelaynumber0Delay before the startup animation begins.
maxSkewnumber15Maximum skew angle in degrees applied during high velocity.
skewSensitivitynumber2500Velocity threshold required to reach maximum skew.
seekThroughSwipebooleanfalseEnables touch/mouse dragging to control the scroll.
slowOnHoverbooleanfalseSlows down (stops) the scroll when hovered.