Retour au catalogue
Roadmap Horizontal
Roadmap horizontale scrollable avec trimestres (Q1-Q4). Ligne de progression et cartes par trimestre.
roadmapmedium Both Responsive a11y
minimalcorporatesaasuniversalhorizontal-scroll
Theme
"use client";
import { motion } from "framer-motion";
import { Check, Clock, CalendarDays } from "lucide-react";
interface RoadmapQuarterItem {
label: string;
done?: boolean;
}
interface RoadmapQuarter {
label: string;
status: "done" | "in-progress" | "upcoming";
items: RoadmapQuarterItem[];
}
interface RoadmapHorizontalProps {
title?: string;
description?: string;
quarters?: RoadmapQuarter[];
}
const EASE = [0.16, 1, 0.3, 1] as const;
const statusIcon = {
done: Check,
"in-progress": Clock,
upcoming: CalendarDays,
};
export default function RoadmapHorizontal({
title = "Notre roadmap",
description = "Nos objectifs trimestre par trimestre",
quarters = [],
}: RoadmapHorizontalProps) {
return (
<section
style={{
padding: "var(--section-padding-y) 0",
background: "var(--color-background)",
overflow: "hidden",
}}
>
<div
style={{
maxWidth: "var(--container-max-width)",
margin: "0 auto",
padding: "0 var(--container-padding-x)",
}}
>
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, ease: EASE }}
style={{ textAlign: "center", marginBottom: "3rem" }}
>
<h2
style={{
fontFamily: "var(--font-sans)",
fontSize: "clamp(1.75rem, 3.5vw, 2.75rem)",
fontWeight: 700,
color: "var(--color-foreground)",
marginBottom: "0.75rem",
}}
>
{title}
</h2>
<p style={{ fontSize: "1.0625rem", color: "var(--color-foreground-muted)", maxWidth: "520px", margin: "0 auto" }}>
{description}
</p>
</motion.div>
{/* Horizontal scroll container */}
<div
style={{
overflowX: "auto",
paddingBottom: "1rem",
WebkitOverflowScrolling: "touch",
}}
>
<div style={{ display: "flex", gap: "1.5rem", minWidth: "max-content", position: "relative" }}>
{/* Horizontal line */}
<div
aria-hidden
style={{
position: "absolute",
top: 18,
left: 0,
right: 0,
height: 2,
background: "var(--color-border)",
}}
/>
{quarters.map((q, qi) => {
const Icon = statusIcon[q.status];
const isActive = q.status === "in-progress";
return (
<motion.div
key={qi}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45, delay: qi * 0.08, ease: EASE }}
style={{ minWidth: 260, maxWidth: 300, position: "relative", paddingTop: 44 }}
>
{/* Dot on line */}
<div
style={{
position: "absolute",
top: 6,
left: "50%",
transform: "translateX(-50%)",
width: 24,
height: 24,
borderRadius: "var(--radius-full)",
background: q.status === "done" ? "var(--color-accent)" : "var(--color-background-alt)",
border: `2px solid ${q.status === "done" ? "var(--color-accent)" : isActive ? "var(--color-foreground)" : "var(--color-border)"}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
}}
>
<Icon style={{ width: 12, height: 12, color: q.status === "done" ? "var(--color-foreground)" : "var(--color-foreground-muted)" }} />
</div>
{/* Card */}
<div
style={{
padding: "1.25rem",
borderRadius: "var(--radius-lg)",
background: "var(--color-background-alt)",
border: isActive ? "2px solid var(--color-accent)" : "1px solid var(--color-border)",
}}
>
<h3
style={{
fontFamily: "var(--font-sans)",
fontSize: "1rem",
fontWeight: 600,
color: "var(--color-foreground)",
marginBottom: "0.75rem",
}}
>
{q.label}
</h3>
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{q.items.map((item, ii) => (
<li
key={ii}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
color: item.done ? "var(--color-foreground-muted)" : "var(--color-foreground)",
textDecoration: item.done ? "line-through" : "none",
}}
>
{item.done && <Check style={{ width: 14, height: 14, color: "var(--color-accent)", flexShrink: 0 }} />}
{!item.done && <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--color-border)", flexShrink: 0 }} />}
{item.label}
</li>
))}
</ul>
</div>
</motion.div>
);
})}
</div>
</div>
</div>
</section>
);
}