Web InnoventixFreeCode

Onboarding Welcome

Original · free

welcome screen with steps

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

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

type StepId = "workspace" | "sources" | "cadence" | "review";

type StepDef = {
  id: StepId;
  label: string;
  title: string;
  blurb: string;
  minutes: number;
};

type SourceId = "postgres" | "stripe" | "segment" | "csv";

type SourceDef = {
  id: SourceId;
  name: string;
  detail: string;
};

type Cadence = "realtime" | "hourly" | "daily";

type CadenceDef = {
  id: Cadence;
  name: string;
  detail: string;
  cost: string;
};

const STEPS: StepDef[] = [
  {
    id: "workspace",
    label: "Workspace",
    title: "Name your workspace",
    blurb: "This is what your teammates see in the sidebar and in every alert we send.",
    minutes: 1,
  },
  {
    id: "sources",
    label: "Data sources",
    title: "Connect where your numbers live",
    blurb: "Pick at least one. You can add the rest later from Settings → Integrations.",
    minutes: 3,
  },
  {
    id: "cadence",
    label: "Sync cadence",
    title: "Decide how fresh the data should be",
    blurb: "Faster syncs cost more rows. Most teams start hourly and tighten it later.",
    minutes: 1,
  },
  {
    id: "review",
    label: "Review",
    title: "One last look",
    blurb: "Confirm the setup and we'll run your first sync in the background.",
    minutes: 1,
  },
];

const SOURCES: SourceDef[] = [
  { id: "postgres", name: "Postgres", detail: "Read replica, no writes" },
  { id: "stripe", name: "Stripe", detail: "Charges, subs, refunds" },
  { id: "segment", name: "Segment", detail: "Track & identify events" },
  { id: "csv", name: "CSV upload", detail: "Up to 200 MB per file" },
];

const CADENCES: CadenceDef[] = [
  { id: "realtime", name: "Real time", detail: "Streamed within ~10 seconds", cost: "High row usage" },
  { id: "hourly", name: "Every hour", detail: "Batched at the top of the hour", cost: "Balanced" },
  { id: "daily", name: "Once a day", detail: "Runs at 04:00 in your timezone", cost: "Lowest cost" },
];

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path
        d="M4.5 10.5l3.5 3.5 7.5-8"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function ArrowIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path
        d="M4 10h12m0 0l-4.5-4.5M16 10l-4.5 4.5"
        stroke="currentColor"
        strokeWidth="1.75"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function SparkIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path
        d="M10 2.5l1.6 4.4 4.4 1.6-4.4 1.6L10 14.5 8.4 10.1 4 8.5l4.4-1.6L10 2.5z"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinejoin="round"
      />
      <path d="M15.5 13.5l.7 1.8 1.8.7-1.8.7-.7 1.8-.7-1.8-1.8-.7 1.8-.7.7-1.8z" fill="currentColor" />
    </svg>
  );
}

