A line that draws itself through your steps when the section scrolls into view, with a dot of light travelling along it.
Discover
Design
Build
Customize
Usage
import { MotionPath } from "@/components/motionkit/motion-path";
<MotionPath
steps={[
{ title: "Discover", description: "We learn your goals and audience." },
{ title: "Design", description: "Wireframes, then polished screens." },
{ title: "Build", description: "Fast, accessible, yours to own." },
{ title: "Launch", description: "Ship, measure and improve." },
]}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Line draw
Scrolls into view
Node pop
Line reaches a step
Travelling dot
After the line draws
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/motion-path.tsx.
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
export type PathStep = {
title: string;
description?: string;
icon?: ReactNode;
};
type MotionPathProps = {
steps: PathStep[];
/** Colour of the line, nodes and travelling dot. */
color?: string;
/** Seconds for the line to draw from the first step to the last. */
duration?: number;
/** Send a dot of light along the finished line, on repeat. */
dot?: boolean;
className?: string;
};
type Point = { x: number; y: number };
/** A smooth curve through every point, leaving and arriving along whichever axis it mostly travels. */
function curveThrough(points: Point[]) {
return points.reduce((d, point, index) => {
if (index === 0) return `M${point.x} ${point.y}`;
const prev = points[index - 1];
const horizontal = Math.abs(point.x - prev.x) >= Math.abs(point.y - prev.y);
const midX = (prev.x + point.x) / 2;
const midY = (prev.y + point.y) / 2;
return horizontal
? `${d} C${midX} ${prev.y} ${midX} ${point.y} ${point.x} ${point.y}`
: `${d} C${prev.x} ${midY} ${point.x} ${midY} ${point.x} ${point.y}`;
}, "");
}
export function MotionPath({ steps, color = "#5FAEB4", duration = 1.8, dot = true, className = "" }: MotionPathProps) {
const container = useRef<HTMLOListElement>(null);
const nodes = useRef<(HTMLSpanElement | null)[]>([]);
const [path, setPath] = useState({ d: "", width: 0, height: 0 });
const inView = useInView(container, { once: true, amount: 0.4 });
const reduceMotion = useReducedMotion();
// the line is drawn from where the nodes actually sit, so it follows any layout or screen size
useEffect(() => {
const element = container.current;
if (!element) return;
const measure = () => {
const box = element.getBoundingClientRect();
const points = nodes.current.flatMap((node) => {
if (!node) return [];
const rect = node.getBoundingClientRect();
return [{ x: rect.left - box.left + rect.width / 2, y: rect.top - box.top + rect.height / 2 }];
});
setPath({ d: curveThrough(points), width: box.width, height: box.height });
};
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [steps.length]);
const drawn = inView || reduceMotion;
return (
<ol
ref={container}
className={`relative grid gap-10 md:grid-flow-col md:auto-cols-fr md:gap-6 ${className}`}
>
<svg aria-hidden className="pointer-events-none absolute inset-0 overflow-visible" width={path.width} height={path.height}>
{/* a faint guide, then the bright line drawing over it */}
<path d={path.d} fill="none" stroke="rgb(255 255 255 / 0.08)" strokeWidth={2} strokeDasharray="4 6" />
<motion.path
d={path.d}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: drawn ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : duration, ease: [0.65, 0, 0.35, 1] }}
style={{ filter: `drop-shadow(0 0 6px ${color})` }}
/>
{dot && !reduceMotion && inView && path.d && (
<motion.circle
r={4}
fill="#F6F1E8"
style={{ offsetPath: `path("${path.d}")`, filter: `drop-shadow(0 0 6px ${color})` }}
initial={{ offsetDistance: "0%", opacity: 0 }}
animate={{ offsetDistance: ["0%", "100%"], opacity: [0, 1, 1, 0] }}
transition={{ duration: duration * 1.4, delay: duration, repeat: Infinity, repeatDelay: 1.2, ease: "easeInOut" }}
/>
)}
</svg>
{steps.map((step, index) => (
// alternate steps sit lower on wide screens, so the line weaves between them
<li key={step.title} className={`relative flex gap-4 md:flex-col md:items-center md:text-center ${index % 2 ? "md:pt-16" : ""}`}>
<motion.span
ref={(node) => {
nodes.current[index] = node;
}}
className="relative flex size-11 shrink-0 items-center justify-center rounded-full border bg-[#0C1010] text-sm font-medium text-[#F6F1E8] [&>svg]:size-4"
initial={{ scale: reduceMotion ? 1 : 0.6, borderColor: "rgb(255 255 255 / 0.12)" }}
animate={drawn ? { scale: 1, borderColor: color } : undefined}
// each node pops as the line reaches it
transition={{ delay: reduceMotion ? 0 : (index / Math.max(steps.length - 1, 1)) * duration, type: "spring", bounce: 0.5, duration: 0.5 }}
>
{step.icon ?? index + 1}
</motion.span>
<motion.div
initial={{ opacity: reduceMotion ? 1 : 0, y: reduceMotion ? 0 : 8 }}
animate={drawn ? { opacity: 1, y: 0 } : undefined}
transition={{ delay: reduceMotion ? 0 : (index / Math.max(steps.length - 1, 1)) * duration + 0.1, duration: 0.4 }}
>
<p className="font-medium text-[#F6F1E8]">{step.title}</p>
{step.description && <p className="mt-1 text-sm leading-6 text-[#94A9A7]">{step.description}</p>}
</motion.div>
</li>
))}
</ol>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | { title, description?, icon? }[] | — | The steps, in order. |
| color | string | "#5FAEB4" | Colour of the line, nodes and dot. |
| duration | number | 1.8 | Seconds for the line to draw from first step to last. |
| dot | boolean | true | Send a dot of light along the finished line, on repeat. |
Orbit
Icons, labels and shapes circling a central logo on rings, each with its own speed and direction.
Marquee
A seamless infinite scroller for logos and testimonials that pauses on hover.
Accordion
FAQ panels that open with a smooth, springy height and a turning chevron.