Web InnoventixFreeCode

Onboarding Checklist

Original · free

setup checklist with progress

byWeb InnoventixReact + Tailwind
onboardchecklistonboarding
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/onboard-checklist.json
onboard-checklist.tsx
"use client";

import { useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Step = {
  id: string;
  title: string;
  detail: string;
  action: string;
  minutes: number;
  required: boolean;
  done: boolean;
};

const STEPS: Step[] = [
  {
    id: "verify",
    title: "Verify your email address",
    detail:
      "We sent a six-digit code to the address you signed up with. Confirming it unlocks invites and turns on delivery for alert emails.",
    action: "Resend the code",
    minutes: 1,
    required: true,
    done: true,
  },
  {
    id: "workspace",
    title: "Name your workspace",
    detail:
      "Pick something your teammates will recognise — usually the company or the product. The name shows up in shared links and in the sidebar switcher, and you can change it later in Settings.",
    action: "Open workspace settings",
    minutes: 2,
    required: true,
    done: true,
  },
  {
    id: "snippet",
    title: "Install the tracking snippet",
    detail:
      "Drop a single script tag before the closing </body> of your app shell, or install the package and call init() once at boot. Events start arriving within about thirty seconds of your first page view.",
    action: "Copy the snippet",
    minutes: 6,
    required: true,
    done: false,
  },
  {
    id: "source",
    title: "Connect a data source",
    detail:
      "Link Postgres, BigQuery, or a CSV upload so product events can be joined against revenue and account data. Read-only credentials are enough — we never issue writes.",
    action: "Choose a source",
    minutes: 8,
    required: true,
    done: false,
  },
  {
    id: "invite",
    title: "Invite a teammate",
    detail:
      "Workspaces get useful the moment a second person can see the numbers. Invite by email or share the join link with anyone on your verified domain.",
    action: "Send an invite",
    minutes: 2,
    required: false,
    done: false,
  },
  {
    id: "alert",
    title: "Set up an alert channel",
    detail:
      "Route threshold breaches to Slack, email, or a webhook so a drop in signups reaches you before the next standup rather than at the end of the month.",
    action: "Add a channel",
    minutes: 4,
    required: false,
    done: false,
  },
];

const RADIUS = 52;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

export default function OnboardChecklist() {
  const [steps, setSteps] = useState<Step[]>(STEPS);
  const [openId, setOpenId] = useState<string | null>(
    () => STEPS.find((s) => !s.done)?.id ?? null,
  );
  const baseId = useId();
  const gradientId = `${baseId}-ring-gradient`;
  const boxRefs = useRef<Record<string, HTMLButtonElement | null>>({});
  const reduce = useReducedMotion();

  const stats = useMemo(() => {
    const done = steps.filter((s) => s.done).length;
    const requiredLeft = steps.filter((s) => s.required && !s.done).length;
    const minutesLeft = steps
      .filter((s) => !s.done)
      .reduce((sum, s) => sum + s.minutes, 0);
    const pct = Math.round((done / steps.length) * 100);
    return { done, requiredLeft, minutesLeft, pct };
  }, [steps]);

  const allDone = stats.done === steps.length;

  const toggle = (id: string) => {
    setSteps((prev) =>
      prev.map((s) => (s.id === id ? { ...s, done: !s.done } : s)),
    );
  };

  const reset = () => {
    setSteps(STEPS);
    setOpenId(STEPS.find((s) => !s.done)?.id ?? null);
  };

  const onBoxKeyDown = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    const last = steps.length - 1;
    let target = -1;
    if (e.key === "ArrowDown") target = i === last ? 0 : i + 1;
    else if (e.key === "ArrowUp") target = i === 0 ? last : i - 1;
    else if (e.key === "Home") target = 0;
    else if (e.key === "End") target = last;
    if (target >= 0) {
      e.preventDefault();
      boxRefs.current[steps[target].id]?.focus();
    }
  };

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes obck-check-pop {
          0% { transform: scale(0.4); opacity: 0; }
          60% { transform: scale(1.12); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes obck-ring-glow {
          0%, 100% { opacity: 0.35; }
          50% { opacity: 0.7; }
        }
        .obck-check-mark { animation: obck-check-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1); }
        .obck-ring-track { transition: stroke-dashoffset 700ms cubic-bezier(0.22, 1, 0.36, 1); }
        .obck-ring-halo { animation: obck-ring-glow 4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .obck-check-mark,
          .obck-ring-halo { animation: none !important; }
          .obck-ring-track { transition: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <div className="grid gap-8 lg:grid-cols-[19rem_1fr] lg:gap-12">
          {/* Progress panel */}
          <div className="lg:sticky lg:top-8 lg:self-start">
            <div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
              <span className="inline-flex items-center rounded-full bg-indigo-100 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 uppercase dark:bg-indigo-500/15 dark:text-indigo-300">
                Getting started
              </span>
              <h2 className="mt-3 text-xl font-semibold tracking-tight text-slate-900 dark:text-white">
                Set up your workspace
              </h2>
              <p className="mt-1.5 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
                Six short steps. Your progress saves as you go, so you can stop
                and come back.
              </p>

              <div className="mt-6 flex items-center gap-5">
                <div className="relative h-32 w-32 shrink-0">
                  <svg
                    viewBox="0 0 120 120"
                    className="h-full w-full -rotate-90"
                    role="img"
                    aria-label={`Setup ${stats.pct} percent complete`}
                  >
                    <defs>
                      <linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
                        <stop offset="0%" stopColor="#6366f1" />
                        <stop offset="100%" stopColor="#8b5cf6" />
                      </linearGradient>
                    </defs>
                    <circle
                      cx="60"
                      cy="60"
                      r={RADIUS}
                      fill="none"
                      strokeWidth="10"
                      className="stroke-slate-200 dark:stroke-slate-800"
                    />
                    {stats.pct > 0 && (
                      <circle
                        cx="60"
                        cy="60"
                        r={RADIUS}
                        fill="none"
                        strokeWidth="10"
                        strokeLinecap="round"
                        stroke={`url(#${gradientId})`}
                        strokeDasharray={CIRCUMFERENCE}
                        strokeDashoffset={
                          CIRCUMFERENCE * (1 - stats.pct / 100)
                        }
                        className="obck-ring-track"
                      />
                    )}
                    {allDone && (
                      <circle
                        cx="60"
                        cy="60"
                        r={RADIUS}
                        fill="none"
                        strokeWidth="16"
                        stroke={`url(#${gradientId})`}
                        className="obck-ring-halo blur-[6px]"
                      />
                    )}
                  </svg>
                  <div className="absolute inset-0 flex flex-col items-center justify-center">
                    <span className="text-2xl font-semibold tabular-nums text-slate-900 dark:text-white">
                      {stats.pct}%
                    </span>
                    <span className="text-[11px] font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500">
                      done
                    </span>
                  </div>
                </div>

                <dl className="min-w-0 space-y-3">
                  <div>
                    <dt className="text-[11px] font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500">
                      Completed
                    </dt>
                    <dd className="text-sm font-medium tabular-nums text-slate-900 dark:text-white">
                      {stats.done} of {steps.length}
                    </dd>
                  </div>
                  <div>
                    <dt className="text-[11px] font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500">
                      Time left
                    </dt>
                    <dd className="text-sm font-medium tabular-nums text-slate-900 dark:text-white">
                      {stats.minutesLeft > 0 ? `~${stats.minutesLeft} min` : "None"}
                    </dd>
                  </div>
                  <div>
                    <dt className="text-[11px] font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500">
                      Required
                    </dt>
                    <dd className="text-sm font-medium text-slate-900 dark:text-white">
                      {stats.requiredLeft > 0
                        ? `${stats.requiredLeft} left`
                        : "All handled"}
                    </dd>
                  </div>
                </dl>
              </div>

              <p aria-live="polite" className="sr-only">
                {stats.done} of {steps.length} setup steps complete.
              </p>

              <AnimatePresence initial={false}>
                {allDone && (
                  <motion.div
                    key="done-banner"
                    initial={reduce ? false : { opacity: 0, y: 6 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0, y: reduce ? 0 : 6 }}
                    transition={{ duration: reduce ? 0 : 0.3, ease: "easeOut" }}
                    className="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-500/25 dark:bg-emerald-500/10"
                  >
                    <p className="text-sm font-medium text-emerald-800 dark:text-emerald-300">
                      Workspace is live
                    </p>
                    <p className="mt-1 text-xs leading-relaxed text-emerald-700 dark:text-emerald-400/90">
                      Events are flowing and alerts are routed. Head to your
                      dashboard whenever you are ready.
                    </p>
                  </motion.div>
                )}
              </AnimatePresence>

              <button
                type="button"
                onClick={reset}
                className="mt-6 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
              >
                Reset checklist
              </button>
            </div>
          </div>

          {/* Steps */}
          <ul className="flex list-none flex-col gap-3 p-0">
            {steps.map((step, i) => {
              const isOpen = openId === step.id;
              const boxId = `${baseId}-box-${step.id}`;
              const labelId = `${baseId}-label-${step.id}`;
              const panelId = `${baseId}-panel-${step.id}`;
              const discloseId = `${baseId}-disclose-${step.id}`;

              return (
                <li
                  key={step.id}
                  className={`overflow-hidden rounded-xl border shadow-sm transition-colors ${
                    step.done
                      ? "border-slate-200 bg-slate-50/80 dark:border-slate-800 dark:bg-slate-900/50"
                      : "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
                  }`}
                >
                  <div className="flex items-start gap-3 p-4 sm:gap-4 sm:p-5">
                    <button
                      type="button"
                      id={boxId}
                      role="checkbox"
                      aria-checked={step.done}
                      aria-labelledby={labelId}
                      ref={(el) => {
                        boxRefs.current[step.id] = el;
                      }}
                      onClick={() => toggle(step.id)}
                      onKeyDown={(e) => onBoxKeyDown(e, i)}
                      className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ${
                        step.done
                          ? "border-indigo-500 bg-indigo-500 text-white"
                          : "border-slate-300 bg-white hover:border-indigo-400 dark:border-slate-600 dark:bg-slate-800 dark:hover:border-indigo-400"
                      }`}
                    >
                      {step.done && (
                        <svg
                          className="obck-check-mark"
                          width="14"
                          height="14"
                          viewBox="0 0 24 24"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="3.5"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          aria-hidden="true"
                        >
                          <path d="m5 13 4 4L19 7" />
                        </svg>
                      )}
                    </button>

                    <div className="min-w-0 flex-1">
                      <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
                        <span
                          id={labelId}
                          className={`text-sm font-medium sm:text-base ${
                            step.done
                              ? "text-slate-400 line-through decoration-slate-300 dark:text-slate-500 dark:decoration-slate-600"
                              : "text-slate-900 dark:text-white"
                          }`}
                        >
                          {step.title}
                        </span>
                        {step.required ? (
                          <span className="rounded-full bg-rose-100 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-rose-700 uppercase dark:bg-rose-500/15 dark:text-rose-300">
                            Required
                          </span>
                        ) : (
                          <span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-slate-500 uppercase dark:bg-slate-800 dark:text-slate-400">
                            Optional
                          </span>
                        )}
                      </div>
                      <p className="mt-1 flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
                        <svg
                          width="12"
                          height="12"
                          viewBox="0 0 24 24"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="2"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          aria-hidden="true"
                        >
                          <circle cx="12" cy="12" r="9" />
                          <path d="M12 7v5l3 2" />
                        </svg>
                        <span className="tabular-nums">
                          About {step.minutes} min
                        </span>
                      </p>
                    </div>

                    <button
                      type="button"
                      id={discloseId}
                      aria-expanded={isOpen}
                      aria-controls={panelId}
                      onClick={() => setOpenId(isOpen ? null : step.id)}
                      className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:hover:bg-slate-800 dark:hover:text-slate-200"
                    >
                      <span className="sr-only">
                        {isOpen ? "Hide details for" : "Show details for"}{" "}
                        {step.title}
                      </span>
                      <motion.svg
                        width="16"
                        height="16"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="2.5"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        aria-hidden="true"
                        animate={{ rotate: isOpen ? 180 : 0 }}
                        transition={{ duration: reduce ? 0 : 0.25, ease: "easeOut" }}
                      >
                        <path d="m6 9 6 6 6-6" />
                      </motion.svg>
                    </button>
                  </div>

                  <AnimatePresence initial={false}>
                    {isOpen && (
                      <motion.div
                        key="panel"
                        initial={reduce ? false : { height: 0, opacity: 0 }}
                        animate={{ height: "auto", opacity: 1 }}
                        exit={{ height: 0, opacity: 0 }}
                        transition={{
                          duration: reduce ? 0 : 0.28,
                          ease: [0.22, 1, 0.36, 1],
                        }}
                        className="overflow-hidden"
                      >
                        <div
                          id={panelId}
                          role="region"
                          aria-labelledby={discloseId}
                          className="border-t border-slate-200 px-4 py-4 pl-13 sm:px-5 sm:pl-14 dark:border-slate-800"
                        >
                          <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                            {step.detail}
                          </p>
                          <button
                            type="button"
                            className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
                          >
                            {step.action}
                            <svg
                              width="14"
                              height="14"
                              viewBox="0 0 24 24"
                              fill="none"
                              stroke="currentColor"
                              strokeWidth="2.5"
                              strokeLinecap="round"
                              strokeLinejoin="round"
                              aria-hidden="true"
                            >
                              <path d="M5 12h14" />
                              <path d="m12 5 7 7-7 7" />
                            </svg>
                          </button>
                        </div>
                      </motion.div>
                    )}
                  </AnimatePresence>
                </li>
              );
            })}
          </ul>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →