Retour au catalogue
Checklist Progress
Checklist d'onboarding interactive avec barre de progression, etapes cochables et recompense de completion.
onboardingmedium Both Responsive a11y
playfulminimalsaaseducationcentered
Theme
"use client";
import { useRef, useState } from "react";
import { motion, useInView, AnimatePresence } from "framer-motion";
import { Check, Circle, Gift, ChevronRight, Sparkles } from "lucide-react";
interface Step {
id: string;
label: string;
description: string;
completed: boolean;
}
interface OnboardingChecklistProgressProps {
title?: string;
subtitle?: string;
reward?: string;
steps?: Step[];
}
const EASE: [number, number, number, number] = [0.16, 1, 0.3, 1];
export default function OnboardingChecklistProgress({
title = "Configurez votre espace",
subtitle = "Completez ces etapes.",
reward = "Debloquez un bonus !",
steps: initialSteps = [],
}: OnboardingChecklistProgressProps) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "-80px" });
const [steps, setSteps] = useState<Step[]>(initialSteps);
const completedCount = steps.filter((s) => s.completed).length;
const totalSteps = steps.length;
const progressPercent = totalSteps > 0 ? (completedCount / totalSteps) * 100 : 0;
const allDone = completedCount === totalSteps && totalSteps > 0;
const toggleStep = (id: string) => {
setSteps((prev) =>
prev.map((s) => (s.id === id ? { ...s, completed: !s.completed } : s))
);
};
return (
<section
ref={ref}
style={{ padding: "5rem 1.5rem", background: "var(--color-background)" }}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={inView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, ease: EASE }}
style={{
maxWidth: 520,
margin: "0 auto",
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-background-card)",
overflow: "hidden",
}}
>
{/* Header */}
<div style={{ padding: "1.5rem 1.5rem 1.25rem", borderBottom: "1px solid var(--color-border)" }}>
<h2 style={{ fontSize: "1.25rem", fontWeight: 700, color: "var(--color-foreground)", marginBottom: "0.375rem" }}>
{title}
</h2>
<p style={{ fontSize: "0.875rem", color: "var(--color-foreground-muted)", lineHeight: 1.5 }}>
{subtitle}
</p>
{/* Progress bar */}
<div style={{ marginTop: "1.25rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--color-foreground)" }}>
{completedCount}/{totalSteps} terminees
</span>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--color-accent)" }}>
{Math.round(progressPercent)}%
</span>
</div>
<div style={{ height: 6, borderRadius: 3, background: "var(--color-background-alt)", overflow: "hidden" }}>
<motion.div
animate={{ width: `${progressPercent}%` }}
transition={{ duration: 0.6, ease: EASE }}
style={{
height: "100%",
borderRadius: 3,
background: allDone ? "var(--color-accent)" : "var(--color-accent)",
}}
/>
</div>
</div>
</div>
{/* Steps list */}
<div style={{ padding: "0.5rem 0" }}>
{steps.map((step, i) => (
<motion.button
key={step.id}
initial={{ opacity: 0, x: -10 }}
animate={inView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.4, delay: i * 0.08, ease: EASE }}
onClick={() => toggleStep(step.id)}
style={{
width: "100%",
display: "flex",
alignItems: "flex-start",
gap: 12,
padding: "1rem 1.5rem",
background: "transparent",
border: "none",
cursor: "pointer",
textAlign: "left",
borderBottom: i < steps.length - 1 ? "1px solid var(--color-border)" : "none",
transition: "background 0.15s",
}}
>
{/* Checkbox */}
<motion.div
animate={{
background: step.completed ? "var(--color-accent)" : "transparent",
borderColor: step.completed ? "var(--color-accent)" : "var(--color-border)",
}}
transition={{ duration: 0.2 }}
style={{
width: 22,
height: 22,
borderRadius: "var(--radius-sm)",
border: "2px solid",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
marginTop: 1,
}}
>
<AnimatePresence>
{step.completed && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ duration: 0.2, ease: EASE }}
>
<Check style={{ width: 14, height: 14, color: "var(--color-background)" }} />
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Content */}
<div style={{ flex: 1 }}>
<p
style={{
fontSize: "0.875rem",
fontWeight: 600,
color: step.completed ? "var(--color-foreground-muted)" : "var(--color-foreground)",
textDecoration: step.completed ? "line-through" : "none",
marginBottom: 2,
}}
>
{step.label}
</p>
<p style={{ fontSize: "0.8125rem", color: "var(--color-foreground-light)", lineHeight: 1.4 }}>
{step.description}
</p>
</div>
<ChevronRight style={{ width: 16, height: 16, color: "var(--color-foreground-light)", flexShrink: 0, marginTop: 2 }} />
</motion.button>
))}
</div>
{/* Reward banner */}
<AnimatePresence>
{allDone ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
transition={{ duration: 0.5, ease: EASE }}
style={{
padding: "1.25rem 1.5rem",
background: "var(--color-accent-subtle)",
borderTop: "1px solid var(--color-border)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<Sparkles style={{ width: 20, height: 20, color: "var(--color-accent)" }} />
<p style={{ fontSize: "0.875rem", fontWeight: 600, color: "var(--color-accent)" }}>
Felicitations ! Toutes les etapes sont completees.
</p>
</motion.div>
) : (
<div
style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<Gift style={{ width: 16, height: 16, color: "var(--color-accent)", flexShrink: 0 }} />
<p style={{ fontSize: "0.8125rem", color: "var(--color-foreground-muted)" }}>{reward}</p>
</div>
)}
</AnimatePresence>
</motion.div>
</section>
);
}