Web InnoventixFreeCode

Onboarding Progress

Original · free

onboarding progress header

byWeb InnoventixReact + Tailwind
onboardprogressonboarding
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-progress.json
onboard-progress.tsx
"use client";

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

type StepState = "done" | "current" | "upcoming";

type Step = {
  id: string;
  label: string;
  hint: string;
  minutes: number;
};

const STEPS: Step[] = [
  {
    id: "workspace",
    label: "Workspace",
    hint: "Name your workspace and pick a region for data residency.",
    minutes: 1,
  },
  {
    id: "import",
    label: "Import data",
    hint: "Pull in contacts from a CSV, HubSpot, or the REST API.",
    minutes: 4,
  },
  {
    id: "team",
    label: "Invite team",
    hint: "Add teammates by email and assign admin or viewer roles.",
    minutes: 2,
  },
  {
    id: "billing",
    label: "Billing",
    hint: "Add a card. You are not charged until the trial ends on day 14.",
    minutes: 2,
  },
  {
    id: "launch",
    label: "Launch",
    hint: "Publish your first workflow and start tracking events.",
    minutes: 1,
  },
];

function stateOf(index: number, current: number): StepState {
  if (index < current) return "done";
  if (index === current) return "current";
  return "upcoming";
}

const DOT_BASE =
  "grid h-8 w-8 shrink-0 place-items-center rounded-full border text-[11px] font-semibold tabular-nums transition-colors duration-300";

const DOT_STYLES: Record<StepState, string> = {
  done: "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500",
  current:
    "border-indigo-600 bg-white text-indigo-700 dark:border-indigo-400 dark:bg-slate-950 dark:text-indigo-300",
  upcoming:
    "border-slate-300 bg-white text-slate-400 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-500",
};

const LABEL_STYLES: Record<StepState, string> = {
  done: "text-slate-700 dark:text-slate-300",
  current: "text-slate-900 dark:text-slate-50",
  upcoming: "text-slate-400 dark:text-slate-500",
};

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 20 20"
      aria-hidden="true"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.4}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M4.5 10.5 8.2 14.2 15.5 6.4" />
    </svg>
  );
}

function ArrowIcon({ back = false }: { back?: boolean }) {
  return (
    <svg
      viewBox="0 0 20 20"
      aria-hidden="true"
      className={`h-4 w-4 ${back ? "rotate-180" : ""}`}
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M4 10h12M11 5l5 5-5 5" />
    </svg>
  );
}

function ClockIcon() {
  return (
    <svg
      viewBox="0 0 20 20"
      aria-hidden="true"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.6}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <circle cx="10" cy="10" r="7.2" />
      <path d="M10 6.2V10l2.6 1.6" />
    </svg>
  );
}

