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>
  );
}

Avis

Roadmap Horizontal — React Roadmap Section — Incubator