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

Avis

Checklist Progress — React Onboarding Section — Incubator