Skip to content
UI Kit
Accent color

AI onboarding

The first minutes with an AI assistant. What it can do with an example that types itself, a first task to start with, the data sources to connect with what each one reads, and an honest page on limits and promises.

One of 3 components in AI setup

4 variants

Variants

What it can do

Abilities as tabs, each with a request that types itself and the result that follows. Arrow keys move between them, phones swipe the row.

Open in Studio

First task

A handful of tasks that work right away, each with time and what it needs, or a task in your own words. Starting opens the chat with it.

Open in Studio

Connect sources

Data sources with what the assistant would read from each, connected one by one with a check that springs in, and a counter in the footer.

Open in Studio

Limits and promises

Before the first chat, what the assistant is good at, what it is not made for and three promises about data stand honestly side by side.

Open in Studio

Details

What AI onboarding brings along, read from the registry and the source.

Category
AI setup
Area
App
Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/ai-onboarding.tsx
Brings along
utilskiticonrevealmotion
npm packages
None
Languages
English and German, 21 texts of its own

Props

The AiOnboardingProps interface from components/ui/ai-onboarding.tsx, with the comments from the code.

  • variantoptional
    AiOnboardingVariant

    capabilitiesfirst-taskconnect-sourceslimits-promise

  • nameoptional
    string

    The assistant's name

  • titleoptional
    string
  • descriptionoptional
    string
  • capabilitiesoptional
    AiCapability[]
  • tasksoptional
    AiStarterTask[]
  • sourcesoptional
    AiSource[]
  • canoptional
    string[]

    limits-promise: what it does well and what it does not

  • cannotoptional
    string[]
  • promisesoptional
    AiPromiseItem[]
  • stepoptional
    { current: number; total: number }

    Step shown in the progress line, e.g. { current: 2, total: 3 }

  • onNextoptional
    () => void

    Continue, the step is marked done and the next segment fills

  • onBackoptional
    () => void

    Back and Skip only show when they are handled

  • onSkipoptional
    () => void
  • onStartoptional
    (task: string) => void

    first-task: the chosen task or the own words

Built-in texts

AI onboarding writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 21 texts
stepEnglish Step {n} of {total}German Schritt {n} von {total}
nextEnglish ContinueGerman Weiter
backEnglish BackGerman Zurück
skipEnglish Skip for nowGerman Später
requestEnglish You askGerman Du fragst
resultEnglish ResultGerman Ergebnis
startEnglish Start with thisGerman Damit starten
startedEnglish Your first chat starts with this task.German Dein erster Chat startet mit dieser Aufgabe.
stepDoneEnglish Step {n} doneGerman Schritt {n} erledigt
needsEnglish NeedsGerman Braucht
connectEnglish ConnectGerman Verbinden
connectedEnglish ConnectedGerman Verbunden
readsEnglish ReadsGerman Liest
laterEnglish You can connect more later in settings.German Weitere Quellen kannst du später in den Einstellungen verbinden.
countEnglish {n} of {total} connectedGerman {n} von {total} verbunden
canEnglish Good atGerman Gut darin
cannotEnglish Not made forGerman Nicht gemacht für
promiseEnglish Our promiseGerman Unser Versprechen
agreeEnglish Understood, start nowGerman Verstanden, jetzt starten
customEnglish Or describe your own taskGerman Oder beschreib deine eigene Aufgabe
customPlaceholderEnglish Plan the team day in NovemberGerman Plane den Teamtag im November

Accessibility

Read from the source of the component.

  • AI onboarding handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-checked, aria-label, aria-live, aria-orientation, aria-selected, aria-valuemax, aria-valuemin and aria-valuenow, so screen readers announce purpose and state.
  • Uses the roles progressbar, radio, radiogroup, status, tab, tablist and tabpanel.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 570 lines
components/ui/ai-onboarding.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollFade } from '@/components/ui/motion';

