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.
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 motionPoint 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-reelHow 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
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.
"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.