export default function OnboardWelcome() {
  const reduced = useReducedMotion();
  const uid = useId();
  const [index, setIndex] = useState<number>(0);
  const [workspace, setWorkspace] = useState<string>("");
  const [touchedWorkspace, setTouchedWorkspace] = useState<boolean>(false);
  const [sources, setSources] = useState<SourceId[]>(["postgres"]);
  const [cadence, setCadence] = useState<Cadence>("hourly");
  const [done, setDone] = useState<boolean>(false);
  const [status, setStatus] = useState<string>("");

  const panelRef = useRef<HTMLDivElement | null>(null);
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const firstRender = useRef<boolean>(true);

  const step = STEPS[index];
  const trimmed = workspace.trim();
  const workspaceValid = trimmed.length >= 3;

  const stepValid = useMemo<boolean>(() => {
    if (step.id === "workspace") return workspaceValid;
    if (step.id === "sources") return sources.length > 0;
    return true;
  }, [step.id, workspaceValid, sources.length]);

  const remaining = useMemo<number>(
    () => STEPS.slice(index).reduce((sum, s) => sum + s.minutes, 0),
    [index],
  );

  useEffect(() => {
    if (firstRender.current) {
      firstRender.current = false;
      return;
    }
    panelRef.current?.focus();
    setStatus(`Step ${index + 1} of ${STEPS.length}: ${STEPS[index].title}`);
  }, [index]);

  const goNext = useCallback(() => {
    if (!stepValid) {
      if (step.id === "workspace") setTouchedWorkspace(true);
      setStatus(
        step.id === "workspace"
          ? "Workspace name needs at least 3 characters."
          : "Select at least one data source.",
      );
      return;
    }
    if (index === STEPS.length - 1) {
      setDone(true);
      setStatus("Setup complete. First sync queued.");
      return;
    }
    setIndex((i) => Math.min(i + 1, STEPS.length - 1));
  }, [stepValid, step.id, index]);

  const goBack = useCallback(() => setIndex((i) => Math.max(i - 1, 0)), []);

  const onTabKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLButtonElement>, i: number) => {
      const last = STEPS.length - 1;
      let target = -1;
      if (event.key === "ArrowRight" || event.key === "ArrowDown") target = i === last ? 0 : i + 1;
      else if (event.key === "ArrowLeft" || event.key === "ArrowUp") target = i === 0 ? last : i - 1;
      else if (event.key === "Home") target = 0;
      else if (event.key === "End") target = last;
      if (target < 0) return;
      event.preventDefault();
      tabRefs.current[target]?.focus();
    },
    [],
  );

  const toggleSource = useCallback((id: SourceId) => {
    setSources((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
  }, []);

  const restart = useCallback(() => {
    setDone(false);
    setIndex(0);
    setTouchedWorkspace(false);
    setStatus("Setup reopened at step 1.");
  }, []);

  const fade = reduced
    ? { initial: false as const, animate: { opacity: 1 }, exit: { opacity: 1 }, transition: { duration: 0 } }
    : {
        initial: { opacity: 0, y: 10 },
        animate: { opacity: 1, y: 0 },
        exit: { opacity: 0, y: -6 },
        transition: { duration: 0.24, ease: [0.22, 0.61, 0.36, 1] as const },
      };

  const progress = done ? 100 : Math.round((index / STEPS.length) * 100);
  const cadenceLabel = CADENCES.find((c) => c.id === cadence)?.name ?? "";

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes ow-drift {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(0, -18px, 0) scale(1.06); }
        }
        @keyframes ow-sheen {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(220%); }
        }
        @keyframes ow-pulse-ring {
          0% { transform: scale(0.85); opacity: 0.55; }
          70% { transform: scale(1.5); opacity: 0; }
          100% { transform: scale(1.5); opacity: 0; }
        }
        .ow-drift { animation: ow-drift 14s ease-in-out infinite; }
        .ow-sheen { animation: ow-sheen 2.6s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
        .ow-ring { animation: ow-pulse-ring 2.2s ease-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .ow-drift, .ow-sheen, .ow-ring { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="ow-drift pointer-events-none absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20"
      />
      <div
        aria-hidden="true"
        className="ow-drift pointer-events-none absolute -bottom-32 -right-16 h-80 w-80 rounded-full bg-violet-300/35 blur-3xl dark:bg-violet-700/20"
        style={{ animationDelay: "-6s" }}
      />

      <div className="relative mx-auto w-full max-w-5xl">
        <div className="mb-9 flex flex-col gap-4 sm:mb-12">
          <span className="inline-flex w-fit items-center gap-2 rounded-full border border-indigo-200 bg-white/80 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 backdrop-blur dark:border-indigo-500/30 dark:bg-slate-900/70 dark:text-indigo-300">
            <SparkIcon className="h-3.5 w-3.5" />
            Setup takes about 6 minutes
          </span>
          <h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
            Welcome to Meridian, let&apos;s wire up your data
          </h2>
          <p className="max-w-2xl text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Four short steps and your first dashboard is live. Nothing here is permanent — every choice can be
            changed from Settings once you&apos;re in.
          </p>
        </div>

        <div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30">
          <div className="h-1 w-full bg-slate-100 dark:bg-slate-800">
            <motion.div
              className="h-full rounded-r-full bg-gradient-to-r from-indigo-500 to-violet-500"
              initial={false}
              animate={{ width: `${progress}%` }}
              transition={reduced ? { duration: 0 } : { duration: 0.4, ease: [0.22, 0.61, 0.36, 1] }}
            />
          </div>

          <div className="grid gap-0 md:grid-cols-[260px_1fr]">
            <div className="border-b border-slate-200 bg-slate-50/70 p-6 md:border-b-0 md:border-r dark:border-slate-800 dark:bg-slate-950/40">
              <div
                role="tablist"
                aria-label="Onboarding steps"
                aria-orientation="vertical"
                className="flex flex-row gap-2 overflow-x-auto md:flex-col md:overflow-visible"
              >
                {STEPS.map((s, i) => {
                  const complete = done || i < index;
                  const current = !done && i === index;
                  return (
                    <button
                      key={s.id}
                      ref={(el) => {
                        tabRefs.current[i] = el;
                      }}
                      type="button"
                      role="tab"
                      id={`${uid}-tab-${s.id}`}
                      aria-selected={current}
                      aria-controls={`${uid}-panel`}
                      tabIndex={current || (done && i === 0) ? 0 : -1}
                      disabled={i > index && !done}
                      onKeyDown={(e) => onTabKeyDown(e, i)}
                      onClick={() => {
                        if (i <= index || done) {
                          setDone(false);
                          setIndex(i);
                        }
                      }}
                      className={[
                        "group flex shrink-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors",
                        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950",
                        current
                          ? "bg-white text-slate-900 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:text-slate-50 dark:ring-slate-700"
                          : "text-slate-600 hover:bg-white/70 dark:text-slate-400 dark:hover:bg-slate-800/50",
                        i > index && !done ? "cursor-not-allowed opacity-55 hover:bg-transparent" : "",
                      ].join(" ")}
                    >
                      <span
                        aria-hidden="true"
                        className={[
                          "relative grid h-7 w-7 shrink-0 place-items-center rounded-full text-xs font-semibold transition-colors",
                          complete
                            ? "bg-emerald-500 text-white"
                            : current
                              ? "bg-indigo-600 text-white"
                              : "bg-slate-200 text-slate-500 dark:bg-slate-800 dark:text-slate-500",
                        ].join(" ")}
                      >
                        {current ? (
                          <span className="ow-ring absolute inset-0 rounded-full bg-indigo-500/40" />
                        ) : null}
                        <span className="relative">
                          {complete ? <CheckIcon className="h-4 w-4" /> : i + 1}
                        </span>
                      </span>
                      <span className="flex min-w-0 flex-col">
                        <span className="truncate text-sm font-medium">{s.label}</span>
                        <span className="hidden text-xs text-slate-500 md:block dark:text-slate-500">
                          {complete ? "Done" : `~${s.minutes} min`}
                        </span>
                      </span>
                    </button>
                  );
                })}
              </div>

              <p className="mt-6 hidden rounded-lg bg-slate-100 px-3 py-2 text-xs leading-relaxed text-slate-600 md:block dark:bg-slate-800/60 dark:text-slate-400">
                {done ? "All steps finished." : `About ${remaining} min left in setup.`}
              </p>
            </div>

            <div
              ref={panelRef}
              id={`${uid}-panel`}
              role="tabpanel"
              aria-labelledby={done ? undefined : `${uid}-tab-${step.id}`}
              aria-label={done ? "Setup complete" : undefined}
              tabIndex={-1}
              className="p-6 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 sm:p-8"
            >
              <AnimatePresence mode="wait" initial={false}>
                {done ? (
                  <motion.div key="done" {...fade} className="flex min-h-[22rem] flex-col justify-center">
                    <span className="mb-5 grid h-12 w-12 place-items-center rounded-2xl bg-emerald-500 text-white">
                      <CheckIcon className="h-6 w-6" />
                    </span>
                    <h3 className="text-2xl font-semibold tracking-tight">
                      {trimmed} is live
                    </h3>
                    <p className="mt-2 max-w-md text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                      We queued the first sync across {sources.length}{" "}
                      {sources.length === 1 ? "source" : "sources"} on a {cadenceLabel.toLowerCase()} cadence.
                      Expect your first dashboard to fill in within a few minutes — we&apos;ll email you when
                      it&apos;s ready.
                    </p>
                    <div className="mt-7 flex flex-wrap gap-3">
                      <button
                        type="button"
                        className="inline-flex items-center gap-2 rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
                        onClick={() => setStatus("Opening your workspace dashboard.")}
                      >
                        Go to dashboard
                        <ArrowIcon className="h-4 w-4" />
                      </button>
                      <button
                        type="button"
                        onClick={restart}
                        className="inline-flex items-center rounded-xl border border-slate-300 px-4 py-2.5 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-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                      >
                        Change something
                      </button>
                    </div>
                  </motion.div>
                ) : (
                  <motion.div key={step.id} {...fade} className="flex min-h-[22rem] flex-col">
                    <p className="text-xs font-medium uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
                      Step {index + 1} of {STEPS.length}
                    </p>
                    <h3 className="mt-2 text-2xl font-semibold tracking-tight">{step.title}</h3>
                    <p className="mt-2 max-w-lg text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                      {step.blurb}
                    </p>

                    <div className="mt-7 flex-1">
                      {step.id === "workspace" ? (
                        <div className="max-w-md">
                          <label
                            htmlFor={`${uid}-workspace`}
                            className="block text-sm font-medium text-slate-700 dark:text-slate-300"
                          >
                            Workspace name
                          </label>
                          <input
                            id={`${uid}-workspace`}
                            type="text"
                            value={workspace}
                            autoComplete="organization"
                            placeholder="Northwind Analytics"
                            aria-invalid={touchedWorkspace && !workspaceValid}
                            aria-describedby={`${uid}-workspace-help`}
                            onBlur={() => setTouchedWorkspace(true)}
                            onChange={(e) => setWorkspace(e.target.value)}
                            onKeyDown={(e) => {
                              if (e.key === "Enter") {
                                e.preventDefault();
                                goNext();
                              }
                            }}
                            className={[
                              "mt-2 w-full rounded-xl border bg-white px-3.5 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 transition-colors",
                              "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
                              "dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-600",
                              touchedWorkspace && !workspaceValid
                                ? "border-rose-400 dark:border-rose-500/70"
                                : "border-slate-300 dark:border-slate-700",
                            ].join(" ")}
                          />
                          <p
                            id={`${uid}-workspace-help`}
                            className={[
                              "mt-2 text-xs",
                              touchedWorkspace && !workspaceValid
                                ? "text-rose-600 dark:text-rose-400"
                                : "text-slate-500 dark:text-slate-500",
                            ].join(" ")}
                          >
                            {touchedWorkspace && !workspaceValid
                              ? "Use at least 3 characters — this shows up in every alert."
                              : "Most teams use their company or squad name. 3 characters minimum."}
                          </p>
                          <div className="mt-4 rounded-xl bg-slate-50 px-3.5 py-3 text-xs text-slate-600 dark:bg-slate-950/60 dark:text-slate-400">
                            Your URL will be{" "}
                            <span className="font-mono text-slate-900 dark:text-slate-200">
                              meridian.app/
                              {trimmed
                                ? trimmed.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
                                : "your-workspace"}
                            </span>
                          </div>
                        </div>
                      ) : null}

                      {step.id === "sources" ? (
                        <fieldset>
                          <legend className="sr-only">Choose data sources to connect</legend>
                          <div className="grid gap-3 sm:grid-cols-2">
                            {SOURCES.map((src) => {
                              const checked = sources.includes(src.id);
                              return (
                                <label
                                  key={src.id}
                                  htmlFor={`${uid}-src-${src.id}`}
                                  className={[
                                    "flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition-colors",
                                    "focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 focus-within:ring-offset-white dark:focus-within:ring-offset-slate-900",
                                    checked
                                      ? "border-indigo-500 bg-indigo-50/70 dark:border-indigo-500/60 dark:bg-indigo-500/10"
                                      : "border-slate-200 hover:border-slate-300 dark:border-slate-800 dark:hover:border-slate-700",
                                  ].join(" ")}
                                >
                                  <input
                                    id={`${uid}-src-${src.id}`}
                                    type="checkbox"
                                    checked={checked}
                                    onChange={() => toggleSource(src.id)}
                                    className="mt-0.5 h-4 w-4 shrink-0 rounded border-slate-300 accent-indigo-600 focus-visible:outline-none dark:border-slate-600"
                                  />
                                  <span className="flex flex-col">
                                    <span className="text-sm font-medium text-slate-900 dark:text-slate-100">
                                      {src.name}
                                    </span>
                                    <span className="text-xs text-slate-500 dark:text-slate-500">
                                      {src.detail}
                                    </span>
                                  </span>
                                </label>
                              );
                            })}
                          </div>
                          <p
                            aria-live="polite"
                            className="mt-3 text-xs text-slate-500 dark:text-slate-500"
                          >
                            {sources.length === 0
                              ? "Pick at least one source to continue."
                              : `${sources.length} selected.`}
                          </p>
                        </fieldset>
                      ) : null}

                      {step.id === "cadence" ? (
                        <div role="radiogroup" aria-label="Sync cadence" className="grid gap-3">
                          {CADENCES.map((c) => {
                            const active = cadence === c.id;
                            return (
                              <label
                                key={c.id}
                                htmlFor={`${uid}-cad-${c.id}`}
                                className={[
                                  "flex cursor-pointer items-center gap-3 rounded-xl border p-3.5 transition-colors",
                                  "focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 focus-within:ring-offset-white dark:focus-within:ring-offset-slate-900",
                                  active
                                    ? "border-indigo-500 bg-indigo-50/70 dark:border-indigo-500/60 dark:bg-indigo-500/10"
                                    : "border-slate-200 hover:border-slate-300 dark:border-slate-800 dark:hover:border-slate-700",
                                ].join(" ")}
                              >
                                <input
                                  id={`${uid}-cad-${c.id}`}
                                  type="radio"
                                  name={`${uid}-cadence`}
                                  value={c.id}
                                  checked={active}
                                  onChange={() => setCadence(c.id)}
                                  className="h-4 w-4 shrink-0 accent-indigo-600 focus-visible:outline-none"
                                />
                                <span className="flex min-w-0 flex-1 flex-col">
                                  <span className="text-sm font-medium text-slate-900 dark:text-slate-100">
                                    {c.name}
                                  </span>
                                  <span className="text-xs text-slate-500 dark:text-slate-500">{c.detail}</span>
                                </span>
                                <span className="shrink-0 rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-400">
                                  {c.cost}
                                </span>
                              </label>
                            );
                          })}
                        </div>
                      ) : null}

                      {step.id === "review" ? (
                        <dl className="divide-y divide-slate-200 overflow-hidden rounded-xl border border-slate-200 dark:divide-slate-800 dark:border-slate-800">
                          <div className="flex items-baseline justify-between gap-4 px-4 py-3">
                            <dt className="text-sm text-slate-500 dark:text-slate-500">Workspace</dt>
                            <dd className="text-sm font-medium text-slate-900 dark:text-slate-100">
                              {trimmed || "Not set"}
                            </dd>
                          </div>
                          <div className="flex items-baseline justify-between gap-4 px-4 py-3">
                            <dt className="text-sm text-slate-500 dark:text-slate-500">Sources</dt>
                            <dd className="text-right text-sm font-medium text-slate-900 dark:text-slate-100">
                              {sources.length > 0
                                ? SOURCES.filter((s) => sources.includes(s.id))
                                    .map((s) => s.name)
                                    .join(", ")
                                : "None"}
                            </dd>
                          </div>
                          <div className="flex items-baseline justify-between gap-4 px-4 py-3">
                            <dt className="text-sm text-slate-500 dark:text-slate-500">Cadence</dt>
                            <dd className="text-sm font-medium text-slate-900 dark:text-slate-100">
                              {cadenceLabel}
                            </dd>
                          </div>
                          <div className="flex items-baseline justify-between gap-4 bg-slate-50 px-4 py-3 dark:bg-slate-950/50">
                            <dt className="text-sm text-slate-500 dark:text-slate-500">First sync</dt>
                            <dd className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
                              Starts immediately
                            </dd>
                          </div>
                        </dl>
                      ) : null}
                    </div>

                    <div className="mt-8 flex items-center justify-between gap-3 border-t border-slate-200 pt-5 dark:border-slate-800">
                      <button
                        type="button"
                        onClick={goBack}
                        disabled={index === 0}
                        className="rounded-xl px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                      >
                        Back
                      </button>
                      <button
                        type="button"
                        onClick={goNext}
                        aria-disabled={!stepValid}
                        className={[
                          "relative inline-flex items-center gap-2 overflow-hidden rounded-xl px-4 py-2.5 text-sm font-medium text-white transition-colors",
                          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
                          stepValid
                            ? "bg-indigo-600 hover:bg-indigo-500"
                            : "cursor-not-allowed bg-slate-400 dark:bg-slate-700",
                        ].join(" ")}
                      >
                        {stepValid && index === STEPS.length - 1 ? (
                          <span
                            aria-hidden="true"
                            className="ow-sheen pointer-events-none absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/30 to-transparent"
                          />
                        ) : null}
                        <span className="relative">
                          {index === STEPS.length - 1 ? "Finish setup" : "Continue"}
                        </span>
                        <ArrowIcon className="relative h-4 w-4" />
                      </button>
                    </div>
                  </motion.div>
                )}
              </AnimatePresence>
            </div>
          </div>
        </div>

        <p aria-live="polite" className="sr-only">
          {status}
        </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 →