Onboarding Setup Wizard
Original · freemulti-step setup wizard
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-setup-wizard.json"use client";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type StepId = "workspace" | "focus" | "install" | "invite";
type RegionId = "us-east" | "eu-west" | "ap-south";
type FrameworkId = "next" | "vite" | "node";
type TeamSizeId = "solo" | "small" | "mid" | "large";
type StepMeta = {
id: StepId;
label: string;
title: string;
blurb: string;
};
const STEPS: StepMeta[] = [
{
id: "workspace",
label: "Workspace",
title: "Name your workspace",
blurb:
"This is what teammates see in the sidebar switcher and in every shared link. You can rename it later without breaking anything.",
},
{
id: "focus",
label: "Sources",
title: "Choose what Arcline watches",
blurb:
"Pick the surfaces you want traced first. Each one turns on a default dashboard — adding more later takes one toggle.",
},
{
id: "install",
label: "Install",
title: "Drop in the SDK",
blurb:
"One provider, one key. The first events usually land in under thirty seconds of your next deploy.",
},
{
id: "invite",
label: "Invite",
title: "Bring in your team",
blurb:
"Invitations expire after seven days. Everyone starts as an Editor and you can change roles from Settings → People.",
},
];
const REGIONS: { id: RegionId; name: string; detail: string; latency: string }[] = [
{ id: "us-east", name: "US East", detail: "Ashburn, Virginia", latency: "~18 ms" },
{ id: "eu-west", name: "EU West", detail: "Dublin, Ireland", latency: "~24 ms" },
{ id: "ap-south", name: "AP South", detail: "Mumbai, India", latency: "~31 ms" },
];
const SOURCES: { id: string; name: string; detail: string }[] = [
{ id: "web", name: "Web app", detail: "Page views, route changes, client errors" },
{ id: "mobile", name: "Mobile", detail: "iOS and Android session traces" },
{ id: "api", name: "Backend API", detail: "Request spans, latency, status codes" },
{ id: "jobs", name: "Background jobs", detail: "Queue depth, retries, failures" },
{ id: "warehouse", name: "Warehouse sync", detail: "Nightly Postgres or BigQuery pull" },
];
const TEAM_SIZES: { id: TeamSizeId; label: string }[] = [
{ id: "solo", label: "Just me" },
{ id: "small", label: "2–9" },
{ id: "mid", label: "10–49" },
{ id: "large", label: "50+" },
];
const FRAMEWORKS: { id: FrameworkId; label: string }[] = [
{ id: "next", label: "Next.js" },
{ id: "vite", label: "Vite" },
{ id: "node", label: "Node" },
];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32);
}
function snippetFor(framework: FrameworkId, region: RegionId): string {
if (framework === "next") {
return [
"// app/providers.tsx",
'"use client";',
'import { ArclineProvider } from "@arcline/next";',
"",
"export function Providers({ children }: { children: React.ReactNode }) {",
" return (",
" <ArclineProvider",
" apiKey={process.env.NEXT_PUBLIC_ARCLINE_KEY}",
' region="' + region + '"',
" >",
" {children}",
" </ArclineProvider>",
" );",
"}",
].join("\n");
}
if (framework === "vite") {
return [
"// src/main.tsx",
'import { createArcline } from "@arcline/browser";',
"",
"createArcline({",
" key: import.meta.env.VITE_ARCLINE_KEY,",
' region: "' + region + '",',
" flushEvery: 2000,",
"});",
].join("\n");
}
return [
"// server/instrument.ts",
'import { Arcline } from "@arcline/node";',
"",
"export const arcline = new Arcline({",
" key: process.env.ARCLINE_KEY,",
' region: "' + region + '",',
' service: "checkout-api",',
"});",
].join("\n");
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path
d="M4.5 10.5 8 14l7.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 0-4.5-4.5M16 10l-4.5 4.5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function CopyIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<rect
x="7"
y="7"
width="9"
height="9"
rx="2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M13 5.5A1.5 1.5 0 0 0 11.5 4h-6A1.5 1.5 0 0 0 4 5.5v6A1.5 1.5 0 0 0 5.5 13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
function CloseIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path
d="m6 6 8 8M14 6l-8 8"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
</svg>
);
}
export default function OnboardSetupWizard() {
const uid = useId();
const reduce = useReducedMotion();
const [index, setIndex] = useState<number>(0);
const [furthest, setFurthest] = useState<number>(0);
const [direction, setDirection] = useState<number>(1);
const [finished, setFinished] = useState<boolean>(false);
const [showErrors, setShowErrors] = useState<boolean>(false);
const [name, setName] = useState<string>("");
const [slug, setSlug] = useState<string>("");
const [slugTouched, setSlugTouched] = useState<boolean>(false);
const [region, setRegion] = useState<RegionId>("us-east");
const [sources, setSources] = useState<string[]>(["web"]);
const [teamSize, setTeamSize] = useState<TeamSizeId>("small");
const [framework, setFramework] = useState<FrameworkId>("next");
const [copied, setCopied] = useState<boolean>(false);
const [installed, setInstalled] = useState<boolean>(false);
const [emails, setEmails] = useState<string[]>([]);
const [draft, setDraft] = useState<string>("");
const [draftError, setDraftError] = useState<string>("");
const headingRef = useRef<HTMLHeadingElement | null>(null);
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const copyTimer = useRef<number | null>(null);
const mounted = useRef<boolean>(false);
const step = STEPS[index];
const snippet = useMemo(() => snippetFor(framework, region), [framework, region]);
const errors = useMemo<Record<StepId, string>>(() => {
const nameError =
name.trim().length === 0
? "Give the workspace a name so your team can find it."
: name.trim().length < 3
? "Use at least three characters."
: "";
const slugError =
slug.length === 0
? "A URL needs at least one character."
: !/^[a-z0-9-]+$/.test(slug)
? "Lowercase letters, numbers and hyphens only."
: "";
return {
workspace: nameError || slugError,
focus: sources.length === 0 ? "Pick at least one source to trace." : "",
install: installed ? "" : "Confirm the snippet is in your codebase to continue.",
invite: "",
};
}, [name, slug, sources, installed]);
const currentError = errors[step.id];
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
headingRef.current?.focus();
}, [index, finished]);
useEffect(() => {
return () => {
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
};
}, []);
const handleName = useCallback(
(value: string) => {
setName(value);
if (!slugTouched) setSlug(slugify(value));
},
[slugTouched],
);
const toggleSource = useCallback((id: string) => {
setSources((prev) =>
prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id],
);
}, []);
const goTo = useCallback(
(next: number) => {
setDirection(next > index ? 1 : -1);
setIndex(next);
setShowErrors(false);
setFurthest((f) => Math.max(f, next));
},
[index],
);
const goNext = useCallback(() => {
if (currentError) {
setShowErrors(true);
return;
}
if (index === STEPS.length - 1) {
setDirection(1);
setFinished(true);
return;
}
goTo(index + 1);
}, [currentError, index, goTo]);
const goBack = useCallback(() => {
if (index > 0) goTo(index - 1);
}, [index, goTo]);
const copySnippet = useCallback(async () => {
try {
await navigator.clipboard.writeText(snippet);
} catch {
// clipboard blocked — the snippet stays selectable in the <pre>
}
setCopied(true);
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 2000);
}, [snippet]);
const addEmail = useCallback(() => {
const value = draft.trim().replace(/,$/, "");
if (value.length === 0) return;
if (!EMAIL_RE.test(value)) {
setDraftError(`"${value}" isn't a valid email address.`);
return;
}
if (emails.includes(value.toLowerCase())) {
setDraftError("That address is already on the list.");
return;
}
setEmails((prev) => [...prev, value.toLowerCase()]);
setDraft("");
setDraftError("");
}, [draft, emails]);
const onDraftKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" || event.key === "," || event.key === "Tab") {
if (event.key === "Tab" && draft.trim().length === 0) return;
event.preventDefault();
addEmail();
return;
}
if (event.key === "Backspace" && draft.length === 0 && emails.length > 0) {
setEmails((prev) => prev.slice(0, -1));
}
},
[addEmail, draft, emails.length],
);
const onTabKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>, position: number) => {
const last = FRAMEWORKS.length - 1;
let next = -1;
if (event.key === "ArrowRight") next = position === last ? 0 : position + 1;
if (event.key === "ArrowLeft") next = position === 0 ? last : position - 1;
if (event.key === "Home") next = 0;
if (event.key === "End") next = last;
if (next < 0) return;
event.preventDefault();
setFramework(FRAMEWORKS[next].id);
tabRefs.current[next]?.focus();
},
[],
);
const restart = useCallback(() => {
setFinished(false);
setIndex(0);
setFurthest(0);
setDirection(-1);
setShowErrors(false);
setName("");
setSlug("");
setSlugTouched(false);
setRegion("us-east");
setSources(["web"]);
setTeamSize("small");
setFramework("next");
setInstalled(false);
setEmails([]);
setDraft("");
setDraftError("");
}, []);
const percent = finished ? 100 : Math.round((index / STEPS.length) * 100);
const fieldBase =
"w-full rounded-xl border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:border-indigo-400 dark:focus-visible:ring-indigo-400/40";
const cardBase =
"relative flex cursor-pointer gap-3 rounded-xl border border-slate-200 bg-white p-3.5 text-left transition peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500/50 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white hover:border-slate-300 dark:border-slate-800 dark:bg-slate-950 dark:peer-focus-visible:ring-indigo-400/50 dark:peer-focus-visible:ring-offset-slate-950 dark:hover:border-slate-700";
const ghostBtn =
"inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-slate-500 dark:focus-visible:ring-offset-slate-950";
const primaryBtn =
"inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500 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-indigo-400 dark:focus-visible:ring-offset-slate-950";
const panelVariants = {
enter: (dir: number) => ({ opacity: 0, x: reduce ? 0 : dir * 24 }),
center: { opacity: 1, x: 0 },
exit: (dir: number) => ({ opacity: 0, x: reduce ? 0 : dir * -24 }),
};
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes oswz-sheen {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@keyframes oswz-pop {
0% { transform: scale(0.6); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes oswz-halo {
0%, 100% { opacity: 0.45; transform: scale(1); }
50% { opacity: 0.85; transform: scale(1.06); }
}
.oswz-sheen { animation: oswz-sheen 2.4s ease-in-out infinite; }
.oswz-pop { animation: oswz-pop 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.oswz-halo { animation: oswz-halo 3.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.oswz-sheen, .oswz-pop, .oswz-halo { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-4xl">
<header className="mb-8 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Arcline setup
</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Four steps to your first trace
</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Most teams finish this in about six minutes. Nothing here is permanent —
every choice is editable from Settings afterwards.
</p>
</header>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-200 px-5 pt-5 sm:px-8 sm:pt-7 dark:border-slate-800">
<ol className="flex flex-wrap items-center gap-x-2 gap-y-3">
{STEPS.map((s, i) => {
const complete = finished || i < furthest || (i < index);
const active = !finished && i === index;
const reachable = i <= furthest && !finished;
return (
<li key={s.id} className="flex items-center gap-2">
<button
type="button"
onClick={() => reachable && goTo(i)}
disabled={!reachable}
aria-current={active ? "step" : undefined}
className={[
"group inline-flex items-center gap-2 rounded-full py-1.5 pl-1.5 pr-3 text-sm font-medium transition 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-indigo-400 dark:focus-visible:ring-offset-slate-900",
active
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300"
: reachable || finished
? "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
: "cursor-default text-slate-400 dark:text-slate-600",
].join(" ")}
>
<span
aria-hidden="true"
className={[
"flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold transition",
complete
? "bg-emerald-500 text-white"
: active
? "bg-indigo-600 text-white"
: "bg-slate-200 text-slate-500 dark:bg-slate-800 dark:text-slate-500",
].join(" ")}
>
{complete ? <CheckIcon className="h-3.5 w-3.5" /> : i + 1}
</span>
{s.label}
<span className="sr-only">
{complete ? " (completed)" : active ? " (current step)" : " (locked)"}
</span>
</button>
{i < STEPS.length - 1 ? (
<span
aria-hidden="true"
className="hidden h-px w-6 bg-slate-200 sm:block dark:bg-slate-800"
/>
) : null}
</li>
);
})}
</ol>
<div
className="relative mt-5 h-1 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Setup progress"
>
<motion.div
className="relative h-full rounded-full bg-indigo-600 dark:bg-indigo-500"
initial={false}
animate={{ width: `${percent}%` }}
transition={
reduce ? { duration: 0 } : { type: "spring", stiffness: 220, damping: 30 }
}
>
<span className="oswz-sheen absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-transparent via-white/60 to-transparent" />
</motion.div>
</div>
<div className="mt-2 pb-4 text-xs text-slate-500 dark:text-slate-500">
{finished ? "Setup complete" : `Step ${index + 1} of ${STEPS.length}`}
</div>
</div>
<div aria-live="polite" className="sr-only">
{finished
? "Setup complete."
: `Step ${index + 1} of ${STEPS.length}: ${step.title}`}
</div>
<div className="px-5 py-7 sm:px-8 sm:py-9">
<AnimatePresence mode="wait" custom={direction} initial={false}>
<motion.div
key={finished ? "done" : step.id}
custom={direction}
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
transition={reduce ? { duration: 0 } : { duration: 0.24, ease: "easeOut" }}
>
{finished ? (
<div className="text-center">
<div className="relative mx-auto mb-5 h-14 w-14">
<span className="oswz-halo absolute inset-0 rounded-full bg-emerald-400/30" />
<span className="oswz-pop relative flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500 text-white">
<CheckIcon className="h-7 w-7" />
</span>
</div>
<h3
ref={headingRef}
tabIndex={-1}
className="text-2xl font-semibold tracking-tight text-slate-900 outline-none dark:text-white"
>
{name.trim()} is live
</h3>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Deploy once and the first spans appear on your overview dashboard.
If nothing shows in five minutes, check that the API key matches
the {REGIONS.find((r) => r.id === region)?.name} region.
</p>
<dl className="mx-auto mt-7 grid max-w-lg gap-px overflow-hidden rounded-xl border border-slate-200 bg-slate-200 text-left sm:grid-cols-2 dark:border-slate-800 dark:bg-slate-800">
<div className="bg-white p-4 dark:bg-slate-900">
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-500">
Workspace URL
</dt>
<dd className="mt-1 truncate font-mono text-sm text-slate-900 dark:text-slate-100">
{slug}.arcline.dev
</dd>
</div>
<div className="bg-white p-4 dark:bg-slate-900">
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-500">
Region
</dt>
<dd className="mt-1 text-sm text-slate-900 dark:text-slate-100">
{REGIONS.find((r) => r.id === region)?.name}
</dd>
</div>
<div className="bg-white p-4 dark:bg-slate-900">
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-500">
Sources traced
</dt>
<dd className="mt-1 text-sm text-slate-900 dark:text-slate-100">
{sources
.map((id) => SOURCES.find((s) => s.id === id)?.name)
.filter(Boolean)
.join(", ")}
</dd>
</div>
<div className="bg-white p-4 dark:bg-slate-900">
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-500">
Invites queued
</dt>
<dd className="mt-1 text-sm text-slate-900 dark:text-slate-100">
{emails.length === 0
? "None — you're flying solo"
: `${emails.length} ${emails.length === 1 ? "person" : "people"}`}
</dd>
</div>
</dl>
<button type="button" onClick={restart} className={`${ghostBtn} mt-7`}>
Start over
</button>
</div>
) : (
<div>
<h3
ref={headingRef}
tabIndex={-1}
className="text-xl font-semibold tracking-tight text-slate-900 outline-none sm:text-2xl dark:text-white"
>
{step.title}
</h3>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{step.blurb}
</p>
<div className="mt-7">
{step.id === "workspace" ? (
<div className="grid gap-5">
<div>
<label
htmlFor={`${uid}-name`}
className="mb-1.5 block text-sm font-medium text-slate-900 dark:text-slate-200"
>
Workspace name
</label>
<input
id={`${uid}-name`}
value={name}
onChange={(e) => handleName(e.target.value)}
placeholder="Northwind Payments"
autoComplete="off"
spellCheck={false}
aria-invalid={showErrors && errors.workspace.length > 0}
aria-describedby={`${uid}-name-hint`}
className={fieldBase}
/>
<p
id={`${uid}-name-hint`}
className="mt-1.5 text-xs text-slate-500 dark:text-slate-500"
>
Usually the company or the product it belongs to.
</p>
</div>
<div>
<label
htmlFor={`${uid}-slug`}
className="mb-1.5 block text-sm font-medium text-slate-900 dark:text-slate-200"
>
Workspace URL
</label>
<div className="flex items-stretch overflow-hidden rounded-xl border border-slate-300 bg-white focus-within:border-indigo-500 focus-within:ring-2 focus-within:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-950 dark:focus-within:border-indigo-400 dark:focus-within:ring-indigo-400/40">
<input
id={`${uid}-slug`}
value={slug}
onChange={(e) => {
setSlugTouched(true);
setSlug(slugify(e.target.value));
}}
placeholder="northwind"
autoComplete="off"
spellCheck={false}
aria-describedby={`${uid}-slug-hint`}
className="min-w-0 flex-1 bg-transparent px-3.5 py-2.5 font-mono text-sm text-slate-900 outline-none placeholder:text-slate-400 dark:text-slate-100 dark:placeholder:text-slate-600"
/>
<span
aria-hidden="true"
className="flex shrink-0 items-center border-l border-slate-200 bg-slate-50 px-3 font-mono text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-500"
>
.arcline.dev
</span>
</div>
<p
id={`${uid}-slug-hint`}
className="mt-1.5 text-xs text-slate-500 dark:text-slate-500"
>
Auto-filled from the name. Edit it and we'll stop
overwriting your version.
</p>
</div>
<fieldset>
<legend className="mb-2 text-sm font-medium text-slate-900 dark:text-slate-200">
Data region
</legend>
<div className="grid gap-2.5 sm:grid-cols-3">
{REGIONS.map((r) => (
<label key={r.id} className="block">
<input
type="radio"
name={`${uid}-region`}
value={r.id}
checked={region === r.id}
onChange={() => setRegion(r.id)}
className="peer sr-only"
/>
<span
className={`${cardBase} flex-col ${
region === r.id
? "border-indigo-500 bg-indigo-50/60 dark:border-indigo-400 dark:bg-indigo-500/10"
: ""
}`}
>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{r.name}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{r.detail}
</span>
<span className="mt-1 font-mono text-[11px] text-emerald-600 dark:text-emerald-400">
{r.latency} p50
</span>
</span>
</label>
))}
</div>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-500">
Events stay in the region you pick. Moving a workspace later
means a re-ingest, so choose the one closest to your users.
</p>
</fieldset>
</div>
) : null}
{step.id === "focus" ? (
<div className="grid gap-6">
<fieldset>
<legend className="mb-2 text-sm font-medium text-slate-900 dark:text-slate-200">
Sources to trace
<span className="ml-2 font-normal text-slate-500 dark:text-slate-500">
{sources.length} selected
</span>
</legend>
<div className="grid gap-2.5 sm:grid-cols-2">
{SOURCES.map((s) => {
const on = sources.includes(s.id);
return (
<label key={s.id} className="block">
<input
type="checkbox"
checked={on}
onChange={() => toggleSource(s.id)}
className="peer sr-only"
/>
<span
className={`${cardBase} items-start ${
on
? "border-indigo-500 bg-indigo-50/60 dark:border-indigo-400 dark:bg-indigo-500/10"
: ""
}`}
>
<span
aria-hidden="true"
className={[
"mt-0.5 flex shrink-0 items-center justify-center rounded-[5px] border transition",
on
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
: "border-slate-300 bg-white dark:border-slate-600 dark:bg-slate-900",
].join(" ")}
style={{ height: "1.125rem", width: "1.125rem" }}
>
{on ? <CheckIcon className="h-3 w-3" /> : null}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-slate-900 dark:text-slate-100">
{s.name}
</span>
<span className="mt-0.5 block text-xs leading-relaxed text-slate-500 dark:text-slate-400">
{s.detail}
</span>
</span>
</span>
</label>
);
})}
</div>
</fieldset>
<fieldset>
<legend className="mb-2 text-sm font-medium text-slate-900 dark:text-slate-200">
How many engineers will use this?
</legend>
<div className="inline-flex flex-wrap gap-1.5 rounded-xl bg-slate-100 p-1 dark:bg-slate-800/70">
{TEAM_SIZES.map((t) => (
<label key={t.id} className="block">
<input
type="radio"
name={`${uid}-team`}
value={t.id}
checked={teamSize === t.id}
onChange={() => setTeamSize(t.id)}
className="peer sr-only"
/>
<span
className={[
"block cursor-pointer rounded-lg px-3.5 py-1.5 text-sm font-medium transition peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-slate-100 dark:peer-focus-visible:ring-indigo-400 dark:peer-focus-visible:ring-offset-slate-800",
teamSize === t.id
? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200",
].join(" ")}
>
{t.label}
</span>
</label>
))}
</div>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-500">
This only sets your default sampling rate — {teamSize === "solo" ? "10%" : teamSize === "small" ? "25%" : teamSize === "mid" ? "50%" : "100%"} of
traces retained for 30 days.
</p>
</fieldset>
</div>
) : null}
{step.id === "install" ? (
<div className="grid gap-4">
<div
role="tablist"
aria-label="Framework"
className="inline-flex w-fit gap-1 rounded-xl bg-slate-100 p-1 dark:bg-slate-800/70"
>
{FRAMEWORKS.map((f, i) => (
<button
key={f.id}
type="button"
role="tab"
id={`${uid}-tab-${f.id}`}
aria-selected={framework === f.id}
aria-controls={`${uid}-tabpanel`}
tabIndex={framework === f.id ? 0 : -1}
ref={(el) => {
tabRefs.current[i] = el;
}}
onClick={() => setFramework(f.id)}
onKeyDown={(e) => onTabKeyDown(e, i)}
className={[
"rounded-lg px-3.5 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-800",
framework === f.id
? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200",
].join(" ")}
>
{f.label}
</button>
))}
</div>
<div
id={`${uid}-tabpanel`}
role="tabpanel"
aria-labelledby={`${uid}-tab-${framework}`}
tabIndex={0}
className="relative overflow-hidden rounded-xl border border-slate-800 bg-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
>
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-2">
<span className="font-mono text-xs text-slate-500">
npm i @arcline/
{framework === "next"
? "next"
: framework === "vite"
? "browser"
: "node"}
</span>
<button
type="button"
onClick={copySnippet}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium text-slate-300 transition hover:bg-slate-800 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
>
{copied ? (
<CheckIcon className="h-3.5 w-3.5 text-emerald-400" />
) : (
<CopyIcon className="h-3.5 w-3.5" />
)}
{copied ? "Copied" : "Copy"}
<span className="sr-only"> code snippet</span>
</button>
</div>
<pre className="overflow-x-auto px-4 py-4 text-[13px] leading-relaxed text-slate-200">
<code>{snippet}</code>
</pre>
</div>
<label className="block">
<input
type="checkbox"
checked={installed}
onChange={(e) => {
setInstalled(e.target.checked);
if (e.target.checked) setShowErrors(false);
}}
aria-describedby={`${uid}-install-note`}
className="peer sr-only"
/>
<span
className={`${cardBase} items-center ${
installed
? "border-emerald-500 bg-emerald-50/60 dark:border-emerald-500/60 dark:bg-emerald-500/10"
: ""
}`}
>
<span
aria-hidden="true"
className={[
"flex shrink-0 items-center justify-center rounded-[5px] border transition",
installed
? "border-emerald-600 bg-emerald-600 text-white dark:border-emerald-500 dark:bg-emerald-500"
: "border-slate-300 bg-white dark:border-slate-600 dark:bg-slate-900",
].join(" ")}
style={{ height: "1.125rem", width: "1.125rem" }}
>
{installed ? <CheckIcon className="h-3 w-3" /> : null}
</span>
<span className="text-sm text-slate-900 dark:text-slate-100">
The snippet is committed to my codebase
</span>
</span>
</label>
<p
id={`${uid}-install-note`}
className="text-xs text-slate-500 dark:text-slate-500"
>
Paste the key into your environment file as well — the SDK
throws at boot if it's missing rather than failing quietly.
</p>
</div>
) : null}
{step.id === "invite" ? (
<div className="grid gap-4">
<div>
<label
htmlFor={`${uid}-email`}
className="mb-1.5 block text-sm font-medium text-slate-900 dark:text-slate-200"
>
Email addresses
</label>
<div className="flex gap-2">
<input
id={`${uid}-email`}
type="email"
value={draft}
onChange={(e) => {
setDraft(e.target.value);
if (draftError) setDraftError("");
}}
onKeyDown={onDraftKeyDown}
onBlur={() => {
if (draft.trim().length > 0) addEmail();
}}
placeholder="dana@northwind.com"
autoComplete="off"
aria-invalid={draftError.length > 0}
aria-describedby={`${uid}-email-hint`}
className={fieldBase}
/>
<button
type="button"
onClick={addEmail}
disabled={draft.trim().length === 0}
className={`${ghostBtn} shrink-0 border border-slate-300 dark:border-slate-700`}
>
Add
</button>
</div>
<p
id={`${uid}-email-hint`}
className="mt-1.5 text-xs text-slate-500 dark:text-slate-500"
>
Press Enter or comma to add. Backspace on an empty field
removes the last one.
</p>
<p
role="status"
className={`mt-1.5 text-xs text-rose-600 dark:text-rose-400 ${
draftError ? "" : "hidden"
}`}
>
{draftError}
</p>
</div>
<ul className="flex flex-wrap gap-2" aria-label="People to invite">
<AnimatePresence initial={false}>
{emails.map((email) => (
<motion.li
key={email}
layout={!reduce}
initial={reduce ? false : { opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}
transition={{ duration: 0.16 }}
className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 py-1 pl-3 pr-1.5 text-sm text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
>
<span className="truncate">{email}</span>
<button
type="button"
onClick={() =>
setEmails((prev) => prev.filter((e) => e !== email))
}
className="rounded-full p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-slate-700 dark:hover:text-white dark:focus-visible:ring-indigo-400"
>
<CloseIcon className="h-3.5 w-3.5" />
<span className="sr-only">Remove {email}</span>
</button>
</motion.li>
))}
</AnimatePresence>
{emails.length === 0 ? (
<li className="text-xs text-slate-500 dark:text-slate-500">
No one yet — you can skip this and invite from Settings later.
</li>
) : null}
</ul>
</div>
) : null}
</div>
<p
role="alert"
className={`mt-6 flex items-center gap-2 text-sm text-rose-600 dark:text-rose-400 ${
showErrors && currentError ? "" : "hidden"
}`}
>
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-4 w-4 shrink-0"
>
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" />
<path
d="M10 6.5v4"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
<circle cx="10" cy="13.5" r="0.9" fill="currentColor" />
</svg>
{currentError}
</p>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
{!finished ? (
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50/70 px-5 py-4 sm:px-8 dark:border-slate-800 dark:bg-slate-950/40">
<button
type="button"
onClick={goBack}
disabled={index === 0}
className={ghostBtn}
>
Back
</button>
<div className="flex items-center gap-2">
{step.id === "invite" ? (
<button
type="button"
onClick={() => {
setEmails([]);
setDraft("");
setDraftError("");
setFinished(true);
}}
className={ghostBtn}
>
Skip for now
</button>
) : null}
<button type="button" onClick={goNext} className={primaryBtn}>
{index !== STEPS.length - 1
? "Continue"
: emails.length === 0
? "Finish setup"
: `Send ${emails.length} ${
emails.length === 1 ? "invite" : "invites"
} & finish`}
<ArrowIcon className="h-4 w-4" />
</button>
</div>
</div>
) : null}
</div>
</div>
</section>
);
}Dependencies
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 quoteSimilar components
Browse all →
Onboarding Welcome
Originalwelcome screen with steps

Onboarding Tour
Originalproduct tour with prev/next dots

Onboarding Checklist
Originalsetup checklist with progress

Onboarding Spotlight
Originalfeature spotlight highlight

Onboarding Tooltip Tour
Originalguided tooltip walkthrough

Onboarding Progress
Originalonboarding progress header

Onboarding Slides
Originalintro slides carousel

Onboarding Permissions
Originalpermissions request screen

Onboarding Get Started
Originalget started action cards

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

