• 001Opening
  • 002The Journey
  • 003The Dragon
  • 004The Meadow
  • 005The Kite
  • 006Jellyfish
  • 007Bloom

Motion Reel

A list of titles where hovering a row plays its video in a preview that trails the cursor with spring lag and skews from its own velocity.

A free, copy-paste hover effects component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.

Configuration

Tweak these live from theConfigurecontrol in the preview.

followLag0.2–1.2s — default 0.6s.
maxTilt0–28° — default 14°.
previewWidth16–40rem — default 27rem.
overlayDark0–0.8 — default 0.35.
dimOpacity0–0.6 — default 0.15.

Interaction Type

  • Hover a title to play its clip
  • The preview trails the cursor with a springy lag
  • Corners drag behind the motion — fluid velocity skew

Dependency

This component uses motion for its animation. Install it, then drop the file in.

npm install motion

Point the CLI here once

A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:

npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"

After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/motion-reel.json — or copy the file from Source code below.

[ shadcn CLI 3.0 registry ]…or add the component in one command

Install via CLI

pnpm dlx shadcn add @harshdev-ui/motion-reel

How to use

Give MotionReel a list of items — each with a small ordinal, a title and a video URL — and it renders them as a centered menu. Hovering a row plays that row's clip inside a preview that follows the cursor, while the other rows dim away.

Unlike the original GSAP version, everything is measured relative to the component's own box rather than the viewport, so it drops straight into a framed card or a full-bleed hero. The only dependency is Motion, which the follow-lag, velocity skew and entrance stagger are all built on.

Basic usage

import { MotionReel } from "@/components/harsh-ui/motion-reel";

const items = [
  { num: "001", title: "Opening", video: "/videos/opening.mp4" },
  { num: "002", title: "The Journey", video: "/videos/journey.mp4" },
  { num: "003", title: "The Dragon", video: "/videos/dragon.mp4" },

  },
];

const Demo = () => (
  <div className="h-[80vh]">
    <MotionReel items={items} />
  </div>
);

Tuning the feel

followLag controls how far the preview trails the cursor, maxTilt clamps the velocity-driven rotation and skew, and dimOpacity sets how far the non-hovered rows fade.

<MotionReel
  items={items}
  followLag={0.45}
  maxTilt={18}
  previewWidth={30}
  overlayDark={0.3}
  dimOpacity={0.1}
/>

Self-playing showcase

With autoPlay the preview drives itself — cycling rows and drifting around the box — which is how the grid-card preview animates with no pointer.

<MotionReel items={items} autoPlay />

Demo

import { MotionReelDemo } from "@/components/demos/motion-reel-demo";

const Demo = () => (
  <div className="h-screen w-full">
    <MotionReelDemo />
  </div>
);

Props

itemsArray<{ num, title, video }> — the rows. num is a small ordinal, video is the clip played on hover. Required.
previewWidthPreview width in rem. Default 27.
overlayDark0–1 — strength of the dark wash over the video so the white text stays readable. Default 0.35.
dimOpacity0–1 — opacity of the non-hovered rows while hovering. Default 0.15.
followLagHow far the preview trails the cursor — higher is laggier. Default 0.6.
maxTiltMax rotation / skew in degrees driven by the preview's velocity. Default 14.
autoPlaySelf-drive the preview (cycle rows automatically) for contexts with no pointer. Default false.

Notes

  • Position, follow-lag and the velocity skew are all Motion values — useSpring trails the cursor and useVelocity feeds the rotate/skew, so there's no GSAP and no ticker to manage.
  • Pointer coordinates are converted to the component's own box, so the preview stays inside a framed card instead of following the whole window.
  • Only the hovered clip plays; the rest are paused, and the active one pauses again when the preview hides.
  • The menu staggers in on mount via Motion variants, and honours prefers-reduced-motion by dropping the entrance and the tilt.
  • autoPlay makes the component showcase itself with no pointer — used for the grid-card preview.

Source code

The complete motion-reel.tsx — toggle TSX / JSX to copy it in either language.