export type AiOnboardingVariant = 'capabilities' | 'first-task' | 'connect-sources' | 'limits-promise';

export interface AiCapability {
  id: string;
  title: string;
  description: string;
  icon?: IconName;
  /** A sample request */
  prompt: string;
  /** The sample result, shown when the capability is picked */
  result: string;
}

export interface AiStarterTask {
  id: string;
  label: string;
  icon?: IconName;
  /** About how long it takes, e.g. "1 min" */
  time?: string;
  /** What the assistant asks for to start */
  needs?: string;
}

export interface AiSource {
  id: string;
  name: string;
  icon?: IconName;
  description: string;
  /** What the assistant reads */
  reads: string;
  connected?: boolean;
}

export interface AiPromiseItem {
  title: string;
  description: string;
  icon?: IconName;
}

export interface AiOnboardingProps {
  variant?: AiOnboardingVariant;
  /** The assistant's name */
  name?: string;
  title?: string;
  description?: string;
  capabilities?: AiCapability[];
  tasks?: AiStarterTask[];
  sources?: AiSource[];
  /** limits-promise: what it does well and what it does not */
  can?: string[];
  cannot?: string[];
  promises?: AiPromiseItem[];
  /** Step shown in the progress line, e.g. { current: 2, total: 3 } */
  step?: { current: number; total: number };
  /** Continue, the step is marked done and the next segment fills */
  onNext?: () => void;
  /** Back and Skip only show when they are handled */
  onBack?: () => void;
  onSkip?: () => void;
  /** first-task: the chosen task or the own words */
  onStart?: (task: string) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    step: 'Step {n} of {total}',
    next: 'Continue',
    back: 'Back',
    skip: 'Skip for now',
    
    
    request: 'You ask',
    result: 'Result',
    start: 'Start with this',
    started: 'Your first chat starts with this task.',
    stepDone: 'Step {n} done',
    needs: 'Needs',
    connect: 'Connect',
    connected: 'Connected',
    reads: 'Reads',
    later: 'You can connect more later in settings.',
    count: '{n} of {total} connected',
    can: 'Good at',
    cannot: 'Not made for',
    promise: 'Our promise',
    agree: 'Understood, start now',
    custom: 'Or describe your own task',
    customPlaceholder: 'Plan the team day in November',
  },
  de: {
    step: 'Schritt {n} von {total}',
    next: 'Weiter',
    back: 'Zurück',
    skip: 'Später',
    
    
    request: 'Du fragst',
    result: 'Ergebnis',
    start: 'Damit starten',
    started: 'Dein erster Chat startet mit dieser Aufgabe.',
    stepDone: 'Schritt {n} erledigt',
    needs: 'Braucht',
    connect: 'Verbinden',
    connected: 'Verbunden',
    reads: 'Liest',
    later: 'Weitere Quellen kannst du später in den Einstellungen verbinden.',
    count: '{n} von {total} verbunden',
    can: 'Gut darin',
    cannot: 'Nicht gemacht für',
    promise: 'Unser Versprechen',
    agree: 'Verstanden, jetzt starten',
    custom: 'Oder beschreib deine eigene Aufgabe',
    customPlaceholder: 'Plane den Teamtag im November',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = AiOnboardingProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

const CSS =
  '@keyframes aob-up{from{opacity:0;transform:translateY(8px)}}@keyframes aob-swap{from{opacity:0;transform:translateY(6px);filter:blur(3px)}}@keyframes aob-check{from{transform:scale(0) rotate(-30deg)}}' +
  '@media (prefers-reduced-motion:no-preference){.aob-up{animation:aob-up .45s var(--ease-kit) var(--aob-d,0ms) both}.aob-swap{animation:aob-swap .5s var(--ease-kit) both}.aob-check{animation:aob-check .4s var(--ease-kit) both}}';

function Style() {
  return (
    <style href="kit-ai-onboarding" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

/** The onboarding window: progress line, heading, body and footer */
function Shell({ t, name, title, description, step, done, children, footer, className, wide }: { t: Texts; name?: string; title?: string; description?: string; step?: { current: number; total: number }; done?: boolean; children: React.ReactNode; footer?: React.ReactNode; className?: string; wide?: boolean }) {
  const filled = step ? Math.min(step.total, step.current + (done ? 1 : 0)) : 0;
  return (
    <section className={cn('kit-glow kit-dots relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <Reveal effect="scale">
          <div className={cn('mx-auto w-full overflow-hidden rounded-[24px] border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]', wide ? 'max-w-[60rem]' : 'max-w-[44rem]')}>
            {step && (
              <div className="flex gap-1.5 px-5 pt-5 sm:px-8 sm:pt-7" role="progressbar" aria-valuemin={1} aria-valuemax={step.total} aria-valuenow={filled} aria-label={fill(t.step, { n: step.current, total: step.total })}>
                {Array.from({ length: step.total }, (_, i) => (
                  <span key={i} className="h-1 flex-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
                    <span className={cn('block h-full origin-left rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)]', i < filled ? 'scale-x-100' : 'scale-x-0')} style={{ transitionDelay: `${i * 120}ms` }} />
                  </span>
                ))}
              </div>
            )}
            <div className="px-5 pb-2 pt-6 sm:px-8 sm:pt-7">
              <p className="flex items-center gap-2 text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">
                <span aria-hidden className="grid size-6 place-items-center rounded-full bg-primary text-primary-foreground">
                  <Icon name="sparkle" size={13} />
                </span>
                {name}
                {step && <span className="text-subtle-foreground">· {fill(t.step, { n: step.current, total: step.total })}</span>}
              </p>
              {title && <h2 className="mt-3 text-balance text-[24px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[30px]">{title}</h2>}
              {description && <p className="mt-2 max-w-[36rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
            </div>
            <div className="px-5 py-5 sm:px-8">{children}</div>
            {footer && <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border bg-surface/60 px-5 py-4 sm:px-8 [&>:only-child]:ml-auto">{footer}</div>}
          </div>
        </Reveal>
      </div>
    </section>
  );
}

const primaryBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg bg-primary px-5 text-[14px] max-sm:w-full font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground disabled:shadow-none sm:h-10';
const ghostBtn = 'inline-flex h-11 items-center justify-center rounded-lg px-3 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:h-10';

function Footer({ t, children, onBack, onSkip }: { t: Texts; children?: React.ReactNode; onBack?: () => void; onSkip?: () => void }) {
  return (
    <>
      {(onBack || onSkip) && (
      <span className="flex gap-1">
        {onBack && (
          <button type="button" onClick={onBack} className={ghostBtn}>
            <Icon name="arrow-left" size={15} className="sm:mr-1" />
            <span className="max-sm:sr-only">{t.back}</span>
          </button>
        )}
        {onSkip && (
          <button type="button" onClick={onSkip} className={ghostBtn}>
            {t.skip}
          </button>
        )}
      </span>
      )}
      {children}
    </>
  );
}

/** Continue, then the step reads as done */
function Next({ t, done, step, onClick, label }: { t: Texts; done: boolean; step?: { current: number; total: number }; onClick: () => void; label?: string }) {
  return done ? (
    <span role="status" className="aob-up inline-flex h-11 items-center gap-2 text-[14px] font-medium text-success-foreground sm:h-10">
      <Icon name="check-circle" size={16} />
      {fill(t.stepDone, { n: step?.current ?? 1 })}
    </span>
  ) : (
    <button type="button" onClick={onClick} className={primaryBtn}>
      {label ?? t.next}
      <Icon name="arrow-right" size={15} />
    </button>
  );
}

/* ------------------------------------------------------------------ */
/* capabilities                                                        */
/* ------------------------------------------------------------------ */

function Capabilities({ t, name, title, description, capabilities = [], step, onNext, onBack, onSkip, className }: P) {
  const [sel, setSel] = useState(0);
  const [typed, setTyped] = useState('');
  const [next, setNext] = useState(false);
  const [ref, inView] = useInView<HTMLDivElement>();
  // Types once when it first comes into view, not again on every return
  const [seen, setSeen] = useState(false);
  useEffect(() => {
    if (inView) setSeen(true);
  }, [inView]);
  const cur = capabilities[sel];
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const [row, fade] = useScrollFade<HTMLDivElement>();
  // The example request types itself, then the result appears
  useEffect(() => {
    if (!cur) return;
    if (!seen || prefersReducedMotion()) return setTyped(cur.prompt);
    setTyped('');
    let i = 0;
    const id = window.setInterval(() => {
      i += 2;
      setTyped(cur.prompt.slice(0, i));
      if (i >= cur.prompt.length) window.clearInterval(id);
    }, 22);
    return () => window.clearInterval(id);
  }, [cur, seen]);
  const done = typed.length >= (cur?.prompt.length ?? 0);
  return (
    <Shell t={t} name={name} title={title} description={description} step={step} done={next} className={className} wide footer={<Footer t={t} onSkip={onSkip} onBack={onBack}><Next t={t} done={next} step={step} onClick={() => (onNext?.(), setNext(true))} /></Footer>}>
      <div ref={ref} className="grid grid-cols-1 gap-4 md:grid-cols-[minmax(0,17rem)_minmax(0,1fr)] md:gap-6">
        <div ref={row} role="tablist" aria-orientation="vertical" aria-label={title} className={cn('kit-no-scrollbar -mx-5 flex gap-2 overflow-x-auto px-5 md:mx-0 md:flex-col md:overflow-visible md:px-0 md:[mask-image:none]', fade)}>
          {capabilities.map((c, i) => {
            const on = i === sel;
            return (
              <button
                key={c.id}
                ref={(el) => {
                  refs.current[i] = el;
                }}
                type="button"
                role="tab"
                aria-selected={on}
                tabIndex={on ? 0 : -1}
                onClick={() => setSel(i)}
                onKeyDown={(e) => {
                  const d = ['ArrowDown', 'ArrowRight'].includes(e.key) ? 1 : ['ArrowUp', 'ArrowLeft'].includes(e.key) ? -1 : 0;
                  if (!d) return;
                  e.preventDefault();
                  const n = (i + d + capabilities.length) % capabilities.length;
                  setSel(n);
                  refs.current[n]?.focus();
                }}
                className={cn('flex w-[15rem] shrink-0 items-start gap-3 rounded-xl border p-3 text-left transition-[border-color,background-color] duration-300 md:w-full', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border hover:bg-accent/60')}
              >
                <span aria-hidden className={cn('grid size-9 shrink-0 place-items-center rounded-lg transition-colors', on ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] text-muted-foreground')}>
                  <Icon name={c.icon ?? 'sparkle'} size={17} />
                </span>
                <span className="min-w-0">
                  <span className="block text-[14px] font-medium text-foreground">{c.title}</span>
                  <span className="mt-0.5 block text-[12.5px] leading-snug text-muted-foreground">{c.description}</span>
                </span>
              </button>
            );
          })}
        </div>
        {cur && (
          <div role="tabpanel" className="rounded-2xl border border-border bg-background/60 p-4 sm:p-5" aria-live="polite">
            <p className="text-[12px] font-medium text-subtle-foreground">{t.request}</p>
            <p className="mt-1.5 min-h-[3.25rem] rounded-xl bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] px-3.5 py-2.5 text-[14.5px] leading-relaxed text-foreground">
              {typed}
              {!done && <span aria-hidden className="ml-0.5 inline-block h-[1em] w-0.5 translate-y-[0.15em] animate-kit-pulse bg-primary" />}
            </p>
            <p className="mt-4 text-[12px] font-medium text-subtle-foreground">{t.result}</p>
            <div className="relative mt-1.5">
              <p aria-hidden className="invisible whitespace-pre-line text-[14.5px] leading-relaxed">{cur.result}</p>
              <p key={`${sel}-${done}`} className={cn('absolute inset-0 whitespace-pre-line text-pretty text-[14.5px] leading-relaxed text-foreground', done ? 'aob-swap' : 'opacity-0')}>
                {cur.result}
              </p>
            </div>
          </div>
        )}
      </div>
    </Shell>
  );
}

/* ------------------------------------------------------------------ */
/* first-task                                                          */
/* ------------------------------------------------------------------ */

function FirstTask({ t, name, title, description, tasks = [], step, onBack, onSkip, onStart, className }: P) {
  const [pick, setPick] = useState<string | null>(null);
  const [own, setOwn] = useState('');
  const [go, setGo] = useState(false);
  const chosen = tasks.find((x) => x.id === pick);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  return (
    <Shell
      t={t}
      name={name}
      title={title}
      description={description}
      step={step}
      done={go}
      className={className}
      footer={
        <Footer t={t} onBack={onBack} onSkip={onSkip}>
          {go ? (
            <span role="status" className="aob-up inline-flex items-center gap-2 text-[14px] font-medium text-success-foreground">
              <Icon name="check-circle" size={16} />
              {t.started}
            </span>
          ) : (
            <button type="button" disabled={!pick && !own.trim()} onClick={() => (onStart?.(chosen?.label ?? own.trim()), setGo(true))} className={primaryBtn}>
              {t.start}
              <Icon name="arrow-right" size={15} />
            </button>
          )}
        </Footer>
      }
    >
      <Stagger role="radiogroup" aria-label={title} step={70} className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
        {tasks.map((x, i) => {
          const on = pick === x.id;
          return (
            <button
              key={x.id}
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role="radio"
              aria-checked={on}
              tabIndex={on || (!pick && i === 0) ? 0 : -1}
              onClick={() => (setPick(x.id), setOwn(''), setGo(false))}
              onKeyDown={(e) => {
                const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
                if (!d) return;
                e.preventDefault();
                const n = (i + d + tasks.length) % tasks.length;
                setPick(tasks[n].id);
                setOwn('');
                setGo(false);
                refs.current[n]?.focus();
              }}
              className={cn('group flex h-full items-start gap-3 rounded-xl border p-3.5 text-left transition-[border-color,background-color,transform] duration-300 ease-[var(--ease-kit)]', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border hover:-translate-y-0.5 hover:border-border-strong motion-reduce:hover:translate-y-0')}>
              <span aria-hidden className={cn('grid size-9 shrink-0 place-items-center rounded-lg transition-colors', on ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] text-muted-foreground')}>
                <Icon name={on ? 'check' : x.icon ?? 'sparkle'} size={17} className={on ? 'aob-check' : undefined} />
              </span>
              <span className="min-w-0 flex-1">
                <span className="block text-[14px] font-medium leading-snug text-foreground">{x.label}</span>
                <span className="mt-1 flex flex-wrap gap-x-3 text-[12px] text-muted-foreground">
                  {x.time && (
                    <span className="inline-flex items-center gap-1">
                      <Icon name="clock" size={12} />
                      {x.time}
                    </span>
                  )}
                  {x.needs && (
                    <span>{`${t.needs} ${x.needs}`}</span>
                  )}
                </span>
              </span>
            </button>
          );
        })}
      </Stagger>
      <label className="mt-4 block">
        <span className="mb-1.5 block text-[13px] font-medium text-foreground">{t.custom}</span>
        <input value={own} onChange={(e) => (setOwn(e.target.value), setPick(null), setGo(false))} placeholder={t.customPlaceholder} className="h-11 w-full rounded-xl border border-border bg-background px-3.5 text-[14.5px] text-foreground outline-none placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] focus-visible:outline-none" />
      </label>
      {chosen && <span className="sr-only" aria-live="polite">{chosen.label}</span>}
    </Shell>
  );
}

/* ------------------------------------------------------------------ */
/* connect-sources                                                     */
/* ------------------------------------------------------------------ */

function ConnectSources({ t, name, title, description, sources = [], step, onNext, onBack, onSkip, className }: P) {
  const [on, setOn] = useState<Record<string, boolean>>(() => Object.fromEntries(sources.map((s) => [s.id, !!s.connected])));
  const [busy, setBusy] = useState<string | null>(null);
  const [next, setNext] = useState(false);
  const n = sources.filter((s) => on[s.id]).length;
  const connect = (id: string) => {
    setBusy(id);
    window.setTimeout(() => (setOn((o) => ({ ...o, [id]: true })), setBusy(null)), prefersReducedMotion() ? 0 : 900);
  };
  return (
    <Shell
      t={t}
      name={name}
      title={title}
      description={description}
      step={step}
      done={next}
      className={className}
      footer={
        <Footer t={t} onBack={onBack} onSkip={onSkip}>
          <Next t={t} done={next} step={step} onClick={() => (onNext?.(), setNext(true))} />
        </Footer>
      }
    >
      <Stagger as="ul" step={70} className="space-y-2.5">
        {sources.map((s) => {
          const c = on[s.id];
          return (
            <li key={s.id} className={cn('flex flex-col gap-3 rounded-xl border p-3.5 transition-[border-color,background-color] duration-500 sm:flex-row sm:items-center', c ? 'border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_5%,var(--card))]' : 'border-border')}>
              <div className="flex min-w-0 flex-1 items-start gap-3">
                <span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-xl border border-border bg-background text-foreground">
                  <Icon name={s.icon ?? 'plug'} size={19} />
                </span>
                <div className="min-w-0">
                  <p className="text-[14.5px] font-medium text-foreground">{s.name}</p>
                  <p className="text-[13px] leading-snug text-muted-foreground">{s.description}</p>
                  <p className="mt-1 flex items-start gap-1 text-[12px] text-subtle-foreground">
                    <Icon name="eye" size={12} className="mt-0.5 shrink-0" />
                    {`${t.reads} ${s.reads}`}
                  </p>
                </div>
              </div>
              {c ? (
                <span className="inline-flex h-11 shrink-0 items-center gap-1.5 self-start px-1 text-[13.5px] font-medium text-success-foreground sm:h-9 sm:self-auto">
                  <span className="aob-check grid size-5 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_18%,var(--card))] text-success-foreground">
                    <Icon name="check" size={12} strokeWidth={2.6} />
                  </span>
                  {t.connected}
                </span>
              ) : (
                <button type="button" disabled={busy === s.id} onClick={() => connect(s.id)} className="inline-flex h-11 shrink-0 items-center gap-1.5 self-start rounded-lg border border-border bg-card px-4 text-[13.5px] font-medium text-foreground hover:bg-accent disabled:opacity-70 sm:h-9 sm:self-auto">
                  <Icon name={busy === s.id ? 'loader' : 'plug'} size={15} className={busy === s.id ? 'animate-spin' : undefined} />
                  {t.connect}
                </button>
              )}
            </li>
          );
        })}
      </Stagger>
      <div className="mt-4 flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
        <p className="flex items-start gap-2 text-[12.5px] text-muted-foreground">
          <Icon name="info" size={14} className="mt-px shrink-0" />
          {t.later}
        </p>
        <p className="shrink-0 text-[12.5px] font-medium tabular-nums text-foreground" aria-live="polite">
          {fill(t.count, { n, total: sources.length })}
        </p>
      </div>
    </Shell>
  );
}

/* ------------------------------------------------------------------ */
/* limits-promise                                                      */
/* ------------------------------------------------------------------ */

function LimitsPromise({ t, name, title, description, can = [], cannot = [], promises = [], step, onNext, onBack, onSkip, className }: P) {
  const [ok, setOk] = useState(false);
  return (
    <Shell
      t={t}
      name={name}
      title={title}
      description={description}
      step={step}
      done={ok}
      className={className}
      wide
      footer={
        <Footer t={t} onBack={onBack} onSkip={onSkip}>
          <Next t={t} done={ok} step={step} label={t.agree} onClick={() => (onNext?.(), setOk(true))} />
        </Footer>
      }
    >
      <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
        {[
          { label: t.can, items: can, icon: 'check' as IconName, tone: 'success' },
          { label: t.cannot, items: cannot, icon: 'minus' as IconName, tone: 'muted' },
        ].map((col, ci) => (
          <Reveal key={col.label} delay={ci * 120} className="rounded-2xl border border-border bg-background/60 p-4 sm:p-5">
            <p className="text-[13px] font-semibold text-foreground">{col.label}</p>
            <ul className="mt-3 space-y-2.5">
              {col.items.map((x, i) => (
                <li key={x} className="aob-up flex items-start gap-2.5 text-[14px] leading-snug text-foreground" style={{ '--aob-d': `${200 + ci * 120 + i * 70}ms` } as React.CSSProperties}>
                  <span aria-hidden className={cn('mt-0.5 grid size-5 shrink-0 place-items-center rounded-full', col.tone === 'success' ? 'bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground')}>
                    <Icon name={col.icon} size={12} strokeWidth={2.4} />
                  </span>
                  {x}
                </li>
              ))}
            </ul>
          </Reveal>
        ))}
      </div>
      {promises.length > 0 && (
        <div className="mt-5">
          <p className="mb-3 text-[13px] font-semibold text-foreground">{t.promise}</p>
          <Stagger step={90} className="grid grid-cols-1 gap-px overflow-hidden rounded-2xl border border-border bg-border sm:grid-cols-3">
            {promises.map((p) => (
              <div key={p.title} className="bg-card p-4">
                <Icon name={p.icon ?? 'shield'} size={18} className="text-primary" />
                <p className="mt-2.5 text-[14px] font-medium text-foreground">{p.title}</p>
                <p className="mt-1 text-pretty text-[13px] leading-relaxed text-muted-foreground">{p.description}</p>
              </div>
            ))}
          </Stagger>
        </div>
      )}
    </Shell>
  );
}

/* ------------------------------------------------------------------ */

/**
 * Onboarding for an AI assistant: what it can do with an example that types
 * itself, a first task to start with, the data sources to connect with what
 * each one reads, and an honest page on limits and promises.
 */
export function AiOnboarding({ variant = 'capabilities', labels, ...props }: AiOnboardingProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const key = JSON.stringify([props.capabilities, props.tasks, props.sources]);
  const body = (() => {
    switch (variant) {
      case 'first-task':
        return <FirstTask key={key} {...p} />;
      case 'connect-sources':
        return <ConnectSources key={key} {...p} />;
      case 'limits-promise':
        return <LimitsPromise {...p} />;
      default:
        return <Capabilities key={key} {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Frequent questions

How do I install AI onboarding?

With aiw add @ai-website/ai-onboarding. The command puts components/ui/ai-onboarding.tsx into your project, the code is yours from then on. It brings along icon, reveal and motion. It needs no further npm packages.

Which variants does AI onboarding have?

4 variants, What it can do, First task, Connect sources and Limits and promises. You pick one with the variant prop, for example variant="first-task".

Can I translate the texts of AI onboarding?

Yes. AI onboarding carries 21 texts of its own in English and German, such as “Continue”, “Back” and “Skip for now”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with AI onboarding?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find AI onboarding with aiw_components and install it with aiw_add @ai-website/ai-onboarding. Name the variant in your prompt, for example variant="capabilities", and the assistant writes your content straight into the props.

Can I adjust AI onboarding without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching AiOnboarding code to copy.

Often searched

Jump to

Copied to the clipboard