export default function OnboardProgress() {
  const [current, setCurrent] = useState<number>(2);
  const reduced = useReducedMotion();
  const headingId = useId();
  const listId = useId();
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const total = STEPS.length;
  const pct = Math.round((current / (total - 1)) * 100);

  const remaining = useMemo(
    () =>
      STEPS.slice(current).reduce((sum, step) => sum + step.minutes, 0),
    [current],
  );

  const focusStep = useCallback((index: number) => {
    const next = Math.max(0, Math.min(STEPS.length - 1, index));
    setCurrent(next);
    tabRefs.current[next]?.focus();
  }, []);

  const onKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      const keys = ["ArrowRight", "ArrowLeft", "Home", "End"];
      if (!keys.includes(event.key)) return;
      event.preventDefault();
      if (event.key === "ArrowRight") focusStep(current + 1);
      if (event.key === "ArrowLeft") focusStep(current - 1);
      if (event.key === "Home") focusStep(0);
      if (event.key === "End") focusStep(STEPS.length - 1);
    },
    [current, focusStep],
  );

  const active = STEPS[current];

  return (
    <section
      className="relative w-full bg-slate-50 px-5 py-20 sm:px-8 sm:py-24 dark:bg-slate-950"
      aria-labelledby={headingId}
    >
      <style>{`
        @keyframes obprog-sheen {
          0%   { transform: translateX(-120%); opacity: 0; }
          40%  { opacity: 0.85; }
          100% { transform: translateX(320%); opacity: 0; }
        }
        @keyframes obprog-pulse {
          0%, 100% { transform: scale(1); opacity: 0.55; }
          50%      { transform: scale(1.75); opacity: 0; }
        }
        .obprog-sheen::after {
          content: "";
          position: absolute;
          inset: 0;
          width: 28%;
          border-radius: 9999px;
          background: linear-gradient(90deg, transparent, rgba(255,255,255,0.75), transparent);
          animation: obprog-sheen 2.6s ease-in-out infinite;
        }
        .obprog-ping {
          animation: obprog-pulse 2s ease-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .obprog-sheen::after,
          .obprog-ping {
            animation: none !important;
          }
          .obprog-sheen::after { opacity: 0; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-3xl">
        <header className="mb-8 flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
              Getting started
            </p>
            <h2
              id={headingId}
              className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50"
            >
              Set up Meridian
            </h2>
          </div>
          <div className="flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <ClockIcon />
            <span className="tabular-nums">
              {remaining === 0
                ? "All done"
                : `About ${remaining} min left`}
            </span>
          </div>
        </header>

        <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-7 dark:border-slate-800 dark:bg-slate-900">
          <div className="mb-6 flex items-baseline justify-between gap-4">
            <p className="text-sm font-medium text-slate-600 dark:text-slate-400">
              Step{" "}
              <span className="tabular-nums text-slate-900 dark:text-slate-100">
                {current + 1}
              </span>{" "}
              of <span className="tabular-nums">{total}</span>
            </p>
            <p
              className="text-sm font-semibold tabular-nums text-indigo-600 dark:text-indigo-400"
              aria-hidden="true"
            >
              {pct}%
            </p>
          </div>

          <div
            className="relative mb-8 h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
            role="progressbar"
            aria-valuenow={pct}
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuetext={`${pct} percent complete, step ${current + 1} of ${total}: ${active.label}`}
            aria-labelledby={headingId}
          >
            <motion.div
              className="obprog-sheen relative h-full overflow-hidden rounded-full bg-gradient-to-r from-indigo-600 to-violet-500 dark:from-indigo-500 dark:to-violet-400"
              initial={false}
              animate={{ width: `${pct}%` }}
              transition={
                reduced
                  ? { duration: 0 }
                  : { type: "spring", stiffness: 190, damping: 26 }
              }
            />
          </div>

          <div
            role="tablist"
            aria-label="Onboarding steps"
            id={listId}
            onKeyDown={onKeyDown}
            className="grid grid-cols-1 gap-2 sm:grid-cols-5 sm:gap-0"
          >
            {STEPS.map((step, index) => {
              const state = stateOf(index, current);
              const selected = index === current;
              return (
                <div key={step.id} className="relative sm:text-center">
                  {index > 0 ? (
                    <span
                      aria-hidden="true"
                      className={`absolute left-[-50%] top-4 hidden h-px w-full sm:block ${
                        index <= current
                          ? "bg-indigo-500/70 dark:bg-indigo-400/60"
                          : "bg-slate-200 dark:bg-slate-800"
                      }`}
                    />
                  ) : null}

                  <button
                    ref={(node) => {
                      tabRefs.current[index] = node;
                    }}
                    type="button"
                    role="tab"
                    id={`${listId}-tab-${step.id}`}
                    aria-selected={selected}
                    aria-controls={`${listId}-panel`}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setCurrent(index)}
                    className="group relative flex w-full items-center gap-3 rounded-xl px-2 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:flex-col sm:gap-2 sm:text-center dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900"
                  >
                    <span className="relative">
                      {selected && !reduced ? (
                        <span
                          aria-hidden="true"
                          className="obprog-ping absolute inset-0 rounded-full bg-indigo-500/40"
                        />
                      ) : null}
                      <span
                        className={`${DOT_BASE} ${DOT_STYLES[state]} relative group-hover:border-indigo-500`}
                      >
                        {state === "done" ? <CheckIcon /> : index + 1}
                      </span>
                    </span>
                    <span
                      className={`text-xs font-medium transition-colors duration-200 ${LABEL_STYLES[state]}`}
                    >
                      {step.label}
                    </span>
                    <span className="sr-only">
                      {state === "done"
                        ? "Completed"
                        : state === "current"
                          ? "Current step"
                          : "Not started"}
                    </span>
                  </button>
                </div>
              );
            })}
          </div>

          <div
            id={`${listId}-panel`}
            role="tabpanel"
            aria-labelledby={`${listId}-tab-${active.id}`}
            tabIndex={0}
            className="mt-7 rounded-xl border border-slate-200 bg-slate-50 p-4 outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 dark:border-slate-800 dark:bg-slate-950/60 dark:focus-visible:ring-indigo-400"
          >
            <AnimatePresence mode="wait" initial={false}>
              <motion.div
                key={active.id}
                initial={reduced ? false : { opacity: 0, y: 6 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduced ? { opacity: 1 } : { opacity: 0, y: -6 }}
                transition={{ duration: reduced ? 0 : 0.22, ease: "easeOut" }}
              >
                <p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                  {active.label}
                </p>
                <p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                  {active.hint}
                </p>
              </motion.div>
            </AnimatePresence>
          </div>

          <div className="mt-6 flex items-center justify-between gap-3">
            <button
              type="button"
              onClick={() => setCurrent((v) => Math.max(0, v - 1))}
              disabled={current === 0}
              className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3.5 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-45 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900"
            >
              <ArrowIcon back />
              Back
            </button>

            <button
              type="button"
              onClick={() => setCurrent((v) => Math.min(total - 1, v + 1))}
              disabled={current === total - 1}
              className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-45 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900"
            >
              {current === total - 2 ? "Finish setup" : "Continue"}
              <ArrowIcon />
            </button>
          </div>
        </div>

        <p className="mt-4 text-center text-xs text-slate-500 dark:text-slate-500">
          Use the arrow keys to move between steps. Progress saves automatically.
        </p>
      </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 →