Source code
"use client";

import { useEffect, useRef, useState } from "react";
import {
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  useVelocity,
  type Variants,
} from "motion/react";

/**
 * Harsh Dev UI · Developed by Harsh Pandav
 * https://harshpandav.dev · hello@harshpandav.dev
 *
 * MotionReel
 * A vertical list of titles. Hovering a row plays its video in a preview that
 * trails the cursor with a springy lag and deforms (rotate + skew) from its own
 * velocity, so the corners drag behind the motion and settle as it settles.
 * Non-hovered rows dim; the active row lights up. Leaving the list hides the
 * preview and pauses playback.
 *
 * Everything is relative to the component's own box (not the viewport), so it
 * works inside a framed card just as well as full-bleed. Zero dependencies
 * beyond React + Motion — no GSAP.
 */

export type ReelItem = {
  num: string;
  title: string;
  video: string;
};

export type MotionReelProps = {
  items: ReelItem[];
  previewWidth?: number;
  overlayDark?: number;
  dimOpacity?: number;
  followLag?: number;
  maxTilt?: number;
  autoPlay?: boolean;
  className?: string;
};

const containerVariants: Variants = {
  hidden: {},
  show: { transition: { staggerChildren: 0.07, delayChildren: 0.05 } },
};

const itemVariants: Variants = {
  hidden: { y: 48, opacity: 0 },
  show: { y: 0, opacity: 1, transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] } },
};

