Simple Marquee
A simple marquee component for scrolling HTML elements.
Weekly Finds
Artworks from Cosmos.
Credits
This component is inspired by this scroll example by Motion.
Installation
1pnpm dlx shadcn@latest add "https://fancycomponents.dev/r/simple-marquee.json"
Usage
You only need to wrap your elements with the SimpleMarquee
component, everything else is taken care of by the component itself.
Understanding the component
Unlike most marquee implementations that use simple CSS animations, this component uses Motion's useAnimationFrame
hook to provide more control over the animation. This allows for a bunch of fancy effects, such as:
- Changing velocity and direction by dragging
- Adjusting speed in response to scrolling
- Adding custom easing functions
- Creating pause/slow on hover effects
Core Animation
The main magic of this component is the useAnimationFrame
hook from Motion, which executes our anim code on every frame. Here's how it works:
- We create motion values (using
useMotionValue
) to track the x or y position:
Motion values
1const baseX = useMotionValue(0)2const baseY = useMotionValue(0)
- We define a
baseVelocity
prop that determines the default speed and direction:
Base velocity
1// Convert baseVelocity to the correct direction2 const actualBaseVelocity =3 direction === "left" || direction === "up" ? -baseVelocity : baseVelocity
- On each animation frame inside the
useAnimationFrame
hook, we increment the position values, by adding that velocity to the current position:
Animation frame
1// Inside useAnimationFrame2let moveBy = directionFactor.current * baseVelocity * (delta / 1000)34if (isHorizontal) {5 baseX.set(baseX.get() + moveBy)6} else {7 baseY.set(baseY.get() + moveBy)8}
- Since we're constantly increasing/decreasing that value, at some point our elements would move out far away from the viewport. Therefore, we use the
useTransform
hook to convert that x/y value to a percentage, and wrapping it between 0 and -100. With this, we essentially force our elements to always move from 0 to -100. Once they reach -100, they will start their journey from 0% again.
Transformation
1const x = useTransform(baseX, (v) => {2 // wrap it between 0 and -1003 const wrappedValue = wrap(0, -100, v)4 // Apply easing if provided, otherwise use linear5 return `${easing ? easing(wrappedValue / -100) * -100 : wrappedValue}%`6})
- The
wrap
helper function ensures values stay between 0 and -100:
Wrapping
1const wrap = (min: number, max: number, value: number): number => {2 const range = max - min3 return ((((value - min) % range) + range) % range) + min4}
This example demonstrates the basic mechanism:
Preventing "Jumps" With Repetition
As you can see above, elements eventually leave the container and jump back to the beginning when they reach -100%. This creates a visible "jump" in the animation.
We can solve this by using the repeat
prop to duplicate all child elements multiple times inside the component:
Repeat example
1{2 Array.from({ length: repeat }, (_, i) => i).map((i) => (3 <motion.div4 key={i}5 className={cn(6 "shrink-0",7 isHorizontal && "flex",8 draggable && grabCursor && "cursor-grab"9 )}10 style={isHorizontal ? { x } : { y }}11 aria-hidden={i > 0}12 >13 {children}14 </motion.div>15 ))16}
By default, the repeat
value is 3, which means your content is duplicated three times. With enough repetitions, new elements enter the visible area before existing ones leave, creating an illusion of continuous animation. Try increasing the repeat
value in the demo above to see how it eliminates the jumpiness.
Features
The marquee's final velocity and behavior are determined by combining several factors that can be enabled through props:
Slow Down On Hover
When slowdownOnHover
is set to true
, the component tracks hover state and applies a slowdown factor:
Slow down on hover
1// Track hover state2const isHovered = useRef(false)3const hoverFactorValue = useMotionValue(1)4const smoothHoverFactor = useSpring(hoverFactorValue, slowDownSpringConfig)56// In component JSX7<motion.div8 onHoverStart={() => (isHovered.current = true)}9 onHoverEnd={() => (isHovered.current = false)}10 // ...other props11>12 {/* ... */}13</motion.div>1415// In animation frame16if (isHovered.current) {17 hoverFactorValue.set(slowdownOnHover ? slowDownFactor : 1)18} else {19 hoverFactorValue.set(1)20}2122// Apply the hover factor to movement calculation23let moveBy = directionFactor.current *24 actualBaseVelocity *25 (delta / 1000) *26 smoothHoverFactor.get()
Key props for this feature:
slowDownFactor
controls how much to slow down (default: 0.3 or 30% of original speed)smoothHoverFactor
uses spring physics for smooth transitions between speeds. This ensures that the velocity change is not happening instantly, but with a smooth animation. For this, we use theuseSpring
hook from Motion.slowDownSpringConfig
lets you customize the spring animation parameters. Please refer to the Motion documentation for more details.
Scroll-Based Velocity
When useScrollVelocity
is enabled, the component tracks scroll velocity and uses it to influence the final velocity of the marquee:
Scroll velocity
1const { scrollY } = useScroll({2 container: (scrollContainer as RefObject<HTMLDivElement>) || innerContainer.current,3})4const scrollVelocity = useVelocity(scrollY)5const smoothVelocity = useSpring(scrollVelocity, scrollSpringConfig)67// Transform scroll velocity into a factor for marquee speed8const velocityFactor = useTransform(9 useScrollVelocity ? smoothVelocity : defaultVelocity,10 [0, 1000],11 [0, 5],12 { clamp: false }13)1415// In animation frame16// Adjust movement based on scroll velocity17moveBy += directionFactor.current * moveBy * velocityFactor.get()1819// Change direction based on scroll if enabled20if (scrollAwareDirection && !isDragging.current) {21 if (velocityFactor.get() < 0) {22 directionFactor.current = -123 } else if (velocityFactor.get() > 0) {24 directionFactor.current = 125 }26}
This creates an interactive effect where:
- Scrolling adds to the marquee's velocity
- If
scrollAwareDirection
is enabled, the scroll direction can reverse the marquee direction - Similar to the hover, we interpolate between the current and scroll velocity by using Spring physics with the
useSpring
hook from Motion. You can customize the spring animation parameters using thescrollSpringConfig
prop.
Custom Easing Functions
The easing
prop allows you to transform the linear animation with custom easing curves:
Custom easing
1const x = useTransform(baseX, (v) => {2 // Apply easing if provided, otherwise use linear3 const wrappedValue = wrap(0, -100, v)4 return `${easing ? easing(wrappedValue / -100) * -100 : wrappedValue}%`5})
The easing function receives a normalized value between 0 and 1 and should return a transformed value. You need to provide an actual function here, not defined keyframes.
You can find ready-to-use easing functions at easings.net.
Draggable Marquee
The marquee can also be dragged. It uses pointer events for tracking the cursor position and applying the drag velocity:
Dragging
1// State for tracking dragging2const isDragging = useRef(false)3const dragVelocity = useRef(0)4const lastPointerPosition = useRef({ x: 0, y: 0 })56const handlePointerDown = (e: React.PointerEvent) => {7 if (!draggable) return8 // Capture pointer events9 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)1011 if (grabCursor) {12 (e.currentTarget as HTMLElement).style.cursor = "grabbing"13 }1415 isDragging.current = true16 lastPointerPosition.current = { x: e.clientX, y: e.clientY }1718 // Pause automatic animation19 dragVelocity.current = 020}2122const handlePointerMove = (e: React.PointerEvent) => {23 if (!draggable || !isDragging.current) return2425 const currentPosition = { x: e.clientX, y: e.clientY }2627 // Calculate movement delta28 const deltaX = currentPosition.x - lastPointerPosition.current.x29 const deltaY = currentPosition.y - lastPointerPosition.current.y3031 // Support for angled dragging32 const angleInRadians = (dragAngle * Math.PI) / 18033 const directionX = Math.cos(angleInRadians)34 const directionY = Math.sin(angleInRadians)3536 // Project movement along angle direction37 const projectedDelta = deltaX * directionX + deltaY * directionY3839 // Set drag velocity40 dragVelocity.current = projectedDelta * dragSensitivity4142 lastPointerPosition.current = currentPosition43}
During animation frames, dragging takes precedence over other movement factors. Meaning, when the user is dragging, the marquee will move according to the drag velocity, and we ignore all other factors (such as the hover, scroll and the basic velocity).
Drag animation frame
1// Inside useAnimationFrame2if (isDragging.current && draggable) {3 if (isHorizontal) {4 baseX.set(baseX.get() + dragVelocity.current)5 } else {6 baseY.set(baseY.get() + dragVelocity.current)7 }89 // Add decay to dragVelocity when not moving10 dragVelocity.current *= 0.91112 // Stop completely if velocity is very small13 if (Math.abs(dragVelocity.current) < 0.01) {14 dragVelocity.current = 015 }1617 return18}
When the user stops dragging, velocity gradually decays back to the base velocity. You can customize the decay factor using the dragVelocityDecay
prop.
Drag velocity decay
1// Gradually decay drag velocity back to zero2if (!isDragging.current && Math.abs(dragVelocity.current) > 0.01) {3 dragVelocity.current *= dragVelocityDecay4} else if (!isDragging.current) {5 dragVelocity.current = 06}
The component also supports changing direction based on drag movement:
Drag direction
1// Update direction based on drag direction2if (dragAwareDirection && Math.abs(dragVelocity.current) > 0.1) {3 // If dragging in negative direction, set directionFactor to -14 // If dragging in positive direction, set directionFactor to 15 directionFactor.current = Math.sign(dragVelocity.current)6}
New Arrivals
Artwork credits: Artworks are from Cosmos. I couldn't track down the original artists.
3D Transforms
To make a 3d effect, you can apply 3D CSS transforms to the marquee container or its children. The following example shows how you can apply them on the container.
Weekly Mix
For angled marquees, you can also apply the dragAngle
prop to change the direction of the drag movement. This is useful if you want to rotate the marquee e.g. by 45 degrees.
3D transforms
1// Convert dragAngle from degrees to radians2const angleInRadians = (dragAngle * Math.PI) / 18034// Calculate the projection of the movement along the angle direction5const directionX = Math.cos(angleInRadians)6const directionY = Math.sin(angleInRadians)78// Project the movement onto the angle direction9const projectedDelta = deltaX * directionX + deltaY * directionY
Resources
- Scroll animations from Motion
- Easings
- CSS Only implementation from Frontend FYI
- Gradient artworks
- Album covers
Props
Prop | Type | Default | Description |
---|---|---|---|
children* | ReactNode | - | The elements to be scrolled |
className | string | - | Additional CSS classes for the container |
direction | "left" | "right" | "up" | "down" | right | The direction of the marquee. Set to |
baseVelocity | number | 5 | The base velocity of the marquee in pixels per second |
easing | (value: number) => number | - | The easing function for the animation |
slowdownOnHover | boolean | false | Whether to slow down the animation on hover |
slowDownFactor | number | 0.3 | The factor to slow down the animation on hover |
slowDownSpringConfig | SpringOptions | { damping: 50, stiffness: 400 } | The spring config for the slow down animation |
useScrollVelocity | boolean | false | Whether to use the scroll velocity to control the marquee speed |
scrollAwareDirection | boolean | false | Whether to adjust the direction based on the scroll direction |
scrollSpringConfig | SpringOptions | { damping: 50, stiffness: 400 } | The spring config for the scroll velocity-based direction adjustment |
scrollContainer | RefObject<HTMLElement> | HTMLElement | null | - | The container to use for the scroll velocity. If not provided, the window will be used. |
repeat | number | 3 | The number of times to repeat the children |
draggable | boolean | false | Whether to allow dragging of the marquee |
dragSensitivity | number | 0.2 | The sensitivity of the drag movement |
dragVelocityDecay | number | 0.96 | The decay of the drag velocity when released |
dragAwareDirection | boolean | false | Whether to adjust the direction based on the drag velocity |
dragAngle | number | 0 | The angle of the drag movement in degrees |
grabCursor | boolean | false | Whether to change the cursor to grabbing when dragging |