
You’re probably accustomed to the infinite scrolling marquee impact – that steady horizontal scroll of content material that many web sites use to showcase their work, testimonials, or companion manufacturers. It’s a stunning and easy impact, however on this tutorial, we’ll spice it up by making a marquee that strikes alongside a customized, funky SVG path. We’ll be utilizing React, Typescript and Movement (previously Framer Movement) for the animation, so let’s get began!
It’s also possible to discover this element in Fancy Parts, the rising library of ready-to-use React elements & microinteractions.
Preparation
First, we have to create an SVG path that can function the rule of thumb for our marquee. You possibly can create one utilizing any vector-drawing device, code it programmatically, and even draw it by hand utilizing SVG path instructions. For this tutorial, I used Figma to create a easy curved path that crosses over itself.
The magic that can make our components transfer alongside the trail is the `offset-path` and `offset-distance` properties.
The
offset-path
CSS property specifies a path for a component to observe and determines the factor’s positioning throughout the path’s mother or father container or the SVG coordinate system. The trail is a line, a curve, or a geometrical form alongside which the factor will get positioned or strikes.
The
offset-distance
CSS property specifies a place alongside an `offset-path` for a component to be positioned. (Documentation on MDN)
Let’s begin by making a easy React element that can render an SVG path and a div factor. The div will use the offset-path
CSS property to observe the trail. This path comes from our SVG’s d
attribute.
Additionally, let’s add a CSS animation that can animate the offset-distance
property of the div from 0%
to 100%
in an infinite loop:
See the Pen
cd-marquee-01-preparation by daniel petho (@nonzeroexitcode)
on CodePen.
💡 Discover how we set the SVG’s mother or father container in our CSS file to match the size specified within the SVG’s viewBox attribute. This creates a fixed-size container that can preserve the precise proportions of our path. Whereas this works for now, we’ll discover strategies for making the marquee responsive later within the tutorial.
And that’s the core of our impact! Whereas this fundamental model works, we will make it fancier with interactive options like velocity controls and scroll-based animations. To implement these, and have higher general management over the animation, let’s change from CSS animations to Movement.
Animating with Movement
First, let’s set up the Movement bundle:
npm i movement
We’ll use three key hooks from Movement to make this work:
1. useMotionValue
: Creates a worth that we will animate easily. We’ll use this to trace our place alongside the trail (0-100%). Consider it as a reactive worth that mechanically triggers visible adjustments with out inflicting re-renders (docs).
const baseOffset = useMotionValue(0);
2. useAnimationFrame
: Offers us frame-by-frame management over the animation. On every body, we’ll replace our place primarily based on a desired velocity, which we’ll name baseVelocity
. That is good for steady animations like our marquee (docs).
const baseVelocity = 20;
useAnimationFrame((_, delta) => {
const moveBy = (baseVelocity * delta) / 1000;
baseOffset.set(baseOffset.get() + moveBy);
});
3. useTransform
: Creates a brand new movement worth that transforms the output of one other movement worth. In our case, it ensures our place (baseOffset
) stays inside 0-100% by wrapping round. We will even use a helpful wrap
perform to do the wrapping (docs).
/**
* Wraps a quantity between a min and max worth
* @param min The minimal worth
* @param max The utmost worth
* @param worth The worth to wrap
* @returns The wrapped worth between min and max
*/
const wrap = (min: quantity, max: quantity, worth: quantity): quantity => {
const vary = max - min;
return ((((worth - min) % vary) + vary) % vary) + min;
};
//...
const offset = useTransform(baseOffset, (v) => `${wrap(0, 100, v)}%`);
Placing all of it collectively, that is how our `MarqueeAlongPath.tsx` element appears:
import {
movement,
useMotionValue,
useTransform,
useAnimationFrame,
} from "movement/react";
import "./index.css";
kind MarqueeAlongPathProps = {
path: string;
baseVelocity: quantity;
}
/**
* Wraps a quantity between a min and max worth
* @param min The minimal worth
* @param max The utmost worth
* @param worth The worth to wrap
* @returns The wrapped worth between min and max
*/
const wrap = (min: quantity, max: quantity, worth: quantity): quantity => {
const vary = max - min;
return ((((worth - min) % vary) + vary) % vary) + min;
};
const MarqueeAlongPath = ({
path,
baseVelocity,
}: MarqueeAlongPathProps) => {
const baseOffset = useMotionValue(0);
const offset = useTransform(baseOffset, (v) => `${wrap(0, 100, v)}%`);
useAnimationFrame((_, delta) => {
const moveBy = baseVelocity * delta / 1000;
baseOffset.set(baseOffset.get() + moveBy);
});
return (
);
};
const path =
"M0 186.219C138.5 186.219 305.5 194.719 305.5 49.7188C305.5 -113.652 -75 186.219 484.5 186.219H587.5";
const App = () => {
return ;
};
export default App;
Plus the modified index.css
file:
physique {
margin: 0;
width: 100vw;
peak: 100vh;
show: flex;
align-items: middle;
justify-content: middle;
overflow: hidden;
background-color: #e5e5e5;
}
.container{
place: relative;
width: 588px;
peak: 187px;
}
.marquee-item {
place: absolute;
prime: 0;
left: 0;
width: 32px;
peak: 32px;
border-radius: 15%;
background-color: black;
}
Now we’ve the identical end result as earlier than, solely utilizing Movement this time.
Including Components
Till now, we had been animating one factor alongside the trail. Let’s modify our element to help a number of ones. Whereas we might use an gadgets
prop to configure which components to indicate, I desire utilizing React kids as a result of it permits us to easily nest any components we wish to animate inside our element.
We’ll additionally introduce a repeat
prop that controls what number of occasions the kids must be repeated alongside the trail. That is significantly helpful when you have got a low variety of components however need them to seem a number of occasions to “fill” the trail.
import React, { useMemo } from "react";
//...
kind MarqueeAlongPathProps = {
kids: React.ReactNode;
path: string;
baseVelocity: quantity;
repeat?: quantity;
};
//...
const gadgets = useMemo(() => {
const childrenArray = React.Youngsters.toArray(kids);
return childrenArray.flatMap((youngster, childIndex) =>
Array.from({ size: repeat }, (_, repeatIndex) => {
const itemIndex = repeatIndex * childrenArray.size + childIndex;
const key = `${childIndex}-${repeatIndex}`;
return {
youngster,
childIndex,
repeatIndex,
itemIndex,
key,
};
})
);
}, [children, repeat]);
//...
Let’s additionally create an inside Merchandise element that can render the precise factor. We additionally have to offset the place of every factor primarily based on the index of the merchandise. For simplicity, let’s simply distribute the gadgets evenly alongside the trail:
kind MarqueeItemProps = {
baseOffset: any;
path: string;
itemIndex: quantity;
totalItems: quantity;
repeatIndex: quantity;
kids: React.ReactNode;
};
const MarqueeItem = ({ baseOffset, path, itemIndex, totalItems, repeatIndex, kids }: MarqueeItemProps) => {
const itemOffset = useTransform(baseOffset, (v: quantity) => {
// Distribute gadgets evenly alongside the trail
const place = (itemIndex * 100) / totalItems;
const wrappedValue = wrap(0, 100, v + place);
return `${wrappedValue}%`;
});
return (
0}
>
{kids}
);
};
Then, let’s simply use the gadgets
array to render the weather inside the primary MarqueeAlongPath
element:
{gadgets.map(({ youngster, repeatIndex, itemIndex, key }) => {
return (
{youngster}
);
}}
You need to use any factor as kids: photographs, movies, customized elements, and so forth. For this train, let’s simply create a easy Card
element, and use it as the kids of our MarqueeAlongPath
element:
const Card = ({ index }: { index: quantity }) => {
return (
Aspect {(index + 1).toString().padStart(2, '0')}
Marquee
);
};
const App = () => {
return (
{[...Array(5)].map((_, i) => (
))}
);
};
Lastly, let’s simply conceal the SVG path by setting the trail stroke="none"
inside the primary element for now. And that’s it! We now have a working marquee alongside a path with our personal components!
Z-Index Administration
Should you look carefully, it’s possible you’ll discover one thing off on the self-crossing a part of the trail – components seem to randomly overlap one another quite than sustaining a constant front-to-back order. I recolored the playing cards for illustration functions:

This occurs as a result of our marquee gadgets are rendered in DOM order, so components that seem later within the markup will all the time be rendered on prime of earlier components. When a component that was added later circles again round and crosses paths with earlier components, it incorrectly renders on prime, breaking the phantasm of correct depth ordering alongside the trail.
To repair this visible challenge, we have to dynamically modify the z-index
of every marquee merchandise primarily based on its place alongside the trail. We will do that by utilizing the itemOffset
movement worth we created earlier, which supplies us every merchandise’s progress (0-100%) alongside the trail.
We’ll add a zIndexBase
prop to our element to set the beginning z-index worth. Then, we’ll create a rework that converts the proportion progress into an applicable z-index worth – gadgets additional alongside the trail may have larger z-index values. For instance, if an merchandise is 75% alongside the trail and we’ve a zIndexBase
of 0, it could get a z-index of 75.
const MarqueeItem = ({
path,
baseOffset,
itemIndex,
totalItems,
repeatIndex,
zIndexBase,
kids,
}: MarqueeItemProps) => {
const itemOffset = useTransform(baseOffset, (v: quantity) => {
// Distribute gadgets evenly alongside the trail
const place = (itemIndex * 100) / totalItems;
const wrappedValue = wrap(0, 100, v + place);
return `${wrappedValue}%`;
});
const zIndex = useTransform(itemOffset, (v) => {
const progress = parseFloat(v.substitute("%", ""));
return Math.flooring(zIndexBase + progress);
});
return (
0}
>
{kids}
);
};
And now it must be fastened:
Altering Velocity on Hover
We will make this marquee extra interactive by introducing a number of options, which can permit us to alter the velocity interactively. Let’s begin with a easy one, the place we’ll decelerate the marquee on hover.
First, let’s monitor if any of the weather are being hovered over. I desire to make use of a ref as a substitute of state to keep away from pointless re-renders.
// In the primary element
const isHovered = useRef(false);
//...
// Within the marquee merchandise
0}
onMouseEnter={() => (isHovered.present = true)}
onMouseLeave={() => (isHovered.present = false)}
>
{youngster}
//...
Whereas we might merely change the baseVelocity
prop when hovering, this is able to create an abrupt transition. As a substitute, we will create a {smooth} animation between the conventional and hover states utilizing Movement’s useSpring
hook (docs).
The useSpring
hook permits us to animate a movement worth with spring physics. We’ll create a movement worth to trace the hover state and use a spring to easily animate it between 1 (regular velocity) and 0.3 (slowed down) when hovering:
const hoverFactorValue = useMotionValue(1)
const smoothHoverFactor = useSpring(hoverFactorValue, {
stiffness: 100,
damping: 20,
})
Now, we will use this worth to multiply our moveBy
worth, which can make the marquee transfer slower when hovered over.
useAnimationFrame((_, delta) => {
if (isHovered.present) {
hoverFactorValue.set(0.3);
} else {
hoverFactorValue.set(1);
}
const moveBy = ((baseVelocity * delta) / 1000) * smoothHoverFactor.get();
baseOffset.set(baseOffset.get() + moveBy);
});
The distinction is refined, however these small particulars are value taking good care of too.
Scroll-based Velocity
We will additionally affect the marquee’s velocity primarily based on the scroll velocity and path. We will use useScroll
to trace absolutely the scroll place in pixels (docs). You may wish to add a container the place you wish to monitor the scroll. In any other case it can simply use the web page scroll, which is ok for us now. Then, we will get the rate of our scroll place with the useVelocity
hook (docs), by passing the scrollY
worth as a parameter.
Now, to keep away from the abrupt adjustments in scroll velocity, we’ll use a spring animation once more with useSpring
to {smooth} it out. Should you use Lenis or another smooth-scroll library, you may skip this step.
// Scroll monitoring
const { scrollY } = useScroll()
const scrollVelocity = useVelocity(scrollY)
// {smooth} out the scroll velocity
const smoothScrollVelocity = useSpring(scrollVelocity, scrollSpringConfig)
The scroll velocity’s worth could be fairly excessive, so let’s map it to a decrease vary with a useTransform
hook.
const { scrollY } = useScroll()
const scrollVelocity = useVelocity(scrollY);
const smoothScrollVelocity = useSpring(scrollVelocity, springConfig);
// map to an affordable vary
const scrollVelocityFactor = useTransform(
smoothScrollVelocity,
[0, 1000],
[0, 5],
{ clamp: false }
)
Let’s additionally reverse the motion path for the marquee once we change the scroll path too. For that, we’ll monitor the path in a ref:
const directionFactor = useRef(1)
Then, we simply want to make use of the scrollVelocityFactor
worth to multiply our moveBy
worth:
useAnimationFrame((_, delta) => {
if (isHovered.present) {
hoverFactorValue.set(0.3);
} else {
hoverFactorValue.set(1);
}
// we have to multiply the bottom velocity with the path issue right here too!
let moveBy = ((baseVelocity * delta) / 1000) * directionFactor.present * smoothHoverFactor.get();
if (scrollVelocityFactor.get() < 0) {
directionFactor.present = -1
} else if (scrollVelocityFactor.get() > 0) {
directionFactor.present = 1
}
// apply the scroll velocity issue
moveBy += directionFactor.present * moveBy * scrollVelocityFactor.get();
baseOffset.set(baseOffset.get() + moveBy);
});
That’s it, now our marquee is influenced by scroll velocity and path!
Mapping Different CSS Properties
One other attention-grabbing characteristic we will add right here is to map different CSS properties to the progress on the trail. For instance, we will map the opacity
of the marquee gadgets to the progress alongside the trail, in order that they are going to fade out and in on the edges of our path. We will use the identical useTransform
hook to remodel the progress worth with the next system:
f(x) = (1 − |2x − 1|¹⁰)²
Don’t fear if this perform appears complicated. It’s only a good curve that goes from 0 to 1 tremendous fast, holds at 1 for some time, then goes again to 0. We will visualize it with a device like GeoGebra:

