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
Variants
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
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AiOnboardingProps interface from components/ui/ai-onboarding.tsx, with the comments from the code.
variantoptionalAiOnboardingVariantcapabilitiesfirst-taskconnect-sourceslimits-promise
nameoptionalstringThe assistant's name
titleoptionalstringdescriptionoptionalstringcapabilitiesoptionalAiCapability[]tasksoptionalAiStarterTask[]sourcesoptionalAiSource[]canoptionalstring[]limits-promise: what it does well and what it does not
cannotoptionalstring[]promisesoptionalAiPromiseItem[]stepoptional{ current: number; total: number }Step shown in the progress line, e.g. { current: 2, total: 3 }
onNextoptional() => voidContinue, the step is marked done and the next segment fills
onBackoptional() => voidBack and Skip only show when they are handled
onSkipoptional() => voidonStartoptional(task: string) => voidfirst-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
| Key | English | German |
|---|---|---|
| step | English Step {n} of {total} | German Schritt {n} von {total} |
| next | English Continue | German Weiter |
| back | English Back | German Zurück |
| skip | English Skip for now | German Später |
| request | English You ask | German Du fragst |
| result | English Result | German Ergebnis |
| start | English Start with this | German Damit starten |
| started | English Your first chat starts with this task. | German Dein erster Chat startet mit dieser Aufgabe. |
| stepDone | English Step {n} done | German Schritt {n} erledigt |
| needs | English Needs | German Braucht |
| connect | English Connect | German Verbinden |
| connected | English Connected | German Verbunden |
| reads | English Reads | German Liest |
| later | English You can connect more later in settings. | German Weitere Quellen kannst du später in den Einstellungen verbinden. |
| count | English {n} of {total} connected | German {n} von {total} verbunden |
| can | English Good at | German Gut darin |
| cannot | English Not made for | German Nicht gemacht für |
| promise | English Our promise | German Unser Versprechen |
| agree | English Understood, start now | German Verstanden, jetzt starten |
| custom | English Or describe your own task | German Oder beschreib deine eigene Aufgabe |
| customPlaceholder | English Plan the team day in November | German 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
'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.