freeNew118 lines · framer-motion
Feature Steps Connector
A line that draws itself from step one to the last. A 'how it works' row of numbered steps joined by a connecting line that draws itself in on mount — horizontal on desktop, a vertical timeline on small screens.
- Use it for
- A short process explanation (3-5 steps) right after a hero or feature section, where a drawn connector reads as more intentional than a bare numbered list.
- Themes
- Light + dark
- License
- Commercial use
Live
How it runs
Four steps, no detours
- 1
Connect
Point it at the repo, the tracker and the docs.
- 2
Configure
Pick what runs on its own and what waits for a yes.
- 3
Run
Plans, tool calls and cost show up in one timeline.
- 4
Review
Approve the handful of decisions that actually need you.
tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
export interface ConnectorStep {
title: string;
description: string;
}
export interface FeatureStepsConnectorProps {
eyebrow?: string;
title?: string;
steps?: ConnectorStep[];
className?: string;
}
const DEFAULT_STEPS: ConnectorStep[] = [
{ title: "Connect", description: "Point it at the repo, the tracker and the docs." },
{ title: "Configure", description: "Pick what runs on its own and what waits for a yes." },
{ title: "Run", description: "Plans, tool calls and cost show up in one timeline." },
{ title: "Review", description: "Approve the handful of decisions that actually need you." },
];
function Dot({ i, active }: { i: number; active: boolean }) {
return (
<span
className={cn(
"relative z-10 grid size-10 shrink-0 place-items-center rounded-full border-2 font-mono text-[12px] font-bold transition-colors duration-300",
active ? "border-signal bg-signal text-signal-foreground" : "border-border bg-card text-muted-foreground",
)}
>
{i + 1}
</span>
);
}
export function FeatureStepsConnector({ eyebrow = "How it runs", title = "Four steps, no detours", steps = DEFAULT_STEPS, className }: FeatureStepsConnectorProps) {
const reduce = useReducedMotionSafe();
const n = steps.length;
return (
<section className={cn("w-full bg-background px-4 py-20 sm:px-6 lg:px-10", className)}>
<div className="mx-auto max-w-3xl text-center">
<p className="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{eyebrow}</p>
<h2 className="mt-3 font-display text-[clamp(2rem,5vw,3.5rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-foreground">{title}</h2>
</div>
{/* Desktop: horizontal connector */}
<div className="mx-auto mt-16 hidden max-w-5xl lg:block">
<div className="relative grid grid-cols-4 gap-6">
<div className="absolute left-0 right-0 top-5 h-[2px] -translate-y-1/2 bg-border" aria-hidden />
<motion.div
className="absolute left-0 top-5 h-[2px] origin-left -translate-y-1/2 bg-signal"
style={{ right: `${100 / n / 2}%` }}
initial={reduce ? false : { scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.9, ease: [0.16, 1, 0.3, 1] }}
aria-hidden
/>
{steps.map((s, i) => (
<motion.div
key={s.title}
initial={reduce ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: i * 0.15 }}
className="flex flex-col items-center text-center"
>
<Dot i={i} active />
<h3 className="mt-4 font-display text-base font-bold tracking-tight text-foreground">{s.title}</h3>
<p className="mt-1.5 max-w-[14rem] text-sm leading-relaxed text-muted-foreground">{s.description}</p>
</motion.div>
))}
</div>
</div>
{/* Mobile / tablet: vertical connector */}
<ol className="mx-auto mt-12 max-w-md space-y-0 lg:hidden">
{steps.map((s, i) => (
<motion.li
key={s.title}
initial={reduce ? false : { opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, delay: i * 0.1 }}
className="relative flex gap-4 pb-8 last:pb-0"
>
{i < n - 1 && (
<motion.span
className="absolute left-5 top-10 w-[2px] origin-top bg-signal"
style={{ height: "calc(100% - 1rem)" }}
initial={reduce ? false : { scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.6, delay: i * 0.1 + 0.15 }}
aria-hidden
/>
)}
<Dot i={i} active />
<div className="pt-1.5">
<h3 className="font-display text-base font-bold tracking-tight text-foreground">{s.title}</h3>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{s.description}</p>
</div>
</motion.li>
))}
</ol>
</section>
);
}
export default FeatureStepsConnector;
How it behaves
- →On desktop the connecting line between step dots scales in from the left as soon as the section mounts, so it reads cleanly even with no scrolling.
- →On small screens the same steps stack vertically with a connecting line that draws top-to-bottom per step, staggered slightly after each dot.
- →Step dots and text spring in with a small per-step delay for a sense of sequence.
- →All entrance and line-drawing animation is skipped under prefers-reduced-motion; steps appear in their final state immediately.
Accessibility
- ✓Steps are a real ordered list (`<ol>`) on mobile and a labelled grid on desktop, so the sequence is preserved for screen readers either way.
- ✓Connecting lines are `aria-hidden`; numbering and heading text carry the actual meaning.
- ✓Honours prefers-reduced-motion.