Right here is how the system interprets to code:
//...
const opacity = useTransform(itemOffset, (v) => {
const progress = parseFloat(v.substitute("%", "")) / 100;
// that is only a good curve which works from 0 to 1 tremendous fast, holds at 1 for some time, then goes again to 0
// makes the gadgets fade out and in on the edges of the trail
const x = 2 * progress - 1;
return Math.pow(1 - Math.pow(Math.abs(x), 10), 2);
});
return (
0}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{kids}
);
And the end result:
Responsiveness
First, let’s simply change again the SVG path’s stroke
to black
to see what’s taking place.
Now, making the SVG factor itself responsive is kind of simple, we will simply set the SVG’s container to have a width of 100% and peak of 100%, and do the identical for the SVG factor itself as effectively. We will preserve the viewBox
attribute unchanged, and the path
factor will scale with the wrapper:
And the `index.css` file:
.container {
place: relative;
width: 100%;
peak: 100%;
margin: 0 auto;
}
Now that is nice for scaling the SVG path itself, however there’s a catch – the marquee gadgets’ offset-path
property nonetheless references the unique 588×187 coordinate area! Which means whereas our SVG and its container visually scales, the trail within the offset-path
CSS property will nonetheless be within the unique coordinate area.

We've got a number of choices to resolve this:
1. A number of Path Variations
Create a number of SVG path variations for various viewport sizes. Whereas this offers management over the trail at every breakpoint, it requires sustaining a number of path definitions and could be fairly tedious.
2. Scale the Container
Scale the marquee gadgets’ container proportionally utilizing CSS scale
transforms. This maintains the unique coordinate area but additionally scales every part contained in the container.
const wrapperRef = useRef(null);
const marqueeContainerRef = useRef(null);
useEffect(() => {
const updateScale = () => {
const wrapper = wrapperRef.present;
const marqueeContainer = marqueeContainerRef.present;
if (!wrapper || !marqueeContainer) return;
const scale = wrapper.clientWidth / 588;
marqueeContainer.fashion.rework = `scale(${scale})`;
marqueeContainer.fashion.transformOrigin = 'prime left';
};
updateScale();
window.addEventListener("resize", updateScale);
return () => window.removeEventListener("resize", updateScale);
}, []);
return (
);
This works effectively, however comes with some vital gotchas. Since we’re scaling every part contained in the marquee-container
div, any dimensions you set in your marquee gadgets (like width: 80px
) gained’t replicate their true remaining measurement – they’ll be scaled together with every part else. This scaling can even trigger undesirable distortions, primarily with textual content components, which may look ugly in some circumstances.
3. Scale the Path
A extra subtle (and someway cleaner) method is to scale solely the SVG path itself (what’s contained in the d
attribute), leaving different components at their unique measurement. Then, you may outline how your inside components ought to behave on totally different viewports.
Whereas implementing this ourselves can be complicated, there may be an superior article by Jhey Tompkins on the how-to. I actually suggest you undergo it! It’s actually value it, and he does a significantly better job of explaining it than I do. Because of this I’m not going to enter too many particulars, i’ll simply provide you with a fast rundown of how we'll port the identical logic to our marquee.
Let’s set up the D3 library, as we'll use it to parse and rework our SVG path.
npm i d3 sorts/d3
First, we have to course of our SVG path to get the precise factors for later. For this, we’ll create a brief SVG factor with the assistance of d3, and use the getTotalLength()
methodology to get the overall size of the trail. Then, we’ll use the getPointAtLength()
methodology to pattern factors alongside the trail (docs). If the trail is just too lengthy, we’ll pattern solely a subset of factors, outlined within the maxSamples
parameter. Regulate it if the scaling takes too lengthy.
/**
* Parse SVG path string into coordinate factors utilizing D3
* This extracts the precise coordinates from the trail for scaling
*/
const parsePathToPoints = (pathString: string, maxSamples: quantity = 100): Array<[number, number]> => {
const factors: Array<[number, number]> = [];
// Create a brief SVG factor to parse the trail
const svg = d3.create("svg");
const path = svg.append("path").attr("d", pathString);
// Pattern factors alongside the trail
const pathNode = path.node() as SVGPathElement;
if (pathNode) {
const totalLength = pathNode.getTotalLength();
// If the trail is just too lengthy, pattern solely a subset of factors.
const numSamples = Math.min(maxSamples, totalLength);
for (let i = 0; i <= numSamples; i++) {
const level = pathNode.getPointAtLength((i / numSamples) * totalLength);
factors.push([point.x, point.y]);
}
}
return factors;
Then, we create a perform that takes the unique path, the unique width and peak of the trail container, and our new container dimensions which we've to scale to. Within the perform, we use d3’s scaleLinear()
perform to create a scale that can map the unique path factors to the brand new container dimensions. Then, we simply use the line()
perform to create a brand new path from the scaled factors. We additionally use d3.curveBasis()
to {smooth} out the brand new path. There are a bunch of different choices for that, take a look on the docs.
/**
* Create a scaled path utilizing D3's line generator
* That is the method advisable within the CSS-Methods article
*/
const createScaledPath = (
originalPath: string,
originalWidth: quantity,
originalHeight: quantity,
newWidth: quantity,
newHeight: quantity
): string => {
// Parse the unique path into factors
const factors = parsePathToPoints(originalPath);
// Create scales for X and Y coordinates
const xScale = d3.scaleLinear()
.area([0, originalWidth])
.vary([0, newWidth]);
const yScale = d3.scaleLinear()
.area([0, originalHeight])
.vary([0, newHeight]);
// Scale the factors
const scaledPoints = factors.map(([x, y]) => [xScale(x), yScale(y)] as [number, number]);
// Create a {smooth} curve utilizing D3's line generator
const line = d3.line()
.x(d => d[0])
.y(d => d[1])
.curve(d3.curveBasis); // Use foundation curve for {smooth} interpolation
return line(scaledPoints) || "";
};
Lastly, contained in the element, register an occasion listener for the resize
occasion, and replace the trail each time the container dimensions change. We are going to do it in a useEffect
hook:
//...
useEffect(() => {
const updatePath = () => {
const wrapper = wrapperRef.present;
if (!wrapper) return;
const containerWidth = wrapper.clientWidth;
const containerHeight = wrapper.clientHeight;
// Authentic SVG dimensions
const originalWidth = 588;
const originalHeight = 187;
// 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 (
{gadgets.map(({ youngster, repeatIndex, itemIndex, key }) => (
{youngster}
))}
);
//...
In each circumstances, you could possibly implement a debounced resize occasion listener since constantly resizing the trail generally is a bit laggy. Additionally, for a cleaner implementation you can even use ResizeObserver
, however for the sake of simplicity, I’ll depart the implementation of those as much as you.
Last Demo
And there you have got it! For the ultimate demo, I’ve changed the playing cards with some stunning art work items. You could find attribution for every art work within the remaining supply code beneath:
Last ideas
Efficiency
- The animation complexity will increase with each the variety of components and path complexity. Extra components or complicated paths will affect efficiency, particularly on cell units. Monitor your body charges and efficiency metrics fastidiously.
- Maintain your SVG paths easy with {smooth} curves and minimal management factors. Keep away from sharp angles and sudden path adjustments. In my expertise, overly complicated paths with too many management factors can lead to uneven or inflexible animations.
Assets
Some useful assets I used whereas researching and constructing this impact, in the event you’d prefer to dig deeper: