Marquee Along SVG Path
Marquee Along SVG Path é um componente de marquee para React e Tailwind CSS, da biblioteca Fancy Components, com licença MIT. Copia e cola no teu projeto.
Fancy Components
MIT
marquee
O que é
Marquee Along SVG Path é um componente de marquee para React e Tailwind CSS, da biblioteca Fancy Components, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Precisa de
npm i motion
Licença
Este componente vem de Fancy Components e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
Marquee Along SVG Path
import React, { RefObject, useCallback, useEffect, useRef } from "react"
import {
motion,
SpringOptions,
useAnimationFrame,
useMotionValue,
useScroll,
useSpring,
useTransform,
useVelocity,
} from "motion/react"
import { cn } from "@/lib/utils"
// Custom wrap function
const wrap = (min: number, max: number, value: number): number => {
const range = max - min
return ((((value - min) % range) + range) % range) + min
}
type PreserveAspectRatioAlign =
| "none"
| "xMinYMin"
| "xMidYMin"
| "xMaxYMin"
| "xMinYMid"
| "xMidYMid"
| "xMaxYMid"
| "xMinYMax"
| "xMidYMax"
| "xMaxYMax"
interface CSSVariableInterpolation {
property: string
from: number | string
to: number | string
}
type PreserveAspectRatioMeetOrSlice = "meet" | "slice"
type PreserveAspectRatio =
| PreserveAspectRatioAlign
| `${Exclude<PreserveAspectRatioAlign, "none">} ${PreserveAspectRatioMeetOrSlice}`
interface MarqueeAlongSvgPathProps {
children: React.ReactNode
className?: string
// Path properties
path: string
pathId?: string
preserveAspectRatio?: PreserveAspectRatio
showPath?: boolean
// SVG properties
width?: string | number
height?: string | number
viewBox?: string
// Marquee properties
baseVelocity?: number
direction?: "normal" | "reverse"
easing?: (value: number) => number
slowdownOnHover?: boolean
slowDownFactor?: number
slowDownSpringConfig?: SpringOptions
// Scroll properties
useScrollVelocity?: boolean
scrollAwareDirection?: boolean
scrollSpringConfig?: SpringOptions
scrollContainer?: RefObject<HTMLElement | null> | HTMLElement | null
// Item repetition
repeat?: number
// Drag properties
draggable?: boolean
dragSensitivity?: number
dragVelocityDecay?: number
dragAwareDirection?: boolean
grabCursor?: boolean
// Z-index properties
enableRollingZIndex?: boolean
zIndexBase?: number
zIndexRange?: number
cssVariableInterpolation?: CSSVariableInterpolation[]
// Responsive properties
responsive?: boolean
}
const MarqueeAlongSvgPath = ({
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,
zIndexBase = 1, // Base z-index value
zIndexRange = 10, // Range of z-index values to use
cssVariableInterpolation = [],
// Responsive defaults
responsive = false,
}: MarqueeAlongSvgPathProps) => {
const container = useRef<HTMLDivElement>(null)
const marqueeContainerRef = useRef<HTMLDivElement>(null)
const baseOffset = useMotionValue(0)
const pathRef = useRef<SVGPathElement>(null)
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// 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()
window.addEventListener("resize", updateScale)
return () => 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: number) => {
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-${Math.random().toString(36).substring(7)}`
// Scroll tracking
const { scrollY } = useScroll({
container: (scrollContainer as RefObject<HTMLDivElement | null>) || container,
})
const scrollVelocity = useVelocity(scrollY)
const smoothVelocity = useSpring(scrollVelocity, scrollSpringConfig)
// Hover and drag state tracking
const isHovered = 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 (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 (isHovered.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: React.PointerEvent) => {
if (!draggable) return
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
if (grabCursor) {
;(e.currentTarget as HTMLElement).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: React.PointerEvent) => {
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
// Update last position
lastPointerPosition.current = currentPosition
}
const handlePointerUp = (e: React.PointerEvent) => {
if (!draggable) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
isDragging.current = false
if (grabCursor) {
;(e.currentTarget as HTMLElement).style.cursor = "grab"
}
}
return (
<div
ref={container}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn("relative", 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 }) => {
// Create a unique offset transform for each item
const itemOffset = useTransform(baseOffset, (v) => {
const position = (itemIndex * 100) / items.length
const wrappedValue = wrap(0, 100, v + position)
return `${easing ? easing(wrappedValue / 100) * 100 : wrappedValue}%`
})
// Create a motion value for the current offset distance
const currentOffsetDistance = useMotionValue(0)
// Update z-index when offset distance changes
const zIndex = useTransform(currentOffsetDistance, (value) =>
calculateZIndex(value)
)
// Update current offset distance value when animation runs
useEffect(() => {
const unsubscribe = itemOffset.on("change", (value: string) => {
// Parse percentage string to get numerical value
const match = value.match(/^([\d.]+)%$/)
if (match && match[1]) {
currentOffsetDistance.set(parseFloat(match[1]))
}
})
return unsubscribe
}, [itemOffset, currentOffsetDistance])
const cssVariables = Object.fromEntries(
(cssVariableInterpolation || []).map(({ property, from, to }) => [
property,
useTransform(currentOffsetDistance, [0, 100], [from, to]),
])
)
return (
<motion.div
key={key}
ref={(el) => {
if (el) itemRefs.current.set(key, el)
}}
className={cn(
"absolute top-0 left-0",
draggable && grabCursor && "cursor-grab"
)}
style={{
offsetPath: `path('${path}')`,
offsetDistance: itemOffset,
zIndex: enableRollingZIndex ? zIndex : undefined,
willChange: "offset-distance",
backfaceVisibility: "hidden",
...cssVariables,
}}
aria-hidden={repeatIndex > 0}
onMouseEnter={() => (isHovered.current = true)}
onMouseLeave={() => (isHovered.current = false)}
>
{child}
</motion.div>
)
})}
</div>
</div>
)
}
export default MarqueeAlongSvgPath