██████╗ ███╗ ██╗██████╗ ██████╗ █████╗ ██████╗ ██████╗ ██╗███╗ ██╗ ██████╗
██╔═══██╗████╗ ██║██╔══██╗██╔═══██╗██╔══██╗██╔══██╗██╔══██╗██║████╗ ██║██╔════╝
██║ ██║██╔██╗ ██║██████╔╝██║ ██║███████║██████╔╝██║ ██║██║██╔██╗ ██║██║ ███╗
██║ ██║██║╚██╗██║██╔══██╗██║ ██║██╔══██║██╔══██╗██║ ██║██║██║╚██╗██║██║ ██║
╚██████╔╝██║ ╚████║██████╔╝╚██████╔╝██║ ██║██║ ██║██████╔╝██║██║ ╚████║╚██████╔╝
╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝
████████╗ ██████╗ ██████╗ ██╗ ███████╗
╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██╔════╝
██║ ██║ ██║██║ ██║██║ ███████╗
██║ ██║ ██║██║ ██║██║ ╚════██║
██║ ╚██████╔╝╚██████╔╝███████╗███████║
╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝
Declarative unlockable onboarding, progressive disclosure, and tutorial overlays for any React app.
Tag a component as Unlockable. Describe when it should appear.
The state machine, persistence, animations, and tutorial overlay are handled for you.
Quickstart · Examples · Concepts · Theming · Frameworks · API · For AI agents · Contributing
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █▄░█ █▀█ ▀▄▀ █░█ █▄█ ▀█▀ █░█ █ █▀ ║
║ █░▀█ █▄█ █░█ █▀█ ░█░ ░█░ █▀█ █ ▄█ E X I S T S ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
Most onboarding libraries assume a linear product tour: step 1, step 2, step 3, done. Real products are not linear. Features should appear when the user is ready for them — after a personality test, after they ship their first artifact, after an AI clusters them into an archetype, or after a feature flag flips.
onboarding-tools flips the model:
- A separate "tour" component that points at things
- A scripted, ordered list of steps
- Hard-coded conditions baked into your routes
+ Each feature declares **its own** unlock criteria
+ Components stay hidden until those criteria are met
+ A state machine handles the transition, animation, and overlay
+ AI / archetype / flag-driven personalisation is a first-class API It is the React equivalent of slapping a @Unlockable("builder")
annotation on a Java component and letting the runtime decide who sees it.
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █▀▀ █▀▀ ▄▀█ ▀█▀ █░█ █▀█ █▀▀ █▀ ║
║ █▀░ ██▄ █▀█ ░█░ █▄█ █▀▄ ██▄ ▄█ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
- Zero runtime dependencies. Only React as an optional peer.
No
react-router, noframer-motion, noclsx, no Vite globals. - Framework-agnostic. Vite, Next.js, Remix, CRA, Webpack — anything that runs React. Router and storage are pluggable adapters.
- Type-safe by default. Strict TypeScript. Public types live in a single
core/types.ts. - Three install surfaces. Use
/corestandalone (pure logic, even from Vue/Svelte),/reactfor the components,/testingfor unit tests. - Persistent state machine.
HIDDEN → ELIGIBLE → UNLOCKING → UNLOCKEDwith a serializable, append-onlylocalStoragelog. - Declarative criteria DSL. Combine
event,archetype,flag,state,unlockable,resolverwithall/any/not. - AI-ready resolver hook. Plug in any function (local heuristic, remote
LLM, classifier) that returns
UnlockDecision[]. - Tutorial overlay. Spotlight + coach card with reduced-motion support, fallback positioning, and full theming via CSS variables.
- Flow derivation. Topological sort with cycle detection so you know what stage the user is on without writing a state diagram.
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █ █▄░█ █▀ ▀█▀ ▄▀█ █░░ █░░ ║
║ █ █░▀█ ▄█ ░█░ █▀█ █▄▄ █▄▄ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
npm install onboarding-tools
# or
pnpm add onboarding-tools
# or
yarn add onboarding-toolsImport the default styles once in your app root (e.g. main.tsx /
_app.tsx / layout.tsx):
import 'onboarding-tools/styles.css';Override CSS custom properties to re-skin without forking — see Theming.
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █▀█ █░█ █ █▀▀ █▄▀ █▀ ▀█▀ ▄▀█ █▀█ ▀█▀ ║
║ ▀▀█ █▄█ █ █▄▄ █░█ ▄█ ░█░ █▀█ █▀▄ ░█░ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
Define your unlockable catalog:
import 'onboarding-tools/styles.css';
import type { UnlockableDefinition } from 'onboarding-tools';
import {
Unlockable,
UnlockableCatalogRegistrar,
UnlockableFlowProvider,
UnlockableProvider,
UnlockableTutorialEngineProvider,
} from 'onboarding-tools/react';
const definitions: UnlockableDefinition[] = [
{
id: 'profile',
activation: 'automatic',
meta: { title: 'Profile', description: 'Complete your profile first.' },
flow: { stage: 'Profile', order: 10, completionEvent: 'profile.completed' },
},
{
id: 'dashboard',
activation: 'manual',
visibility: 'hidden',
meta: { title: 'Dashboard', description: 'Unlock the dashboard after setup.' },
unlocksOn: { kind: 'event', event: 'profile.completed' },
flow: { stage: 'Dashboard', order: 20, completionEvent: 'dashboard.opened' },
},
];
export function App() {
return (
<UnlockableProvider appId="demo">
<UnlockableCatalogRegistrar definitions={definitions} />
<UnlockableFlowProvider>
<UnlockableTutorialEngineProvider>
<Unlockable definition={definitions[1]}>
<Dashboard />
</Unlockable>
</UnlockableTutorialEngineProvider>
</UnlockableFlowProvider>
</UnlockableProvider>
);
}That's the whole API surface for a basic setup. The Dashboard component
stays out of the DOM until emitEvent('profile.completed') fires; then the
overlay confirms the unlock, plays the reveal animation, and the tutorial
engine narrates the new capability.
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █▀▀ ▀▄▀ ▄▀█ █▀▄▀█ █▀█ █░░ █▀▀ █▀ ║
║ ██▄ █░█ █▀█ █░▀░█ █▀▀ █▄▄ ██▄ ▄█ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
A runnable Next.js (App Router) demo lives in
examples/next. It wires the three main patterns —
automatic, event-gated, and archetype-gated — and is also the easiest
way to see the unlock animation, the manual confirmation overlay, and
the tutorial engine in action.
The deploy button targets examples/next as the project root. The
example's vercel.json builds the parent onboarding-tools package
before next build, so no extra setup is required.
Run it locally:
cd examples/next
npm install
npm run devThe example links the package via file:../.. and runs npm run build
on the parent through a predev / prebuild hook, so any edit in
src/ lands in the demo on the next dev-server start.
Why no Vite example? The only difference between Vite and Next.js here is the router adapter (~5 lines) and the
'use client'boundary. Maintaining two near-identical demos was redundant — the router-adapter pattern is documented under Router adapter for both stacks.
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █▀▀ █▀█ █▄░█ █▀▀ █▀▀ █▀█ ▀█▀ █▀ ║
║ █▄▄ █▄█ █░▀█ █▄▄ ██▄ █▀▀ ░█░ ▄█ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
┌──────────┐ criteria met ┌────────────┐ user/auto ┌────────────┐ effect done ┌────────────┐
│ HIDDEN │ ───────────────▶ │ ELIGIBLE │ ───────────▶ │ UNLOCKING │ ─────────────▶ │ UNLOCKED │
└──────────┘ └────────────┘ └────────────┘ └────────────┘
automaticactivation jumps straight fromELIGIBLEtoUNLOCKING.manualactivation waits forconfirmUnlock(id)(overlay button or tutorial step).UNLOCKEDis the terminal state. It is persisted to your storage adapter and survives reloads.
Every unlocksOn is a tree of criteria, combined with all / any / not:
{
unlocksOn: {
all: [
{ kind: 'event', event: 'onboarding.completed' },
{ any: [
{ kind: 'archetype', value: 'builder' },
{ kind: 'flag', key: 'beta.enabled', value: true },
]},
{ not: { kind: 'unlockable', id: 'legacy-flow', status: 'UNLOCKED' } },
],
},
}Available criteria kinds: event, archetype, flag, state, unlockable,
resolver.
Tag a definition with one or more archetypes and mark it autoAssignable:
{
id: 'cv-coach',
archetype: ['builder', 'storyteller'],
autoAssignable: true,
meta: { title: 'CV Coach', description: '…', tags: ['cv-management'] },
}After your AI / personality test resolves a user, push the result into the provider:
const { setUserArchetypes, addSignal } = useUnlockableSignals();
setUserArchetypes(['builder']); // from clustering output
addSignal('cv-management'); // from a feature-interest signalThe built-in createLocalUnlockableResolver matches archetypes against
meta.tags, meta.capability, and meta.audience. Replace it with any
custom UnlockResolver (e.g. an LLM call) by passing resolver to
UnlockableProvider.
When definitions declare flow.stage / flow.order / flow.completionEvent,
UnlockableFlowProvider derives a topologically-sorted onboarding graph
with cycle detection. Use useUnlockableFlow() to render progress UI or
gate routes with <UnlockableFlowRouteGate />.
UnlockableTutorialEngineProvider mounts a single coach overlay that:
- Spotlights a CSS selector (
tutorial.steps[].target). - Falls back to a centered card if the target is not on screen yet.
- Respects
prefers-reduced-motion. - Supports multi-step tutorials with
next/confirmUnlock/focusTarget/clickTargetactions.
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █▀█ █▀█ █░█ ▀█▀ █▀▀ █▀█ ▄▀█ █▀▄ ▄▀█ █▀█ ▀█▀ █▀▀ █▀█ ║
║ █▀▄ █▄█ █▄█ ░█░ ██▄ █▀▄ █▀█ █▄▀ █▀█ █▀▀ ░█░ ██▄ █▀▄ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
onboarding-tools does not bundle a router. Pass a tiny adapter to plug in
the one you already use:
// React Router v6
import { useLocation, useNavigate } from 'react-router-dom';
import type { OnboardingRouterAdapter } from 'onboarding-tools/react';
function useOnboardingRouter(): OnboardingRouterAdapter {
const location = useLocation();
const navigate = useNavigate();
return {
pathname: location.pathname,
navigate: (path, options) => navigate(path, { replace: options?.replace }),
};
}// Next.js App Router
'use client';
import { usePathname, useRouter } from 'next/navigation';
import type { OnboardingRouterAdapter } from 'onboarding-tools/react';
function useOnboardingRouter(): OnboardingRouterAdapter {
const pathname = usePathname() ?? '/';
const router = useRouter();
return {
pathname,
navigate: (path, options) =>
options?.replace ? router.replace(path) : router.push(path),
};
}Pass the adapter to UnlockableFlowProvider and
UnlockableTutorialEngineProvider.
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █▀ ▀█▀ █▀█ █▀█ ▄▀█ █▀▀ █▀▀ ▄▀█ █▀▄ ▄▀█ █▀█ ▀█▀ █▀▀ █▀█ ║
║ ▄█ ░█░ █▄█ █▀▄ █▀█ █▄█ ██▄ █▀█ █▄▀ █▀█ █▀▀ ░█░ ██▄ █▀▄ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
The default storage uses window.localStorage and is SSR-safe (gracefully
no-ops when window is undefined). Swap it for anything that satisfies
UnlockableStorageAdapter:
<UnlockableProvider
appId="demo"
storage={{
getItem: (key) => sessionStorage.getItem(key),
setItem: (key, value) => sessionStorage.setItem(key, value),
removeItem: (key) => sessionStorage.removeItem(key),
}}
/>For tests, import the in-memory adapter:
import { createMemoryStorage } from 'onboarding-tools/testing';
const storage = createMemoryStorage();╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ ▀█▀ █░█ █▀▀ █▀▄▀█ █ █▄░█ █▀▀ ║
║ ░█░ █▀█ ██▄ █░▀░█ █ █░▀█ █▄█ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
Override CSS custom properties anywhere in your stylesheet — no Sass, no runtime theme provider:
:root {
--ot-color-primary: #0f766e;
--ot-color-primary-soft: rgb(15 118 110 / 12%);
--ot-color-backdrop: rgb(2 6 23 / 65%);
--ot-radius-card: 20px;
--ot-shadow-card: 0 30px 90px rgb(15 23 42 / 22%);
--ot-z-overlay: 70;
--ot-z-tutorial: 80;
}Need deeper customisation? Pass a theme prop to UnlockableProvider:
<UnlockableProvider
theme={{
className: 'my-app-onboarding',
tokens: { '--ot-color-primary': '#0f766e' },
defaultEffect: { name: 'pulse', durationMs: 700 },
overlay: { kind: 'spotlight', primaryActionLabel: 'Show me' },
}}
/>╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █▀▀ █▀█ ▄▀█ █▀▄▀█ █▀▀ █░█░█ █▀█ █▀█ █▄▀ █▀ ║
║ █▀░ █▀▄ █▀█ █░▀░█ ██▄ ▀▄▀▄▀ █▄█ █▀▄ █░█ ▄█ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
| Host | Status | Notes |
|---|---|---|
| Vite + React | ✅ | Reference setup. Used in tests/ (jsdom). |
| Next.js (App Router) | ✅ | Wrap providers in a 'use client' boundary. SSR-safe storage. |
| Next.js (Pages Router) | ✅ | Same — providers go in _app.tsx. |
| Remix | ✅ | Mount providers in app/root.tsx inside the client tree. |
| Create React App | ✅ | Works as-is. |
| Webpack / Rspack | ✅ | ESM output; sideEffects declared for styles.css. |
| Bun / esbuild | ✅ | No CJS pitfalls. |
| React Native | core/* works. The DOM overlay needs a custom renderer. |
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ ▄▀█ █▀█ █ █▀█ █▀▀ █▀▀ █▀▀ █▀█ █▀▀ █▄░█ █▀▀ █▀▀ ║
║ █▀█ █▀▀ █ █▀▄ ██▄ █▀░ ██▄ █▀▄ ██▄ █░▀█ █▄▄ ██▄ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
| Surface | Where it lives |
|---|---|
Types (UnlockableDefinition, …) |
onboarding-tools (root) |
| Pure logic (state, criteria, flow, …) | onboarding-tools/core |
| React bindings, hooks, components | onboarding-tools/react |
| In-memory storage for tests | onboarding-tools/testing |
| Stylesheet | onboarding-tools/styles.css |
Key exports from onboarding-tools/react:
| Export | Purpose |
|---|---|
<UnlockableProvider> |
Root provider. Owns state, storage, theme. |
<UnlockableCatalogRegistrar> |
Registers definitions on mount. |
<UnlockableFlowProvider> |
Derives the onboarding flow graph. |
<UnlockableFlowRouteGate> |
Gates a route by stage completion. |
<UnlockableTutorialEngineProvider> |
Mounts the tutorial overlay engine. |
<Unlockable> |
Wraps a component as an unlock target. |
<UnlockableOverlay> |
Standalone unlock confirmation overlay. |
useUnlockable(id) |
Status + control for a single unlockable. |
useUnlockableCatalog() |
Serializable catalog (for AI / analytics). |
useUnlockableSignals() |
Archetypes, signals, flags. |
useUnlockableEvents() |
Emit / read domain events. |
useUnlockableFlow() |
Stages, active stage, completion lookup. |
Full type signatures live in src/core/types.ts (shipped as .d.ts).
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █▀█ █▀█ ▄▀█ █▀▄ █▀▄▀█ ▄▀█ █▀█ ║
║ █▀▄ █▄█ █▀█ █▄▀ █░▀░█ █▀█ █▀▀ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
- React Native renderer for the tutorial overlay.
- Devtools panel: visualise catalog, criteria, decisions live.
- First-class adapter for TanStack Router and Wouter.
- Headless overlay primitives so users can ship their own UI.
- Built-in resolver helpers for OpenAI / Anthropic classification.
- Storybook with every state and animation pinned.
Have an idea? Open a discussion
or read CONTRIBUTING.md.
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █▀▀ █▀█ █▄░█ ▀█▀ █▀█ █ █▄▄ █░█ ▀█▀ █ █▄░█ █▀▀ ║
║ █▄▄ █▄█ █░▀█ ░█░ █▀▄ █ █▄█ █▄█ ░█░ █ █░▀█ █▄█ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
PRs welcome. Read CONTRIBUTING.md for the local
setup, conventions, and release process. Be kind — we follow the
Contributor Covenant.
Security issues: see SECURITY.md.
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ █░░ █ █▀▀ █▀▀ █▄░█ █▀ █▀▀ ║
║ █▄▄ █ █▄▄ ██▄ █░▀█ ▄█ ██▄ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
MIT © 2026 Carlos and onboarding-tools contributors.
Built for products that grow with the user, not against them.