export function MotionReel({
  items,
  previewWidth = 27,
  overlayDark = 0.35,
  dimOpacity = 0.15,
  followLag = 0.6,
  maxTilt = 14,
  autoPlay = false,
  className = "",
}: MotionReelProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
  const primed = useRef(false);

  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [hovering, setHovering] = useState(false);
  const reduce = useReducedMotion();
  const visible = hovering && activeIndex !== null;

  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const spring = { stiffness: Math.round(120 / followLag), damping: 20, mass: 0.7 };
  const sx = useSpring(x, spring);
  const sy = useSpring(y, spring);

  const vx = useVelocity(sx);
  const vy = useVelocity(sy);
  const tiltSpring = { stiffness: 150, damping: 20 };
  const rotate = useSpring(
    useTransform(vx, [-2200, 2200], [-maxTilt, maxTilt], { clamp: true }),
    tiltSpring,
  );
  const skewX = useSpring(
    useTransform(vx, [-2200, 2200], [maxTilt * 0.7, -maxTilt * 0.7], { clamp: true }),
    tiltSpring,
  );
  const skewY = useSpring(
    useTransform(vy, [-2200, 2200], [-maxTilt * 0.35, maxTilt * 0.35], { clamp: true }),
    tiltSpring,
  );

  const primeAt = (clientX: number, clientY: number) => {
    const rect = rootRef.current?.getBoundingClientRect();
    if (!rect) return;
    const px = clientX - rect.left;
    const py = clientY - rect.top;
    x.set(px);
    y.set(py);
    if (!primed.current) {
      sx.jump(px);
      sy.jump(py);
      primed.current = true;
    }
  };

  useEffect(() => {
    if (!autoPlay) return;
    const center = () => {
      const el = rootRef.current;
      if (!el) return;
      const w = el.offsetWidth;
      const h = el.offsetHeight;
      x.set(w / 2);
      y.set(h / 2);
      sx.jump(w / 2);
      sy.jump(h / 2);
      primed.current = true;
    };
    center();
    setActiveIndex(0);
    setHovering(true);
    let i = 0;
    const id = setInterval(() => {
      i = (i + 1) % items.length;
      setActiveIndex(i);
      const el = rootRef.current;
      if (el) {
        x.set(el.offsetWidth * (0.5 + 0.22 * Math.sin(i * 1.7)));
        y.set(el.offsetHeight * (0.5 + 0.16 * Math.cos(i * 1.3)));
      }
    }, 1500);
    return () => {
      clearInterval(id);
      setHovering(false);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [autoPlay, items.length]);

  useEffect(() => {
    videoRefs.current.forEach((v, i) => {
      if (!v) return;
      if (visible && i === activeIndex) {
        v.play().catch(() => {});
      } else {
        v.pause();
      }
    });
  }, [visible, activeIndex]);

  return (
    <div
      ref={rootRef}
      onMouseEnter={(e) => primeAt(e.clientX, e.clientY)}
      onMouseMove={(e) => primeAt(e.clientX, e.clientY)}
      onMouseLeave={() => setHovering(false)}
      className={`relative flex h-full w-full items-center justify-center overflow-hidden bg-background ${className}`}
    >
      <motion.div
        aria-hidden
        style={{ x: sx, y: sy }}
        className="pointer-events-none absolute left-0 top-0 z-0"
      >
        <div className="-translate-x-1/2 -translate-y-1/2">
          <motion.div
            style={{
              width: `${previewWidth}rem`,
              maxWidth: "75vw",
              ...(reduce ? {} : { rotate, skewX, skewY }),
            }}
            animate={{ scale: visible ? 1 : 0.3, opacity: visible ? 1 : 0 }}
            transition={{
              duration: visible ? 0.5 : 0.35,
              ease: visible ? [0.34, 1.56, 0.64, 1] : [0.4, 0, 1, 1],
            }}
            className="relative aspect-video overflow-hidden will-change-transform"
          >
            {items.map((item, i) => (
              <video
                key={item.video}
                ref={(el) => {
                  videoRefs.current[i] = el;
                }}
                src={item.video}
                muted
                loop
                playsInline
                preload="metadata"
                className="absolute inset-0 h-full w-full object-cover transition-opacity duration-300"
                style={{ opacity: i === activeIndex ? 1 : 0 }}
              />
            ))}
            <div
              className="absolute inset-0"
              style={{
                background: `color-mix(in srgb, var(--background) ${overlayDark * 100}%, transparent)`,
              }}
            />
          </motion.div>
        </div>
      </motion.div>

      <motion.ul
        variants={reduce ? undefined : containerVariants}
        initial={reduce ? undefined : "hidden"}
        animate={reduce ? undefined : "show"}
        style={{ fontFamily: "var(--font-oswald, 'Oswald'), sans-serif" }}
        className="relative z-10 flex flex-col items-center gap-2 md:gap-3"
      >
        {items.map((item, i) => {
          const isActive = i === activeIndex;
          const dimmed = hovering && !isActive;
          return (
            <motion.li
              key={item.title}
              variants={reduce ? undefined : itemVariants}
              onMouseEnter={() => {
                setActiveIndex(i);
                setHovering(true);
              }}
              className="cursor-pointer"
              style={{ willChange: "transform" }}
            >
              <div
                className="flex items-baseline gap-[0.6rem] uppercase transition-[opacity,color] duration-300 sm:gap-5"
                style={{
                  opacity: dimmed ? dimOpacity : 1,
                  color:
                    isActive && hovering
                      ? "var(--foreground)"
                      : "color-mix(in srgb, var(--foreground) 78%, transparent)",
                }}
              >
                <span
                  className="text-[clamp(0.6rem,1.2vw,0.85rem)] font-light tracking-[0.3em] transition-colors duration-300 sm:tracking-[0.5em]"
                  style={{ color: isActive && hovering ? "#a8a29e" : "#78716c" }}
                >
                  {item.num}
                </span>
                <span className="whitespace-nowrap text-[1.05rem] font-extralight leading-[1.25] tracking-[0.15em] sm:text-[clamp(1.4rem,4.2vw,3rem)] sm:tracking-[0.28em]">
                  {item.title}
                </span>
              </div>
            </motion.li>
          );
        })}
      </motion.ul>
    </div>
  );
}

Keep in mind

Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.

Contact

Found a bug or an issue? Drop a mail — hello@harshpandav.dev

License & Usage

  • Free to use and modify in personal and commercial projects.
  • Attribution to Harsh Dev UI is appreciated but not required.
  • MIT licensed.

Command Palette

Search for a command to run...