Sections

Onboarding Checklist

A setup-progress card with a progress ring, checkmarks that draw in, label strikes that sweep, and one restrained ring burst on completion.

Install

npx shadcn@latest add @paragon/onboarding-checklist

onboarding-checklist.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUpRight } from "lucide-react";
import { cn } from "@/lib/utils";

interface Task {
  id: string;
  label: string;
  description: string;
}

const TASKS: Task[] = [
  {
    id: "project",
    label: "Create your first project",
    description: "Spin up a project to hold environments and deploys.",
  },
  {
    id: "repo",
    label: "Connect a Git repository",
    description: "Every push to main builds and deploys automatically.",
  },
  {
    id: "team",
    label: "Invite your team",
    description: "Reviews and rollbacks work better with more than one of you.",
  },
  {
    id: "domain",
    label: "Configure a custom domain",
    description: "Point production at your own domain with managed TLS.",
  },
  {
    id: "previews",
    label: "Enable preview environments",
    description: "Each pull request gets an isolated, shareable deploy.",
  },
];

const RING_R = 17;
const RING_C = 2 * Math.PI * RING_R;

export interface OnboardingChecklistProps
  extends React.ComponentProps<"section"> {
  /** Task ids that start completed. */
  defaultCompleted?: string[];
}

/**
 * A setup-progress card: SVG progress ring, task rows whose checkmarks draw
 * in via a normalized pathLength dash transition while the label strike
 * sweeps left-to-right. Completing the final task fires one soft ring burst —
 * a single scale-and-fade halo, no confetti.
 */
export function OnboardingChecklist({
  defaultCompleted = ["project", "repo", "team"],
  className,
  ...props
}: OnboardingChecklistProps) {
  const reducedMotion = useReducedMotion();
  const [done, setDone] = React.useState<Set<string>>(
    () => new Set(defaultCompleted),
  );
  const [burst, setBurst] = React.useState(0);
  const prevCount = React.useRef(done.size);

  const count = done.size;
  const total = TASKS.length;
  const allDone = count === total;

  React.useEffect(() => {
    if (count === total && prevCount.current < total) {
      setBurst((b) => b + 1);
    }
    prevCount.current = count;
  }, [count, total]);

  const toggle = (id: string) => {
    setDone((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  return (
    <section
      aria-label="Workspace setup checklist"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border sm:p-6",
        className,
      )}
      {...props}
    >
      <header className="flex items-center gap-4">
        {/* Progress ring */}
        <div className="relative shrink-0">
          <svg
            viewBox="0 0 44 44"
            className="size-11 -rotate-90"
            role="img"
            aria-label={`${count} of ${total} setup tasks complete`}
          >
            <circle
              cx="22"
              cy="22"
              r={RING_R}
              fill="none"
              strokeWidth="3"
              className="stroke-muted"
            />
            <circle
              cx="22"
              cy="22"
              r={RING_R}
              fill="none"
              strokeWidth="3"
              strokeLinecap="round"
              strokeDasharray={RING_C}
              strokeDashoffset={RING_C * (1 - count / total)}
              className={cn(
                "stroke-emerald-600 dark:stroke-emerald-400",
                "motion-safe:transition-[stroke-dashoffset] motion-safe:duration-300 motion-safe:[transition-timing-function:var(--ease-out)]",
              )}
            />
          </svg>
          <span
            aria-hidden
            className="absolute inset-0 flex rotate-0 items-center justify-center text-[11px] font-semibold tabular-nums"
          >
            {count}/{total}
          </span>
          {/* Single soft ring burst on completion */}
          <AnimatePresence>
            {burst > 0 && allDone && !reducedMotion && (
              <motion.span
                key={burst}
                aria-hidden
                initial={{ opacity: 0.5, scale: 1 }}
                animate={{ opacity: 0, scale: 1.55 }}
                transition={{ duration: 0.5, ease: "easeOut" }}
                onAnimationComplete={() => setBurst(0)}
                className="pointer-events-none absolute inset-0 rounded-full border-2 border-emerald-500/60"
              />
            )}
          </AnimatePresence>
        </div>

        <div className="min-w-0">
          <h2 className="text-sm font-semibold">
            {allDone ? "Setup complete" : "Finish setting up Relay"}
          </h2>
          <p
            className="mt-0.5 text-[13px] text-muted-foreground tabular-nums"
            aria-live="polite"
          >
            {allDone
              ? "You’re ready to ship to production."
              : `${count} of ${total} complete`}
          </p>
        </div>
      </header>

      <ul className="mt-4 space-y-0.5">
        {TASKS.map((task) => {
          const checked = done.has(task.id);
          return (
            <li key={task.id}>
              <button
                type="button"
                role="checkbox"
                aria-checked={checked}
                onClick={() => toggle(task.id)}
                className={cn(
                  "group flex w-full items-start gap-3 rounded-lg px-2.5 py-2.5 text-left outline-none",
                  "transition-[background-color] duration-150 ease-out hover:bg-muted/60",
                  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
                )}
              >
                {/* Check circle */}
                <span
                  aria-hidden
                  className={cn(
                    "mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border",
                    "transition-[background-color,border-color] duration-200 ease-out",
                    checked
                      ? "border-emerald-600 bg-emerald-600 dark:border-emerald-500 dark:bg-emerald-500"
                      : "border-muted-foreground/40 group-hover:border-muted-foreground/70",
                  )}
                >
                  <svg viewBox="0 0 12 12" className="size-3" fill="none">
                    <path
                      d="M2.5 6.5L5 9L9.5 3.5"
                      stroke="white"
                      strokeWidth="1.75"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      pathLength={1}
                      strokeDasharray={1}
                      strokeDashoffset={checked ? 0 : 1}
                      className="motion-safe:transition-[stroke-dashoffset] motion-safe:duration-300 motion-safe:[transition-timing-function:var(--ease-out)]"
                      style={
                        reducedMotion && !checked ? { opacity: 0 } : undefined
                      }
                    />
                  </svg>
                </span>

                <span className="min-w-0">
                  <span
                    className={cn(
                      "relative inline-block text-sm font-medium",
                      "transition-[color] duration-200 ease-out",
                      checked && "text-muted-foreground",
                    )}
                  >
                    {task.label}
                    {/* Strike sweeps in from the left */}
                    <span
                      aria-hidden
                      className={cn(
                        "absolute top-1/2 left-0 h-px w-full origin-left bg-current",
                        "motion-safe:transition-transform motion-safe:duration-300 motion-safe:[transition-timing-function:var(--ease-out)]",
                        "transition-opacity duration-150",
                        checked
                          ? "scale-x-100 opacity-100"
                          : "scale-x-0 opacity-0",
                      )}
                    />
                  </span>
                  <span className="mt-0.5 block text-[13px] text-pretty text-muted-foreground/80">
                    {task.description}
                  </span>
                </span>
              </button>
            </li>
          );
        })}
      </ul>

      <footer className="mt-4 border-t pt-4">
        <a
          href="#docs"
          className="inline-flex items-center gap-1 text-[13px] font-medium text-muted-foreground transition-colors duration-150 hover:text-foreground"
        >
          Read the quickstart guide
          <ArrowUpRight aria-hidden className="size-3.5" />
        </a>
      </footer>
    </section>
  );
}