Onboarding Slides
Original · freeintro slides carousel
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-slides.json"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, ReactElement } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type SlideId = "capture" | "route" | "measure" | "ship";
interface Slide {
id: SlideId;
eyebrow: string;
title: string;
body: string;
bullets: string[];
accent: string;
glow: string;
}
const SLIDES: Slide[] = [
{
id: "capture",
eyebrow: "Step 1 of 4",
title: "Every lead lands in one inbox",
body:
"Forms, live chat, the phone widget, and your Instagram DMs all write to the same queue. No more three tabs and a spreadsheet called final_leads_v2.xlsx.",
bullets: [
"Web forms and chat sync in under 2 seconds",
"Duplicate contacts merge on email or phone",
"Nothing is deleted — archived threads stay searchable",
],
accent: "indigo",
glow: "from-indigo-500/25 via-violet-500/10 to-transparent",
},
{
id: "route",
eyebrow: "Step 2 of 4",
title: "Rules decide who picks it up",
body:
"Write the rule once in plain language — territory, deal size, language, time of day — and the queue assigns itself. Unclaimed leads escalate after 15 minutes.",
bullets: [
"Round-robin or weighted assignment per team",
"Out-of-office reroutes without a manual handoff",
"Escalation ladder you can pause during holidays",
],
accent: "emerald",
glow: "from-emerald-500/25 via-sky-500/10 to-transparent",
},
{
id: "measure",
eyebrow: "Step 3 of 4",
title: "See where deals actually stall",
body:
"Stage-by-stage timing shows the gap between first reply and first call. Most teams find the leak sitting in one stage they never suspected.",
bullets: [
"Median time-in-stage, not vanity totals",
"Cohort view by source, rep, and month",
"Export any chart to CSV or a shareable link",
],
accent: "amber",
glow: "from-amber-500/25 via-rose-500/10 to-transparent",
},
{
id: "ship",
eyebrow: "Step 4 of 4",
title: "Invite the team and go live",
body:
"Import your contacts, pick a plan later. The workspace stays fully usable for 14 days without a card, and your data exports whole if you leave.",
bullets: [
"CSV import maps columns automatically",
"Roles: owner, manager, rep, read-only",
"Full export in JSON or CSV, any time",
],
accent: "violet",
glow: "from-violet-500/25 via-indigo-500/10 to-transparent",
},
];
function IconInbox() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-full w-full">
<path d="M4 13h3.5l1.5 2.5h6L16.5 13H20" />
<path d="M5.4 5.6 3.6 12.2A2 2 0 0 0 3.5 12.7V17a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2v-4.3c0-.17-.02-.34-.06-.5L18.6 5.6A2 2 0 0 0 16.7 4H7.3a2 2 0 0 0-1.9 1.6Z" />
</svg>
);
}
function IconRoute() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-full w-full">
<circle cx="6" cy="6" r="2.4" />
<circle cx="18" cy="18" r="2.4" />
<circle cx="18" cy="6" r="2.4" />
<path d="M6 8.4V14a3 3 0 0 0 3 3h6.6" />
<path d="M8.4 6h7.2" />
</svg>
);
}
function IconChart() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-full w-full">
<path d="M4 4v14a2 2 0 0 0 2 2h14" />
<rect x="7.5" y="12" width="2.8" height="5" rx="0.8" />
<rect x="12.6" y="8" width="2.8" height="9" rx="0.8" />
<rect x="17.7" y="4.8" width="2.8" height="12.2" rx="0.8" />
</svg>
);
}
function IconRocket() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-full w-full">
<path d="M12 3.5c2.9 2 4.5 5.2 4.5 8.7l-2.2 2.6h-4.6L7.5 12.2C7.5 8.7 9.1 5.5 12 3.5Z" />
<circle cx="12" cy="10" r="1.6" />
<path d="M9.7 14.8 8 17.4l2.4-.5M14.3 14.8l1.7 2.6-2.4-.5" />
<path d="M10.6 19.4c.5.9.9 1.4 1.4 1.4s.9-.5 1.4-1.4" />
</svg>
);
}
const ICONS: Record<SlideId, () => ReactElement> = {
capture: IconInbox,
route: IconRoute,
measure: IconChart,
ship: IconRocket,
};
const ACCENT_TEXT: Record<string, string> = {
indigo: "text-indigo-600 dark:text-indigo-300",
emerald: "text-emerald-600 dark:text-emerald-300",
amber: "text-amber-600 dark:text-amber-300",
violet: "text-violet-600 dark:text-violet-300",
};
const ACCENT_RING: Record<string, string> = {
indigo: "ring-indigo-500/25 bg-indigo-500/10 dark:ring-indigo-400/25",
emerald: "ring-emerald-500/25 bg-emerald-500/10 dark:ring-emerald-400/25",
amber: "ring-amber-500/25 bg-amber-500/10 dark:ring-amber-400/25",
violet: "ring-violet-500/25 bg-violet-500/10 dark:ring-violet-400/25",
};
const ACCENT_BAR: Record<string, string> = {
indigo: "bg-indigo-500 dark:bg-indigo-400",
emerald: "bg-emerald-500 dark:bg-emerald-400",
amber: "bg-amber-500 dark:bg-amber-400",
violet: "bg-violet-500 dark:bg-violet-400",
};
export default function OnboardSlides() {
const reduce = useReducedMotion();
const [index, setIndex] = useState(0);
const [dir, setDir] = useState<1 | -1>(1);
const [done, setDone] = useState(false);
const tablistRef = useRef<HTMLDivElement | null>(null);
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const panelId = useId();
const tabId = useId();
const total = SLIDES.length;
const slide = SLIDES[index];
const Icon = ICONS[slide.id];
const isLast = index === total - 1;
const go = useCallback(
(next: number, focusTab = false) => {
const clamped = Math.max(0, Math.min(total - 1, next));
setDir(clamped >= index ? 1 : -1);
setIndex(clamped);
if (focusTab) tabRefs.current[clamped]?.focus();
},
[index, total],
);
useEffect(() => {
if (index === total - 1) setDone(true);
}, [index, total]);
const onTabKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const map: Record<string, number | undefined> = {
ArrowRight: index + 1,
ArrowLeft: index - 1,
Home: 0,
End: total - 1,
};
const next = map[event.key];
if (next === undefined) return;
event.preventDefault();
go(next, true);
};
const variants = {
enter: (d: 1 | -1) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 44 }),
center: { opacity: 1, x: 0 },
exit: (d: 1 | -1) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * -44 }),
};
return (
<section className="relative w-full overflow-hidden bg-white px-4 py-20 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes obsl-drift {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0, -14px, 0) scale(1.05); }
}
@keyframes obsl-sheen {
0% { transform: translateX(-120%); }
100% { transform: translateX(220%); }
}
.obsl-drift { animation: obsl-drift 11s ease-in-out infinite; }
.obsl-sheen { animation: obsl-sheen 2.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.obsl-drift, .obsl-sheen { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className={`obsl-drift pointer-events-none absolute -top-32 left-1/2 h-[26rem] w-[46rem] -translate-x-1/2 rounded-full bg-gradient-to-b ${slide.glow} blur-3xl transition-[background-image] duration-700`}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-[0.35] dark:opacity-[0.22]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, rgb(100 116 139 / 0.35) 1px, transparent 0)",
backgroundSize: "26px 26px",
maskImage: "radial-gradient(ellipse 70% 60% at 50% 40%, black, transparent)",
WebkitMaskImage: "radial-gradient(ellipse 70% 60% at 50% 40%, black, transparent)",
}}
/>
<div className="relative mx-auto w-full max-w-4xl">
<header className="mb-8 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">
Welcome to Coilpath
</p>
<h2 className="mt-1.5 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Four things worth knowing first
</h2>
</div>
<p className="shrink-0 text-sm tabular-nums text-slate-500 dark:text-slate-400">
<span className="font-semibold text-slate-900 dark:text-white">{index + 1}</span> / {total}
</p>
</header>
<div className="mb-6 h-1 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<motion.div
className={`h-full rounded-full ${ACCENT_BAR[slide.accent]}`}
initial={false}
animate={{ width: `${((index + 1) / total) * 100}%` }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 240, damping: 32 }}
/>
</div>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white/80 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/70">
<div className="grid gap-0 md:grid-cols-[1fr_auto]">
<div className="relative min-h-[21rem] p-6 sm:p-8">
<AnimatePresence mode="wait" custom={dir} initial={false}>
<motion.div
key={slide.id}
custom={dir}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={reduce ? { duration: 0.15 } : { duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
role="tabpanel"
id={`${panelId}-${slide.id}`}
aria-labelledby={`${tabId}-${slide.id}`}
tabIndex={0}
className="rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-4 focus-visible:ring-offset-white dark:focus-visible:ring-slate-100 dark:focus-visible:ring-offset-slate-900"
>
<div className={`inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ring-1 ${ACCENT_RING[slide.accent]} ${ACCENT_TEXT[slide.accent]}`}>
<span className={`h-6 w-6 ${ACCENT_TEXT[slide.accent]}`}>
<Icon />
</span>
{slide.eyebrow}
</div>
<h3 className="mt-4 text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl dark:text-white">
{slide.title}
</h3>
<p className="mt-2.5 max-w-prose text-sm leading-relaxed text-slate-600 sm:text-[0.95rem] dark:text-slate-300">
{slide.body}
</p>
<ul className="mt-5 space-y-2.5">
{slide.bullets.map((b) => (
<li key={b} className="flex items-start gap-2.5 text-sm text-slate-700 dark:text-slate-300">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={`mt-0.5 h-4 w-4 shrink-0 ${ACCENT_TEXT[slide.accent]}`}>
<circle cx="10" cy="10" r="8" strokeOpacity={0.35} />
<path d="m6.6 10.2 2.2 2.2 4.6-4.8" />
</svg>
<span>{b}</span>
</li>
))}
</ul>
</motion.div>
</AnimatePresence>
</div>
<div
ref={tablistRef}
role="tablist"
aria-label="Onboarding steps"
aria-orientation="vertical"
onKeyDown={onTabKeyDown}
className="flex shrink-0 gap-2 border-t border-slate-200 p-4 md:w-56 md:flex-col md:border-l md:border-t-0 dark:border-slate-800"
>
{SLIDES.map((s, i) => {
const active = i === index;
const TabIcon = ICONS[s.id];
return (
<button
key={s.id}
ref={(el) => {
tabRefs.current[i] = el;
}}
type="button"
role="tab"
id={`${tabId}-${s.id}`}
aria-selected={active}
aria-controls={`${panelId}-${s.id}`}
tabIndex={active ? 0 : -1}
onClick={() => go(i)}
className={`group relative flex flex-1 items-center gap-2.5 rounded-xl px-3 py-2.5 text-left text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white md:flex-none dark:focus-visible:ring-slate-100 dark:focus-visible:ring-offset-slate-900 ${
active
? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
}`}
>
<span className="h-4 w-4 shrink-0">
<TabIcon />
</span>
<span className="hidden truncate md:inline">{s.title}</span>
<span className="md:hidden">{i + 1}</span>
</button>
);
})}
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50/60 px-4 py-3.5 sm:px-6 dark:border-slate-800 dark:bg-slate-950/40">
<button
type="button"
onClick={() => go(index - 1)}
disabled={index === 0}
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors outline-none hover:bg-slate-200/70 focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-slate-100 dark:focus-visible:ring-offset-slate-950"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
<path d="M12 4.5 6.5 10l5.5 5.5" />
</svg>
Back
</button>
<div className="flex items-center gap-1.5" aria-hidden="true">
{SLIDES.map((s, i) => (
<span
key={s.id}
className={`h-1.5 rounded-full transition-all duration-300 ${
i === index
? `w-5 ${ACCENT_BAR[slide.accent]}`
: "w-1.5 bg-slate-300 dark:bg-slate-700"
}`}
/>
))}
</div>
<button
type="button"
onClick={() => {
if (isLast) setDone(true);
else go(index + 1);
}}
className="relative inline-flex items-center gap-1.5 overflow-hidden rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition-colors outline-none hover:bg-slate-800 focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white dark:focus-visible:ring-slate-100 dark:focus-visible:ring-offset-slate-950"
>
{isLast ? (
<span
aria-hidden="true"
className="obsl-sheen pointer-events-none absolute inset-y-0 w-12 bg-gradient-to-r from-transparent via-white/30 to-transparent dark:via-slate-900/20"
/>
) : null}
<span className="relative">{isLast ? "Create workspace" : "Continue"}</span>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="relative h-4 w-4">
<path d="M8 4.5 13.5 10 8 15.5" />
</svg>
</button>
</div>
</div>
<p aria-live="polite" className="sr-only">
{`Step ${index + 1} of ${total}: ${slide.title}`}
</p>
<p className="mt-4 text-center text-xs text-slate-500 dark:text-slate-400">
{done
? "You have seen every step — jump back to any of them from the list above."
: "Use the arrow keys on the step list to move between slides."}
</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 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 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.

