Onboarding Welcome
Original · freewelcome screen with steps
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"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'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'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'll email you when
it'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
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 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 Setup Wizard
Originalmulti-step setup wizard

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.

