Skip to content

Marquee along SVG scaling issue #59

Open

Description

@dancrump1

When implementing a different SVG path than the provided example, an issue came up for me. The offsetPath on the motion.div does not scale the same as the <svg><path></svg> above it. The size of the travel the images takes depends on the string value for path.

Sorry if this isn't explained well. I can get the SVG to scale, but the mapped motion.div's remain the size outlined by the value i pass to d={path}. Moving the motion.div inside the SVG doesn't do anything, but making the motion.div a motion.g now allows the SVG to scale the offset-path value.

UPDATED RETURN for marquee:

return (
		<div
			ref={container}
			onPointerDown={handlePointerDown}
			onPointerMove={handlePointerMove}
			onPointerUp={handlePointerUp}
			onPointerCancel={handlePointerUp}
			className={cn("relative", className)}
		>
			<svg
				xmlns="http://www.w3.org/2000/svg"
				width={width}
				height={height}
				viewBox={viewBox}
				preserveAspectRatio={preserveAspectRatio}
			>
				{/* Path for visualization */}
				<path
					id={id}
					d={path}
					className="w-full"
					stroke={showPath ? "currentColor" : "none"}
					fill="none"
					ref={pathRef}
				/>

				{/* Animate each item along the path */}
				{items.map(({ child, repeatIndex, itemIndex, key }) => {
					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;
					});

					const x = useMotionValue(0);
					const y = useMotionValue(0);
					const zIndex = useTransform(currentOffsetDistance, (value) =>
						calculateZIndex(value)
					);

					useEffect(() => {
						const pathEl = pathRef.current;
						if (!pathEl) return;

						const totalLength = pathEl.getTotalLength();

						const unsubscribe = itemOffset.on(
							"change",
							(value: string) => {
								const percent = parseFloat(value) / 100;
								const point = pathEl.getPointAtLength(
									percent * totalLength
								);
								x.set(point.x);
								y.set(point.y);
							}
						);

						return unsubscribe;
					}, [itemOffset]);

					const cssVariables = Object.fromEntries(
						(cssVariableInterpolation || []).map(
							({ property, from, to }) => [
								property,
								useTransform(
									currentOffsetDistance,
									[0, 100],
									[from, to]
								),
							]
						)
					);

					return (
						<motion.g
							key={key}
							className={cn(
								"along-element relative",
								draggable && grabCursor && "cursor-grab"
							)}
							style={{
								offsetDistance: itemOffset,
								zIndex: enableRollingZIndex ? zIndex : undefined,
								translateX: x,
								translateY: y,
								...cssVariables,
							}}
							aria-hidden={repeatIndex > 0}
							onMouseEnter={() => (isHovered.current = true)}
							onMouseLeave={() => (isHovered.current = false)}
						>
							{child}
						</motion.g>
					);
				})}
			</svg>
		</div>
	);

