Skip to content
All Taste components

Animated app UI

Onboarding stepper

A setup progress card where finished steps tick off, the line fills to where you are, and the current step gently pulses.

4 steps · done / current / upcoming · progress fill · breathing ring · step form

Set up your workspace

Step 3 of 4
  1. Create account
  2. Add your business
  3. 3Invite your team
  4. 4Connect bank
Invite your teamThey’ll get an email to join Northwind. You can change roles later.
import { Check, Plus } from "lucide-react";
import { cn } from "@/lib/utils";

/* Add once to globals.css:
@keyframes breathe { from { transform: scale(1); opacity: .6; } to { transform: scale(1.35); opacity: .16; } }
*/

const steps = ["Create account", "Add your business", "Invite your team", "Connect bank"];
const current = 2; // zero-based
const invites = [
  { email: "asha@kestrel.studio", role: "Admin" },
  { email: "dev@kestrel.studio", role: "Member" },
];

export function OnboardingStepper() {
  return (
    <section aria-labelledby="setup-title" className="mx-auto grid max-w-[540px] gap-5 rounded-lg border border-border bg-bg p-5 font-sans">
      <div className="flex flex-wrap items-baseline justify-between gap-2">
        <h2 id="setup-title" className="text-[15px] font-medium text-fg">Set up your workspace</h2>
        <span className="text-[13px] text-fg-subtle">Step {current + 1} of {steps.length}</span>
      </div>

      <ol className="grid grid-cols-4">
        {steps.map((label, i) => {
          const done = i < current, now = i === current;
          return (
            <li key={label} aria-current={now ? "step" : undefined} className={cn("relative grid justify-items-center gap-2 px-0.5 text-center text-xs leading-tight", now ? "font-medium text-fg" : "text-fg-muted")}>
              {i < steps.length - 1 && (
                <span aria-hidden className="absolute left-[calc(50%+18px)] right-[calc(-50%+18px)] top-[13px] h-0.5 overflow-hidden rounded bg-surface-2">
                  {done && <span className="block h-full origin-left bg-primary animate-grow-x motion-reduce:animate-none" style={{ animationDelay: 250 + i * 320 + "ms" }} />}
                </span>
              )}
              <span
                className={cn(
                  "relative z-10 grid size-7 place-items-center rounded-full border-[1.5px] text-xs font-medium",
                  done && "border-primary bg-primary text-primary-fg",
                  now && "border-primary bg-bg text-primary",
                  !done && !now && "border-border-strong bg-bg text-fg-muted",
                )}
              >
                {done ? (
                  <Check className="size-3.5 animate-pop motion-reduce:animate-none" style={{ animationDelay: i * 320 + "ms" }} aria-hidden />
                ) : (
                  i + 1
                )}
                {now && <span aria-hidden className="absolute -inset-[5px] rounded-full border-2 border-primary animate-[breathe_1.6s_ease-in-out_infinite_alternate] motion-reduce:animate-none" />}
              </span>
              {label}
            </li>
          );
        })}
      </ol>

      <div className="grid gap-3 rounded-lg bg-surface p-4 animate-rise [animation-delay:700ms] motion-reduce:animate-none">
        <div>
          <p className="text-[15px] font-medium text-fg">Invite your team</p>
          <p className="text-[13.5px] text-fg-muted">They’ll get an email to join Northwind. You can change roles later.</p>
        </div>
        {invites.map((inv, i) => (
          <div key={inv.email} className="grid grid-cols-[1fr_auto] gap-2 animate-rise motion-reduce:animate-none" style={{ animationDelay: 850 + i * 70 + "ms" }}>
            <input defaultValue={inv.email} aria-label={"Teammate " + (i + 1) + " email"} className="h-9 min-w-0 rounded border border-border-strong bg-bg px-3 text-sm text-fg outline-none focus:border-primary focus:ring-2 focus:ring-ring" />
            <select defaultValue={inv.role} aria-label={"Teammate " + (i + 1) + " role"} className="h-9 rounded border border-border-strong bg-bg px-2 text-sm text-fg">
              <option>Admin</option>
              <option>Member</option>
            </select>
          </div>
        ))}
        <div className="flex flex-wrap items-center justify-between gap-2">
          <button type="button" className="inline-flex h-8 items-center gap-1.5 rounded px-2.5 text-[13px] font-medium text-fg hover:bg-surface-2"><Plus className="size-4" />Add another</button>
          <div className="flex gap-2">
            <button type="button" className="h-9 rounded px-3.5 text-sm font-medium text-fg hover:bg-surface-2">Skip</button>
            <button type="button" className="h-9 rounded bg-primary px-3.5 text-sm font-medium text-primary-fg transition hover:bg-primary-hover active:scale-[.97]">Send invites</button>
          </div>
        </div>
      </div>
    </section>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.