Marquee
A customizable, interactive scrolling marquee component with various animation options, drag capabilities, and responsive design.
Marquee
Click or drag the marquee to interact with it
Installation Guide
1
Install Dependencies
Framer Motion
npm install framer-motion
Utility Functions
npm install clsx tailwind-merge
2
Setup Configuration
Create file:
/lib/utils.ts
/lib/utils.ts
Configuration1import { clsx, type ClassValue } from "clsx";
2import { twMerge } from "tailwind-merge";
3
4export function cn(...inputs: ClassValue[]) {
5return twMerge(clsx(inputs));
6}
3
Copy Component Code
Marquee.tsx
TypeScript1"use client";
2import { cn } from "@/lib/utils";
3import { useMotionValue, animate, motion } from "framer-motion";
4import { useState, useEffect, useRef } from "react";
5
6export type MarqueeProps = {
7 children: React.ReactNode;
8 gap?: number;
9 speed?: number;
10 speedOnHover?: number;
11 direction?: "horizontal" | "vertical";
12 reverse?: boolean;
13 className?: string;
14 fadeEdges?: boolean;
15 fadeWidth?: number;
16 pauseOnTap?: boolean;
17 draggable?: boolean;
18};
19
20export function Marquee({
21 children,
22 gap = 16,
23 speed = 100,
24 speedOnHover,
25 direction = "horizontal",
26 reverse = false,
27 className,
28 fadeEdges = false,
29 fadeWidth = 64,
30 pauseOnTap = true,
31 draggable = true,
32}: MarqueeProps) {
33 const [currentSpeed, setCurrentSpeed] = useState(speed);
34 const [isPaused, setIsPaused] = useState(false);
35 const [isDragging, setIsDragging] = useState(false);
36 const containerRef = useRef<HTMLDivElement>(null);
37 const translation = useMotionValue(0);
38 const [isTransitioning, setIsTransitioning] = useState(false);
39 const [key, setKey] = useState(0);
40 const dragStartPosition = useRef(0);
41 const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
42
43 useEffect(() => {
44 if (!containerRef.current) return;
45
46 const currentRef = containerRef.current;
47
48 const updateDimensions = () => {
49 if (currentRef) {
50 const rect = currentRef.getBoundingClientRect();
51 setDimensions({
52 width: rect.width,
53 height: rect.height,
54 });
55 }
56 };
57
58 updateDimensions();
59
60 const resizeObserver = new ResizeObserver(updateDimensions);
61 resizeObserver.observe(currentRef);
62
63 return () => {
64 resizeObserver.unobserve(currentRef);
65 resizeObserver.disconnect();
66 };
67 }, []);
68
69 const { width, height } = dimensions;
70
71 useEffect(() => {
72 let controls;
73
74 if (isPaused || isDragging || (!width && !height)) {
75 return () => {};
76 }
77
78 const size = direction === "horizontal" ? width : height;
79 const contentSize = size + gap;
80
81 if (!size) return () => {};
82
83 const from = reverse ? -contentSize / 2 : 0;
84 const to = reverse ? 0 : -contentSize / 2;
85 const distanceToTravel = Math.abs(to - from);
86 const duration = distanceToTravel / currentSpeed;
87
88 if (isTransitioning) {
89 const remainingDistance = Math.abs(translation.get() - to);
90 const transitionDuration = remainingDistance / currentSpeed;
91
92 controls = animate(translation, [translation.get(), to], {
93 ease: "linear",
94 duration: transitionDuration,
95 onComplete: () => {
96 setIsTransitioning(false);
97 setKey((prevKey) => prevKey + 1);
98 },
99 });
100 } else {
101 controls = animate(translation, [from, to], {
102 ease: "linear",
103 duration: duration,
104 repeat: Infinity,
105 repeatType: "loop",
106 repeatDelay: 0,
107 onRepeat: () => {
108 translation.set(from);
109 },
110 });
111 }
112
113 return controls?.stop;
114 }, [
115 key,
116 translation,
117 currentSpeed,
118 width,
119 height,
120 gap,
121 isTransitioning,
122 direction,
123 reverse,
124 isPaused,
125 isDragging,
126 ]);
127
128 const fadeGradientStyles = (() => {
129 if (!fadeEdges) return {};
130
131 const size = direction === "horizontal" ? width : height;
132 if (size === 0) return {};
133
134 const fadePercentage = Math.min(100, Math.round((fadeWidth / size) * 100));
135
136 if (direction === "horizontal") {
137 return {
138 maskImage: `linear-gradient(to right, transparent, black ${fadePercentage}%, black ${
139 100 - fadePercentage
140 }%, transparent 100%)`,
141 WebkitMaskImage: `linear-gradient(to right, transparent, black ${fadePercentage}%, black ${
142 100 - fadePercentage
143 }%, transparent 100%)`,
144 };
145 } else {
146 return {
147 maskImage: `linear-gradient(to bottom, transparent, black ${fadePercentage}%, black ${
148 100 - fadePercentage
149 }%, transparent 100%)`,
150 WebkitMaskImage: `linear-gradient(to bottom, transparent, black ${fadePercentage}%, black ${
151 100 - fadePercentage
152 }%, transparent 100%)`,
153 };
154 }
155 })();
156
157 const handleTap = () => {
158 if (pauseOnTap && !isDragging) {
159 setIsPaused(!isPaused);
160 setIsTransitioning(true);
161 setKey((prevKey) => prevKey + 1);
162 }
163 };
164
165 const hoverProps = speedOnHover
166 ? {
167 onHoverStart: () => {
168 setIsTransitioning(true);
169 setCurrentSpeed(speedOnHover);
170 },
171 onHoverEnd: () => {
172 setIsTransitioning(true);
173 setCurrentSpeed(speed);
174 },
175 }
176 : {};
177
178 const handleDragStart = () => {
179 if (!draggable) return;
180
181 setIsDragging(true);
182 dragStartPosition.current = translation.get();
183 };
184
185 const handleDragEnd = () => {
186 if (!draggable) return;
187
188 setIsDragging(false);
189 setIsTransitioning(true);
190 setKey((prevKey) => prevKey + 1);
191 };
192
193 const dragConstraints = (() => {
194 const contentSize = direction === "horizontal" ? width : height;
195
196 return {
197 left: -contentSize,
198 right: contentSize,
199 top: -contentSize,
200 bottom: contentSize,
201 };
202 })();
203
204 return (
205 <div
206 className={cn(
207 "overflow-hidden relative",
208 className,
209 (pauseOnTap || draggable) && "cursor-pointer",
210 isDragging && "cursor-grabbing"
211 )}
212 style={fadeGradientStyles}
213 onClick={handleTap}
214 >
215 <motion.div
216 className={cn("flex w-max", draggable && "cursor-grab")}
217 style={{
218 ...(direction === "horizontal"
219 ? { x: translation }
220 : { y: translation }),
221 flexDirection: direction === "horizontal" ? "row" : "column",
222 gap: `${gap}px`,
223 }}
224 ref={containerRef}
225 {...hoverProps}
226 drag={draggable ? (direction === "horizontal" ? "x" : "y") : false}
227 dragConstraints={dragConstraints}
228 onDragStart={handleDragStart}
229 onDragEnd={handleDragEnd}
230 dragElastic={0.1}
231 dragMomentum={false}
232 >
233 {children}
234 {children}
235 </motion.div>
236 </div>
237 );
238}
239
4
Final Steps
Update Import Paths
Make sure to update the import paths in the component code to match your project structure. For example, change @/components/ui/button
to match your UI components location.
Props
Name | Type | Default | Description |
---|---|---|---|
children | React.ReactNode | undefined | Content to be displayed in the marquee |
gap | number | 16 | Space between repeated content in pixels |
speed | number | 100 | Animation speed (higher is faster) |
speedOnHover | number | undefined | Animation speed when hovered (higher is faster) |
direction | string | horizontal | Scrolling direction ('horizontal' or 'vertical') |
reverse | boolean | false | Reverse the animation direction |
className | string | undefined | Additional CSS classes to apply |
fadeEdges | boolean | false | Fade the edges of the marquee content |
fadeWidth | number | 64 | Width of the edge fade in pixels |
pauseOnTap | boolean | true | Pause animation when clicked |
draggable | boolean | true | Allow dragging interaction with the marquee |
Examples





