Activity

  1. danielpetho commented on Aug 13, 2025

    @danielpetho
    Owner

    hey Daniel!

    Sorry this is my bad, since I completely left out the responsivity part.

    I will update the component soon, but in the meantime, pls check out this article.

    It's a step by step tutorial for this exact component I wrote for Codrops, so integrating the correct scaling should be hopefully easy. The relevant part is at the Responsiveness section, with 3 different techniques how to make it truly responsive.

    If you're having problem implementing it, please make a Codesandbox and I'll try to help.

  2. dancrump1 commented on Aug 14, 2025

    @dancrump1
    Author

    No worries on updating. Your article made it super easy, but I enjoyed trying to figure out the strategies you outlined myself first. I'll grab that npm lib you pointed out and give that a try. It'll probably be way cleaner than what I could craft in my code-freeze time :D

  3. danielpetho commented on Aug 29, 2025

    @danielpetho
    Owner

    nice, I'm glad it worked!

    the trick is actually from jhey, so full credits to him:)

    I'm going to keep this open until it's fixed though.

  4. dancrump1 commented on Aug 30, 2025

    @dancrump1
    Author

    Here is the current implementation I am running. Current assumptions are the svg viewbox sizing. These should be updated with the correct sizing for each SVG. Definitely not perfect! I'm sure you have something stronger than this already, it came basically from your article. Mostly posting for anyone struggling with using images.

    chrome_skfYzzY7r4.mp4
    import React, {
    	RefObject,
    	useCallback,
    	useEffect,
    	useMemo,
    	useRef,
    	useState,
    } from "react";
    
    import * as d3 from "d3";
    import {
    	motion,
    	SpringOptions,
    	useAnimationFrame,
    	useMotionValue,
    	useScroll,
    	useSpring,
    	useTransform,
    	useVelocity,
    } from "motion/react";
    
    type MarqueeAlongPathProps = {
    	children: React.ReactNode;
    	path: string;
    	baseVelocity: number;
    	repeat?: number;
    	zIndexBase?: number;
    	enableRollingZIndex?: boolean;
    	scrollContainerRef: React.RefObject<HTMLDivElement | null>;
    };
    
    type MarqueeItemProps = {
    	baseOffset: any;
    	itemIndex: number;
    	totalItems: number;
    	repeatIndex: number;
    	zIndexBase: number;
    	scaledPath: string;
    	isHovered: React.MutableRefObject<boolean>;
    	children: React.ReactNode;
    };
    
    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> | 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[];
    }
    
    /**
     * Parse SVG path string into coordinate points using D3
     * This extracts the actual coordinates from the path for scaling
     */
    const parsePathToPoints = (
    	pathString: string,
    	maxSamples: number = 100
    ): Array<[number, number]> => {
    	const points: Array<[number, number]> = [];
    
    	// Create a temporary SVG element to parse the path
    	const svg = d3.create("svg");
    	const path = svg.append("path").attr("d", pathString);
    
    	// Sample points along the path
    	const pathNode = path.node() as SVGPathElement;
    	if (pathNode) {
    		const totalLength = pathNode.getTotalLength();
    
    		// If the path is too long, sample only a subset of points. Majes
    		const numSamples = Math.min(maxSamples, totalLength);
    
    		for (let i = 0; i <= numSamples; i++) {
    			const point = pathNode.getPointAtLength(
    				(i / numSamples) * totalLength
    			);
    			points.push([point.x, point.y]);
    		}
    	}
    
    	return points;
    };
    
    /**
     * Create a scaled path using D3's line generator
     * This is the approach recommended in the CSS-Tricks article
     */
    const createScaledPath = (
    	originalPath: string,
    	originalWidth: number,
    	originalHeight: number,
    	newWidth: number,
    	newHeight: number
    ): string => {
    	// Parse the original path into points
    	const points = parsePathToPoints(originalPath);
    
    	// Create scales for X and Y coordinates
    	const xScale = d3
    		.scaleLinear()
    		.domain([0, originalWidth])
    		.range([0, newWidth]);
    
    	const yScale = d3
    		.scaleLinear()
    		.domain([0, originalHeight])
    		.range([0, newHeight]);
    
    	// Scale the points
    	const scaledPoints = points.map(
    		([x, y]) => [xScale(x), yScale(y)] as [number, number]
    	);
    
    	// Create a smooth curve using D3's line generator
    	const line = d3
    		.line()
    		.x((d) => d[0])
    		.y((d) => d[1])
    		.curve(d3.curveBasis); // Use basis curve for smooth interpolation
    
    	return line(scaledPoints) || "";
    };
    
    const MarqueeItem = ({
    	baseOffset,
    	itemIndex,
    	totalItems,
    	repeatIndex,
    	zIndexBase,
    	scaledPath,
    	isHovered,
    	children,
    }: MarqueeItemProps) => {
    	const itemOffset = useTransform(baseOffset, (v: number) => {
    		const position = (itemIndex * 100) / totalItems;
    		const wrappedValue = wrap(0, 100, v + position);
    		return `${wrappedValue}%`;
    	});
    
    	const zIndex = useTransform(itemOffset, (v) => {
    		const progress = parseFloat(v.replace("%", ""));
    		return Math.floor(zIndexBase + progress);
    	});
    
    	const opacity = useTransform(itemOffset, (v) => {
    		const progress = parseFloat(v.replace("%", "")) / 100;
    		const x = 2 * progress - 1;
    		return Math.pow(1 - Math.pow(Math.abs(x), 10), 2);
    	});
    
    	return (
    		<motion.div
    			className="marquee-item absolute top-0 left-0"
    			style={{
    				offsetPath: `path('${scaledPath}')`,
    				offsetDistance: itemOffset,
    				offsetRotate: "auto",
    				zIndex: zIndex,
    				// opacity: opacity,
    			}}
    			aria-hidden={repeatIndex > 0}
    			onMouseEnter={() => (isHovered.current = true)}
    			onMouseLeave={() => (isHovered.current = false)}
    		>
    			{children}
    		</motion.div>
    	);
    };
    
    const MarqueeAlongPath = ({
    	children,
    	path,
    	scrollContainerRef,
    	pathId,
    	preserveAspectRatio = "xMidYMid meet",
    	showPath = false,
    
    	// SVG defaults
    	width = "100%",
    	height = "100%",
    	viewBox = "0 0 200 1000",
    
    	// 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 = [],
    }: MarqueeAlongPathProps) => {
    	const baseOffset = useMotionValue(0);
    
    	const springConfig = {
    		stiffness: 100,
    		damping: 20,
    	};
    
    	// Scroll tracking
    	const { scrollY } = useScroll({
    		container: scrollContainer as RefObject<HTMLDivElement>,
    	});
    
    	const scrollVelocity = useVelocity(scrollY);
    	const smoothVelocity = useSpring(scrollVelocity, scrollSpringConfig);
    
    	const smoothScrollVelocity = useSpring(scrollVelocity, springConfig);
    
    	const scrollVelocityFactor = useTransform(
    		smoothScrollVelocity,
    		[0, 1000],
    		[0, 5],
    		{ clamp: false }
    	);
    
    	const items = 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]);
    
    	const currentOffsetDistance = useMotionValue(0);
    
    	// 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)}`;
    
    	// 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 }
    	);
    	useAnimationFrame((_, delta) => {
    		if (isHovered.current) {
    			hoverFactorValue.set(0.3);
    		} else {
    			hoverFactorValue.set(1);
    		}
    
    		let moveBy =
    			((baseVelocity * delta) / 1000) *
    			directionFactor.current *
    			smoothHoverFactor.get();
    
    		if (scrollVelocityFactor.get() < 0) {
    			directionFactor.current = -1;
    		} else if (scrollVelocityFactor.get() > 0) {
    			directionFactor.current = 1;
    		}
    
    		moveBy += directionFactor.current * moveBy * scrollVelocityFactor.get();
    
    		baseOffset.set(baseOffset.get() + moveBy);
    	});
    
    	const wrapperRef = useRef<HTMLDivElement>(null);
    
    	// Toggle between scaling methods: 1 or 2
    	const [useScaleMethod] = useState<1 | 2>(2);
    
    	// Scale method #1
    	const marqueeContainerRef = useRef<HTMLDivElement>(null);
    	// Original SVG dimensions
    	const originalWidth = 800;
    	const originalHeight = 400;
    
    	useEffect(() => {
    		if (useScaleMethod === 1) {
    			// Scale method #1: CSS transform scale
    			const updateScale = () => {
    				const wrapper = wrapperRef.current;
    				const marqueeContainer = marqueeContainerRef.current;
    				if (!wrapper || !marqueeContainer) return;
    
    				const scale = wrapper.clientWidth / originalWidth;
    				marqueeContainer.style.transform = `scale(${scale})`;
    				marqueeContainer.style.transformOrigin = "top left";
    			};
    
    			updateScale();
    			window.addEventListener("resize", updateScale);
    			return () => window.removeEventListener("resize", updateScale);
    		}
    	}, []);
    
    	// Scale method #2 with D3
    	const [scaledPath, setScaledPath] = useState(path);
    	const [currentViewBox, setCurrentViewBox] = useState(
    		`0 0 ${originalWidth} ${originalHeight}`
    	);
    
    	useEffect(() => {
    		if (useScaleMethod === 2) {
    			// Scale method #2: D3 path scaling
    			const updatePath = () => {
    				const wrapper = wrapperRef.current;
    				if (!wrapper) return;
    
    				const containerWidth = wrapper.clientWidth;
    				const containerHeight = wrapper.clientHeight;
    
    				// Use D3 to create the scaled path
    				const newPath = createScaledPath(
    					path,
    					originalWidth,
    					originalHeight,
    					containerWidth,
    					containerHeight
    				);
    
    				setScaledPath(newPath);
    				setCurrentViewBox(`0 0 ${containerWidth} ${containerHeight}`);
    			};
    
    			updatePath();
    			window.addEventListener("resize", updatePath);
    			return () => window.removeEventListener("resize", updatePath);
    		}
    	}, [path]);
    
    	return (
    		<div
    			className="container w-full relative overflow-x-hidden overflow-y-visible"
    			ref={wrapperRef}
    		>
    			<svg
    				width="100%"
    				height="100%"
    				viewBox={currentViewBox}
    				fill="none"
    				xmlns="http://www.w3.org/2000/svg"
    			>
    				<path d={scaledPath} stroke="black" fill="none" />
    			</svg>
    			<div
    				className="marquee-container absolute top-0 left-0 w-full h-full"
    				ref={marqueeContainerRef}
    			>
    				{items.map(({ child, repeatIndex, itemIndex, key }) => (
    					<MarqueeItem
    						key={key}
    						baseOffset={baseOffset}
    						itemIndex={itemIndex}
    						totalItems={items.length}
    						repeatIndex={repeatIndex}
    						zIndexBase={zIndexBase}
    						scaledPath={scaledPath}
    						isHovered={isHovered}
    					>
    						{child}
    					</MarqueeItem>
    				))}
    			</div>
    		</div>
    	);
    };
    
    export default MarqueeAlongPath;
    
  5. danielpetho commented on Sep 13, 2025

    @danielpetho
    Owner

    No I have the same implementation haha, so if anyone reading this have something better feel free to throw it here or open a PR:)

  6. dancrump1 commented on Nov 14, 2025

    @dancrump1
    Author

    I'm dropping this into a website and noticed the mobile performance was a bit touchy. Asked Cursor to optimize performance for mobile and here's the output. Verified on personal mobile device, recalculating the path on screen resize is significantly faster, though the fidelity of the path for the images to follow is reduced for mobile. Still important to pass in a height and width for each SVG used.

    No guarantees this is an improvement. Please review before implementing! My testing showed marked improvements, but may have missed critical bugs / issues.

    Performance Optimizations Summary
    Optimized the MarqueeAlongPath component for mobile. Changes:

    1. Adaptive path sampling
      Reduced samples from 10,000 to 500 on mobile
      Adaptive step size based on path complexity
      Minimum 50 samples to maintain quality
    2. Caching
      Added pathCache to avoid re-parsing paths
      Added scaledPathCache (limited to 10 entries) for scaled paths
      Reduces redundant calculations
    3. Debounced resize handler
      Debounced resize events (250ms on mobile, 100ms on desktop)
      Uses requestIdleCallback on mobile for non-blocking path calculations
      Added passive: true to resize listener
    4. Animation frame throttling
      Throttled to ~60fps on mobile (16ms intervals)
      Desktop remains unlimited for smoother animations
    5. GPU acceleration
      Added willChange: "transform" and transform: "translateZ(0)" to animated elements
      Forces GPU compositing for smoother animations
    6. SVG rendering optimization
      Uses shapeRendering: "optimizeSpeed" on mobile
      Desktop uses "geometricPrecision" for quality
    7. Simpler curves on mobile
      Uses d3.curveLinear on mobile instead of d3.curveBasis
      Reduces computation while maintaining visual quality
    8. Mobile detection
      Detects mobile devices for adaptive optimizations
      Applies optimizations only when needed
      These changes should improve mobile performance while maintaining desktop quality. The component now uses fewer resources on mobile devices.
      Note: The remaining linter error about missing @types/d3 is pre-existing. You can fix it by running:
      npm install --save-dev @types/d3

    Optimizations for orientation change performance

    1. ResizeObserver instead of window resize
      More efficient and fires immediately when the element size changes
      Better for orientation changes than window resize events
    2. Immediate viewBox update
      Updates the SVG viewBox immediately on resize
      Provides instant visual feedback while the path recalculates
    3. Reduced delays
      Changed from 250ms debounce + 1000ms requestIdleCallback to 50ms timeout
      Path calculation happens in requestAnimationFrame for smoother updates
    4. Dimension change detection
      Only recalculates if dimensions change by more than 1px
      Avoids unnecessary work during rapid resize events
    5. Asynchronous path calculation
      Path calculation is deferred and non-blocking
      UI remains responsive during orientation changes
    6. Batched updates
      Short timeout (50ms) batches rapid resize events
      Prevents multiple calculations during orientation transitions
      These changes should reduce the 4-second delay to under 100ms. The viewBox updates immediately, and the path recalculates asynchronously without blocking the UI.
      The component should now:
      Respond instantly to orientation changes (viewBox updates immediately)
      Calculate the new path in the background (50ms delay + animation frame)
      Avoid unnecessary recalculations (dimension change detection)

    Smoothing improvements

    1. Smoother curve interpolation
      Changed from d3.curveLinear to d3.curveCardinal.tension(0.5) on mobile
      Creates smooth curves between points instead of straight lines
      Reduces snapping between path segments
    2. Increased path sampling
      Increased minimum samples from 50 to 200 on mobile
      Better sampling ratio (1.5 instead of 2) for denser point distribution
      More points = smoother motion
    3. Path quality
      The curveCardinal with proper tension creates gradual curves
      Rotation changes should be more gradual since the path itself is smoother
      These changes should reduce snapping. The curveCardinal interpolation creates smooth Bézier curves between points, so the element follows a curved path rather than straight segments.
    import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
    
    import * as d3 from "d3";
    import {
    	motion,
    	useAnimationFrame,
    	useMotionValue,
    	useScroll,
    	useSpring,
    	useTransform,
    	useVelocity,
    } from "motion/react";
    
    import { path3 } from "../client-utils/background-paths";
    import { cn } from "../client-utils/cn";
    
    // Cache for parsed paths to avoid re-parsing
    const pathCache = new Map<string, Array<[number, number]>>();
    
    // Cache for scaled paths (limited size to prevent memory issues)
    const scaledPathCache = new Map<string, string>();
    
    // Detect mobile device for performance optimizations
    const isMobile = typeof window !== "undefined" && 
    	(window.innerWidth < 768 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
    
    type MarqueeAlongPathProps = {
    	children?: React.ReactNode;
    	path: string;
    	baseVelocity?: number;
    	repeat?: number;
    	zIndexBase?: number;
    	enableRollingZIndex?: boolean;
    	scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
    	scrollContainer?: RefObject<HTMLDivElement>;
    	className?: string;
    	direction?: "normal" | "reverse";
    	slowDownSpringConfig?: { damping: number; stiffness: number };
    	useScrollVelocity?: boolean;
    	scrollSpringConfig?: { damping: number; stiffness: number };
    	width?: number | string;
    	height?: number | string;
    	showPath?: boolean;
    };
    
    type MarqueeItemProps = {
    	baseOffset: ReturnType<typeof useMotionValue<number>>;
    	itemIndex: number;
    	totalItems: number;
    	repeatIndex: number;
    	zIndexBase: number;
    	scaledPath: string;
    	isHovered: React.MutableRefObject<boolean>;
    	children: React.ReactNode;
    };
    
    /**
     * Wraps a number between a min and max value
     * @param min The minimum value
     * @param max The maximum value
     * @param value The value to wrap
     * @returns The wrapped value between min and max
     */
    const wrap = (min: number, max: number, value: number): number => {
    	const range = max - min;
    	return ((((value - min) % range) + range) % range) + min;
    };
    
    /**
     * Parse SVG path string into coordinate points using D3
     * This extracts the actual coordinates from the path for scaling
     * Optimized for mobile with adaptive sampling
     */
    const parsePathToPoints = (
    	pathString: string,
    	maxSamples: number = 10000
    ): Array<[number, number]> => {
    	// Check cache first
    	const cacheKey = `${pathString}-${maxSamples}`;
    	if (pathCache.has(cacheKey)) {
    		return pathCache.get(cacheKey)!;
    	}
    
    	const points: Array<[number, number]> = [];
    
    	// Create a temporary SVG element to parse the path
    	const svg = d3.create("svg");
    	const path = svg.append("path").attr("d", pathString);
    
    	// Sample points along the path
    	const pathNode = path.node() as SVGPathElement;
    	if (pathNode) {
    		const totalLength = pathNode.getTotalLength();
    
    		// Adaptive sampling: reduce samples on mobile for better performance
    		// Use fewer samples but ensure minimum quality
    		const adaptiveMaxSamples = isMobile 
    			? Math.min(1000, maxSamples) // Much fewer samples on mobile
    			: Math.min(maxSamples, totalLength);
    		
    		// Use adaptive step size based on path complexity
    		// Increase minimum samples for smoother motion, especially on mobile
    		const numSamples = Math.max(
    			isMobile ? 200 : 50, // More samples on mobile for smoother curves
    			Math.min(adaptiveMaxSamples, Math.ceil(totalLength / (isMobile ? 1.5 : 1)))
    		);
    
    		for (let i = 0; i <= numSamples; i++) {
    			const point = pathNode.getPointAtLength(
    				(i / numSamples) * totalLength
    			);
    			points.push([point.x, point.y]);
    		}
    	}
    
    	// Cache the result
    	pathCache.set(cacheKey, points);
    	return points;
    };
    
    /**
     * Create a scaled path using D3's line generator
     * This is the approach recommended in the CSS-Tricks article
     * Memoized for performance
     */
    const createScaledPath = (
    	originalPath: string,
    	originalWidth: number,
    	originalHeight: number,
    	newWidth: number,
    	newHeight: number
    ): string => {
    	// Cache key for scaled paths
    	const cacheKey = `${originalPath}-${originalWidth}-${originalHeight}-${newWidth}-${newHeight}`;
    	
    	// Check cache first
    	if (scaledPathCache.has(cacheKey)) {
    		return scaledPathCache.get(cacheKey)!;
    	}
    
    	// Parse the original path into points (this is cached internally)
    	const points = parsePathToPoints(originalPath);
    
    	// Create scales for X and Y coordinates
    	const xScale = d3
    		.scaleLinear()
    		.domain([0, originalWidth])
    		.range([0, newWidth]);
    
    	const yScale = d3
    		.scaleLinear()
    		.domain([0, originalHeight])
    		.range([0, newHeight]);
    
    	// Scale the points
    	const scaledPoints = points.map(
    		([x, y]) => [xScale(x), yScale(y)] as [number, number]
    	);
    
    	// Create a smooth curve using D3's line generator
    	// Use curveCardinal on mobile (smoother than linear, faster than basis)
    	// curveCardinal provides smooth interpolation with good performance
    	const line = d3
    		.line<[number, number]>()
    		.x((d: [number, number]) => d[0])
    		.y((d: [number, number]) => d[1])
    		.curve(isMobile ? d3.curveCardinal.tension(0.5) : d3.curveBasis); // Smooth curve on mobile, basis on desktop
    
    	const result = line(scaledPoints) || "";
    	
    	// Cache with size limit (keep last 10 scaled paths)
    	if (scaledPathCache.size > 10) {
    		const firstKey = scaledPathCache.keys().next().value;
    		if (firstKey) {
    			scaledPathCache.delete(firstKey);
    		}
    	}
    	scaledPathCache.set(cacheKey, result);
    	
    	return result;
    };
    
    const MarqueeItem = ({
    	baseOffset,
    	itemIndex,
    	totalItems,
    	repeatIndex,
    	// eslint-disable-next-line @typescript-eslint/no-unused-vars
    	zIndexBase: _zIndexBase, // Available for future use with dynamic z-index
    	scaledPath,
    	isHovered,
    	children,
    }: MarqueeItemProps) => {
    	const itemOffset = useTransform(baseOffset, (v: number) => {
    		const position = (itemIndex * 100) / totalItems;
    		const wrappedValue = wrap(0, 100, v + position);
    		return `${wrappedValue}%`;
    	});
    
    	// Use with multiple images when svg causes them to overlap
    	// Note: zIndex transform is available but not currently used in style
    	// To enable dynamic z-index based on position, uncomment:
    	// const zIndex = useTransform(itemOffset, (v) => {
    	// 	const progress = parseFloat(v.replace("%", ""));
    	// 	return Math.floor(zIndexBase + progress);
    	// });
    	// Then use it in the style prop: zIndex: zIndex
    
    	return (
    		<motion.div
    			className="marquee-item absolute top-0 left-0"
    			style={{
    				offsetPath: `path('${scaledPath}')`,
    				offsetDistance: itemOffset,
    				// "auto" makes element follow path direction
    				// The smooth curveCardinal path should make rotation changes gradual
    				offsetRotate: "auto",
    				zIndex: 1,
    				// GPU acceleration hints for better performance
    				willChange: "transform",
    				transform: "translateZ(0)", // Force GPU acceleration
    			}}
    			aria-hidden={repeatIndex > 0}
    			onMouseEnter={() => (isHovered.current = true)}
    			onMouseLeave={() => (isHovered.current = false)}
    		>
    			{children}
    		</motion.div>
    	);
    };
    
    const MarqueeAlongPath = ({
    	children,
    	path,
    	baseVelocity = 5,
    	direction = "normal",
    	slowDownSpringConfig = { damping: 50, stiffness: 400 },
    	useScrollVelocity = false,
    	scrollSpringConfig = { damping: 50, stiffness: 400 },
    	scrollContainer,
    	repeat = 0,
    	zIndexBase = 1,
    	className,
    	width,
    	height,
    }: MarqueeAlongPathProps) => {
    	const baseOffset = useMotionValue(0);
    
    	// Scroll tracking
    	const { scrollY } = useScroll({
    		container: scrollContainer as RefObject<HTMLDivElement>,
    	});
    
    	const scrollVelocity = useVelocity(scrollY);
    	const smoothVelocity = useSpring(scrollVelocity, scrollSpringConfig);
    
    	const items = 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]);
    
    	// Hover and drag state tracking
    	const isHovered = useRef(false);
    
    	// 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 }
    	);
    	// Throttle animation frame updates on mobile for better performance
    	const lastUpdateTime = useRef(0);
    	const throttleMs = isMobile ? 16 : 0; // ~60fps on mobile, unlimited on desktop
    
    	useAnimationFrame((time, delta) => {
    		// Throttle updates on mobile
    		if (isMobile && time - lastUpdateTime.current < throttleMs) {
    			return;
    		}
    		lastUpdateTime.current = time;
    
    		if (isHovered.current) {
    			hoverFactorValue.set(0.3);
    		} else {
    			hoverFactorValue.set(1);
    		}
    
    		let moveBy =
    			((baseVelocity * delta) / 1000) *
    			directionFactor.current *
    			smoothHoverFactor.get();
    
    		if (velocityFactor.get() < 0) {
    			directionFactor.current = -1;
    		} else if (velocityFactor.get() > 0) {
    			directionFactor.current = 1;
    		}
    
    		moveBy += directionFactor.current * moveBy * velocityFactor.get();
    
    		baseOffset.set(baseOffset.get() + moveBy);
    	});
    
    	const wrapperRef = useRef<HTMLDivElement>(null);
    
    	const marqueeContainerRef = useRef<HTMLDivElement>(null);
    	// Original SVG dimensions
    	// TODO: Base svg h/w on the svg we pass in to the component
    	const originalWidth = typeof width === "string" ? parseFloat(width) || 175 : (width || 175);
    	const originalHeight = typeof height === "string" ? parseFloat(height) || 165 : (height || 165);
    
    	// Scale method #2 with D3
    	const [scaledPath, setScaledPath] = useState<string | null>(null);
    	const [currentViewBox, setCurrentViewBox] = useState(
    		`0 0 ${originalWidth} ${originalHeight}`
    	);
    
    	// ResizeObserver for immediate size updates (better than window resize)
    	const resizeObserverRef = useRef<ResizeObserver | null>(null);
    	const pathCalculationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
    	const lastDimensionsRef = useRef<{ width: number; height: number } | null>(null);
    	
    	const updatePath = useCallback(() => {
    		const wrapper = wrapperRef.current;
    		if (!wrapper) return;
    
    		const containerWidth = wrapper.clientWidth;
    		const containerHeight = wrapper.clientHeight;
    
    		// Check if dimensions actually changed significantly (avoid unnecessary recalculations)
    		const lastDims = lastDimensionsRef.current;
    		if (lastDims) {
    			const widthDiff = Math.abs(containerWidth - lastDims.width);
    			const heightDiff = Math.abs(containerHeight - lastDims.height);
    			// Only recalculate if change is significant (more than 1px)
    			if (widthDiff < 1 && heightDiff < 1) {
    				return;
    			}
    		}
    		lastDimensionsRef.current = { width: containerWidth, height: containerHeight };
    
    		// Immediately update viewBox for instant visual feedback
    		setCurrentViewBox(`0 0 ${containerWidth} ${containerHeight}`);
    
    		// Calculate path asynchronously to avoid blocking
    		const calculatePath = () => {
    			// Use D3 to create the scaled path
    			const newPath = createScaledPath(
    				path,
    				originalWidth,
    				originalHeight,
    				containerWidth,
    				containerHeight
    			);
    
    			setScaledPath(newPath);
    		};
    
    		// Clear any pending path calculations
    		if (pathCalculationTimeoutRef.current) {
    			clearTimeout(pathCalculationTimeoutRef.current);
    		}
    
    		// Use a very short delay to batch rapid resize events (like orientation changes)
    		// But make it immediate enough that it feels responsive
    		pathCalculationTimeoutRef.current = setTimeout(() => {
    			// Use requestAnimationFrame to ensure calculation happens after paint
    			requestAnimationFrame(() => {
    				calculatePath();
    			});
    		}, isMobile ? 50 : 16); // Much shorter delay - 50ms on mobile, 1 frame on desktop
    	}, [path, originalWidth, originalHeight]);
    
    	useEffect(() => {
    		const wrapper = wrapperRef.current;
    		if (!wrapper) {
    			// If wrapper isn't ready, try again on next frame
    			const timeoutId = setTimeout(() => {
    				const retryWrapper = wrapperRef.current;
    				if (retryWrapper) {
    					updatePath();
    				}
    			}, 0);
    			return () => clearTimeout(timeoutId);
    		}
    
    		// Initial path calculation
    		updatePath();
    
    		// Use ResizeObserver for more efficient and immediate resize detection
    		if (typeof ResizeObserver !== "undefined") {
    			resizeObserverRef.current = new ResizeObserver((entries) => {
    				// Only process if size actually changed
    				for (const entry of entries) {
    					if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
    						updatePath();
    					}
    				}
    			});
    
    			resizeObserverRef.current.observe(wrapper);
    		} else {
    			// Fallback to window resize for older browsers
    			const handleResize = () => {
    				updatePath();
    			};
    
    			window.addEventListener("resize", handleResize, { passive: true });
    			return () => {
    				window.removeEventListener("resize", handleResize);
    			};
    		}
    
    		return () => {
    			if (resizeObserverRef.current) {
    				resizeObserverRef.current.disconnect();
    			}
    			if (pathCalculationTimeoutRef.current) {
    				clearTimeout(pathCalculationTimeoutRef.current);
    			}
    		};
    	}, [updatePath]);
    
    	return (
    		<div 
    			className={cn("w-full h-full relative", className)} 
    			ref={wrapperRef}
    			style={{
    				// GPU acceleration hints
    				willChange: "transform",
    				transform: "translateZ(0)",
    			}}
    		>
    			{scaledPath ? (
    				<>
    					<svg
    						width="100%"
    						height="100%"
    						viewBox={currentViewBox}
    						fill="none"
    						xmlns="http://www.w3.org/2000/svg"
    						style={{
    							// Optimize SVG rendering
    							shapeRendering: isMobile ? "optimizeSpeed" : "geometricPrecision",
    						}}
    					>
    						<path
    							d={scaledPath}
    							fill={path === path3 ? "none" : "#EEE7E4"}
    							stroke="#EEE7E4"
    							strokeWidth={4}
    						/>
    					</svg>
    					<div
    						className="marquee-container absolute top-0 left-0 w-full h-full"
    						ref={marqueeContainerRef}
    						style={{
    							// GPU acceleration for container
    							willChange: "transform",
    							transform: "translateZ(0)",
    						}}
    					>
    						{items.map(({ child, repeatIndex, itemIndex, key }) => (
    							<MarqueeItem
    								key={key}
    								baseOffset={baseOffset}
    								itemIndex={itemIndex}
    								totalItems={items.length}
    								repeatIndex={repeatIndex}
    								zIndexBase={zIndexBase}
    								scaledPath={scaledPath}
    								isHovered={isHovered}
    							>
    								{child}
    							</MarqueeItem>
    						))}
    					</div>
    				</>
    			) : (
    				<svg
    					width="100%"
    					height="100%"
    					viewBox={`0 0 ${originalWidth} ${originalHeight}`}
    					fill="none"
    					xmlns="http://www.w3.org/2000/svg"
    					preserveAspectRatio="none"
    					className="opacity-50"
    					style={{
    						shapeRendering: isMobile ? "optimizeSpeed" : "geometricPrecision",
    					}}
    				>
    					<path
    						d={path}
    						fill={path === path3 ? "none" : "#EEE7E4"}
    						stroke="#EEE7E4"
    						strokeWidth={4}
    					/>
    				</svg>
    			)}
    		</div>
    	);
    };
    
    export default MarqueeAlongPath;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions