Web InnoventixFreeCode

Onboarding Slides

Original · free

intro slides carousel

byWeb InnoventixReact + Tailwind
onboardslidesonboarding
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-slides.json
onboard-slides.tsx
"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

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 →