Marquee on SVG Path
Marquee on SVG Path
Images follow a looping SVG curve with drag momentum and scroll-responsive speed.
Drag the images or use the arrow keys while the path is focused.
Component preview
Marquee on SVG Path
Install using CLI
npx shadcn@latest add "https://www.obsidianui.dev/r/svg-path-marquee.json"Usage
Code
"use client";
import { SvgPathMarquee } from "@/components/block/svg-path-marquee";
const path = "M1 209.434C58.5872 255.935 387.926 325.938 482.583 209.434C600.905 63.8051 525.516 -43.2211 427.332 19.9613C329.149 83.1436 352.902 242.723 515.041 267.302C644.752 286.966 943.56 181.94 995 156.5";
const images = [
"https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/svg-path-marquee/svg-path-marquee-img01.png",
"https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/svg-path-marquee/svg-path-marquee-img02.png",
"https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/svg-path-marquee/svg-path-marquee-img03.png",
"https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/svg-path-marquee/svg-path-marquee-img04.png",
"https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/svg-path-marquee/svg-path-marquee-img05.png",
"https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/svg-path-marquee/svg-path-marquee-img06.png",
"https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/svg-path-marquee/svg-path-marquee-img07.png",
"https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/svg-path-marquee/svg-path-marquee-img08.png"
];
export default function Demo() {
return <SvgPathMarquee path={path} viewBox="0 0 996 330" className="h-[400px] w-full overflow-hidden bg-black" responsive draggable grabCursor baseVelocity={8} repeat={2}>
{images.map((src, index) => <img key={src} src={src} alt={`Landscape ${index + 1}`} width={100} height={140} draggable={false} />)}
</SvgPathMarquee>;
}Install Manually
1
Install dependencies
npm install motion
2
Copy the source code
Copy into components/block/svg-path-marquee.jsx
"use client";
import React, { useCallback, useEffect, useId, useRef } from "react";
import {
motion,
useAnimationFrame,
useMotionValue,
useScroll,
useSpring,
useTransform,
useVelocity,
useReducedMotion,
} from "motion/react";
// Custom wrap function
const wrap = (min, max, value) => {
const range = max - min
return ((((value - min) % range) + range) % range) + min
}
function MarqueePathItem({
child,
repeatIndex,
itemIndex,
itemKey,
baseOffset,
itemCount,
easing,
calculateZIndex,
cssVariableInterpolation,
draggable,
grabCursor,
path,
enableRollingZIndex,
itemRefs,
isHoveredRef,
}) {
const itemOffset = useTransform(baseOffset, (value) => {
const position = (itemIndex * 100) / itemCount
const wrappedValue = wrap(0, 100, value + position)
return `${easing ? easing(wrappedValue / 100) * 100 : wrappedValue}%`
})
const currentOffsetDistance = useMotionValue(0)
const zIndex = useTransform(currentOffsetDistance, (value) =>
calculateZIndex(value))
const itemRef = useRef(null)
useEffect(() => {
const unsubscribe = itemOffset.on("change", (value) => {
const match = value.match(/^([\d.]+)%$/)
if (match && match[1]) {
const numericValue = parseFloat(match[1])
currentOffsetDistance.set(numericValue)
if (itemRef.current) {
cssVariableInterpolation.forEach(({ property, from, to }) => {
const nextValue = from + (to - from) * (numericValue / 100)
itemRef.current.style.setProperty(property, String(nextValue))
})
}
}
})
return unsubscribe
}, [cssVariableInterpolation, currentOffsetDistance, itemOffset])
return (
<motion.div
key={itemKey}
ref={(el) => {
itemRef.current = el
if (el) itemRefs.current.set(itemKey, el)
else itemRefs.current.delete(itemKey)
}}
className={`absolute top-0 left-0 ${draggable && grabCursor ? 'cursor-grab' : ''}`}
style={{
offsetPath: `path('${path}')`,
offsetDistance: itemOffset,
zIndex: enableRollingZIndex ? zIndex : undefined,
willChange: "offset-distance",
backfaceVisibility: "hidden",
}}
aria-hidden={repeatIndex > 0}
onMouseEnter={() => (isHoveredRef.current = true)}
onMouseLeave={() => (isHoveredRef.current = false)}>
{child}
</motion.div>
)
}
/** @param {{children?: import('react').ReactNode, className?: string, path?: string, pathId?: string,
* preserveAspectRatio?: string, showPath?: boolean, width?: string | number, height?: string | number, viewBox?: string,
* baseVelocity?: number, direction?: string, easing?: (value: number) => number, slowdownOnHover?: boolean,
* slowDownFactor?: number, slowDownSpringConfig?: {damping: number, stiffness: number}, useScrollVelocity?: boolean,
* scrollAwareDirection?: boolean, scrollSpringConfig?: {damping: number, stiffness: number},
* scrollContainer?: import('react').RefObject<HTMLElement | null>, repeat?: number, draggable?: boolean,
* dragSensitivity?: number, dragVelocityDecay?: number, dragAwareDirection?: boolean, grabCursor?: boolean,
* enableRollingZIndex?: boolean, zIndexBase?: number, zIndexRange?: number,
* cssVariableInterpolation?: Array<{property: string, from: number, to: number}>, responsive?: boolean, label?: string}} props */
export const SvgPathMarquee = ({
children,
className,
// Path defaults
path,
pathId,
preserveAspectRatio = "xMidYMid meet",
showPath = false,
// SVG defaults
width = "100%",
height = "100%",
viewBox = "0 0 100 100",
// Marquee defaults
baseVelocity = 5,
direction = "normal",
easing,
slowdownOnHover = false,
slowDownFactor = 0.3,
slowDownSpringConfig = { damping: 50, stiffness: 400 },
// Scroll defaults
useScrollVelocity = false,
scrollAwareDirection = false,
scrollSpringConfig = { damping: 50, stiffness: 400 },
scrollContainer,
// Items repetition
repeat = 3,
// Drag defaults
draggable = false,
dragSensitivity = 0.2,
dragVelocityDecay = 0.96,
dragAwareDirection = false,
grabCursor = false,
// Z-index defaults
enableRollingZIndex = true,
// Base z-index value
zIndexBase = 1,
// Range of z-index values to use
zIndexRange = 10,
cssVariableInterpolation = [],
// Responsive defaults
responsive = false,
label = "Images following a curved path. Drag or use the left and right arrow keys.",
}) => {
const container = useRef(null)
const marqueeContainerRef = useRef(null)
const baseOffset = useMotionValue(0)
const pathRef = useRef(null)
const itemRefs = useRef(new Map())
const generatedPathId = useId()
const reduceMotion = useReducedMotion()
// Responsive scaling using direct DOM manipulation (no re-renders)
useEffect(() => {
if (!responsive) return
const [, , vbWidth, vbHeight] = viewBox.split(" ").map(Number)
const originalWidth = vbWidth || 100
const originalHeight = vbHeight || 100
const updateScale = () => {
const wrapper = container.current
const marqueeContainer = marqueeContainerRef.current
if (!wrapper || !marqueeContainer) return
const wrapperWidth = wrapper.clientWidth
const wrapperHeight = wrapper.clientHeight
const scaleX = wrapperWidth / originalWidth
const scaleY = wrapperHeight / originalHeight
const scale = Math.min(scaleX, scaleY)
// Calculate the scaled dimensions
const scaledWidth = originalWidth * scale
const scaledHeight = originalHeight * scale
// Center the marquee container within the wrapper
const offsetX = (wrapperWidth - scaledWidth) / 2
const offsetY = (wrapperHeight - scaledHeight) / 2
// Set fixed dimensions on the container
marqueeContainer.style.width = `${originalWidth}px`
marqueeContainer.style.height = `${originalHeight}px`
// Apply scale and position to center
marqueeContainer.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`
marqueeContainer.style.transformOrigin = "top left"
}
updateScale()
const observer = new ResizeObserver(updateScale)
if (container.current) observer.observe(container.current)
window.addEventListener("resize", updateScale)
return () => { observer.disconnect(); window.removeEventListener("resize", updateScale); };
}, [responsive, viewBox])
// Create an array of items outside of the render function
const items = React.useMemo(() => {
const childrenArray = React.Children.toArray(children)
return childrenArray.flatMap((child, childIndex) =>
Array.from({ length: repeat }, (_, repeatIndex) => {
const itemIndex = repeatIndex * childrenArray.length + childIndex
const key = `${childIndex}-${repeatIndex}`
return {
child,
childIndex,
repeatIndex,
itemIndex,
key,
}
}));
}, [children, repeat])
// Function to calculate z-index based on offset distance
const calculateZIndex = useCallback((offsetDistance) => {
if (!enableRollingZIndex) {
return undefined
}
// Simple progress-based z-index
const normalizedDistance = offsetDistance / 100
return Math.floor(zIndexBase + normalizedDistance * zIndexRange);
}, [enableRollingZIndex, zIndexBase, zIndexRange])
// Generate a random ID for the path if not provided
const id = pathId || `marquee-path-${generatedPathId.replace(/:/g, "")}`
// Scroll tracking
const { scrollY } = useScroll({
container: scrollContainer || undefined,
})
const scrollVelocity = useVelocity(scrollY)
const smoothVelocity = useSpring(scrollVelocity, scrollSpringConfig)
// Hover and drag state tracking
const isHoveredRef = useRef(false)
const isDragging = useRef(false)
const dragVelocity = useRef(0)
// Direction factor for changing direction based on scroll or drag
const directionFactor = useRef(direction === "normal" ? 1 : -1)
// Motion values for animation
const hoverFactorValue = useMotionValue(1)
const defaultVelocity = useMotionValue(1)
const smoothHoverFactor = useSpring(hoverFactorValue, slowDownSpringConfig)
// Transform scroll velocity into a factor that affects marquee speed
const velocityFactor = useTransform(
useScrollVelocity ? smoothVelocity : defaultVelocity,
[0, 1000],
[0, 5],
{ clamp: false }
)
// Animation frame handler
useAnimationFrame((_, delta) => {
if (reduceMotion) return
if (isDragging.current && draggable) {
baseOffset.set(baseOffset.get() + dragVelocity.current)
// Add decay to dragVelocity
dragVelocity.current *= 0.9
// Stop completely if velocity is very small
if (Math.abs(dragVelocity.current) < 0.01) {
dragVelocity.current = 0
}
return
}
// Update hover factor
if (isHoveredRef.current) {
hoverFactorValue.set(slowdownOnHover ? slowDownFactor : 1)
} else {
hoverFactorValue.set(1)
}
// Calculate regular movement
let moveBy =
directionFactor.current *
baseVelocity *
(delta / 1000) *
smoothHoverFactor.get()
// Adjust movement based on scroll velocity if scrollAwareDirection is enabled
if (scrollAwareDirection && !isDragging.current) {
if (velocityFactor.get() < 0) {
directionFactor.current = -1
} else if (velocityFactor.get() > 0) {
directionFactor.current = 1
}
}
moveBy += directionFactor.current * moveBy * velocityFactor.get()
if (draggable) {
moveBy += dragVelocity.current
// Update direction based on drag direction if dragAwareDirection is true
if (dragAwareDirection && Math.abs(dragVelocity.current) > 0.1) {
directionFactor.current = Math.sign(dragVelocity.current)
}
// Gradually decay drag velocity back to zero
if (!isDragging.current && Math.abs(dragVelocity.current) > 0.01) {
dragVelocity.current *= dragVelocityDecay
} else if (!isDragging.current) {
dragVelocity.current = 0
}
}
baseOffset.set(baseOffset.get() + moveBy)
})
// Pointer event handlers for dragging
const lastPointerPosition = useRef({ x: 0, y: 0 })
const handlePointerDown = (e) => {
if (!draggable) return
;(e.currentTarget).setPointerCapture(e.pointerId)
if (grabCursor) {
;(e.currentTarget).style.cursor = "grabbing"
}
isDragging.current = true
lastPointerPosition.current = { x: e.clientX, y: e.clientY }
// Pause automatic animation by setting velocity to 0
dragVelocity.current = 0
}
const handlePointerMove = (e) => {
if (!draggable || !isDragging.current) return
const currentPosition = { x: e.clientX, y: e.clientY }
// Calculate movement delta - simplified for path movement
const deltaX = currentPosition.x - lastPointerPosition.current.x
const deltaY = currentPosition.y - lastPointerPosition.current.y
// For path following, we use a simple magnitude of movement
const delta = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
const projectedDelta = deltaX > 0 ? delta : -delta
// Update drag velocity based on the projected movement
dragVelocity.current = projectedDelta * dragSensitivity
if (reduceMotion) baseOffset.set(baseOffset.get() + dragVelocity.current)
// Update last position
lastPointerPosition.current = currentPosition
}
const handlePointerUp = (e) => {
if (!draggable) return
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId)
isDragging.current = false
if (grabCursor) {
;(e.currentTarget).style.cursor = "grab"
}
}
return (
<div
ref={container}
role="region"
tabIndex={draggable ? 0 : undefined}
aria-label={label}
onKeyDown={(event) => {
if (!draggable || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return
event.preventDefault()
baseOffset.set(baseOffset.get() + (event.key === "ArrowRight" ? 5 : -5))
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={`relative font-body outline-offset-4 focus-visible:outline-2 focus-visible:outline-ring ${className || ''}`}>
<div
ref={marqueeContainerRef}
className="relative"
style={{ contain: "layout style" }}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox={viewBox}
preserveAspectRatio={preserveAspectRatio}
className="w-full h-full">
<path
id={id}
d={path}
stroke={showPath ? "currentColor" : "none"}
fill="none"
ref={pathRef} />
</svg>
{items.map(({ child, repeatIndex, itemIndex, key }) => (
<MarqueePathItem
key={key}
child={child}
repeatIndex={repeatIndex}
itemIndex={itemIndex}
itemKey={key}
baseOffset={baseOffset}
itemCount={items.length}
easing={easing}
calculateZIndex={calculateZIndex}
cssVariableInterpolation={cssVariableInterpolation}
draggable={draggable}
grabCursor={grabCursor}
path={path}
enableRollingZIndex={enableRollingZIndex}
itemRefs={itemRefs}
isHoveredRef={isHoveredRef}
/>
))}
</div>
</div>
);
}
export { SvgPathMarquee as MarqueeAlongSvgPath }Preview behavior
Drag the images or use the arrow keys while the path is focused. The effect stays inside its container and respects reduced motion preferences.
Last updated on