@codyzk/onboarding
v0.0.3
Published
**JSON-driven, component-based onboarding flows for React Native apps.**
Readme
@codyzk/onboarding
JSON-driven, component-based onboarding flows for React Native apps.
Build flexible, beautiful onboarding experiences with:
- 🎨 Token-based design system - Centralized colors, spacing, typography
- 📦 Component array system - Arrange UI elements in any order
- 🎯 Type-safe - Full TypeScript + Zod validation
- 🔌 Pluggable - Custom adapters for permissions and ratings
- 📱 Cross-platform - Works on iOS, Android, and Web
Installation
npm install @codyzk/onboarding
# or
pnpm add @codyzk/onboardingQuick Start
import { Onboarding } from "@codyzk/onboarding";
import flow from "./flows/onboarding.json";
function App() {
return (
<Onboarding
flow={flow}
theme="auto" // "light", "dark", or "auto" (follows system)
onEnd={(result) => {
// Navigate based on result.dest: "paywall", "home", or "custom"
if (result.dest === "paywall") {
navigation.navigate("Paywall");
} else {
navigation.navigate("Home");
}
}}
analytics={{
screen: (node) => {
analytics.track("Onboarding Screen Viewed", {
nodeId: node.id,
type: node.type,
});
},
}}
/>
);
}Component Array System v1
The new component system provides complete flexibility over your onboarding UI:
{
"id": "welcome_flow",
"version": 1,
"start": "welcome",
"nodes": {
"welcome": {
"type": "screen",
"id": "welcome",
"schemaVersion": 1,
"tokens": {
"colors": {
"brand": "#4F46E5",
"text": "#111827"
},
"spacing": {
"md": 16,
"lg": 24
},
"typography": {
"title": { "size": 28, "weight": 700 }
}
},
"components": [
{
"id": "root",
"type": "box",
"style": { "padding": "lg", "gap": "md" },
"children": [
{
"id": "title",
"type": "text",
"content": "Welcome!",
"typography": { "preset": "title" },
"style": { "color": "brand" }
},
{
"id": "cta",
"type": "button",
"label": "Get Started",
"action": { "kind": "next", "to": "next_screen" },
"style": { "backgroundColor": "brand" }
}
]
}
]
}
}
}Key Features:
- Tokens - Define colors, spacing, radii, typography once
- Components - Text, image, button, box, spacer, divider
- Flexible layouts - Nested boxes with flexbox styling
- Actions - Button actions for navigation, links, events
📖 Read the Implementation Guide for detailed documentation.
API Reference
<Onboarding /> Props
| Prop | Type | Required | Default | Description |
| ------------------- | ------------------------------------------------------ | -------------------- | ----------------- | -------------------------------------------------------------------------------------------- |
| flow | Flow | Record<string, unknown> | One of flow/flowPath | - | Inline flow object (e.g., import flow from "./flows/default.json") |
| flowPath | string | One of flow/flowPath | - | Path to a flow JSON file. Useful in Node/web builds where dynamic require is available. |
| onEnd | (result) => void | No | - | Callback when flow ends. Receives { dest: "home" \| "paywall" \| "custom", payload?: any } |
| theme | "light" \| "dark" \| "auto" | No | "auto" | Theme mode. "auto" follows system appearance |
| permissionAdapter | PermissionAdapter | No | Stub | Custom permission adapter (default uses in-memory stub) |
| ratingAdapter | RatingAdapter | No | Noop | Custom rating adapter (default is no-op) |
| linkOpener | { open: (url) => Promise<void> } | No | Linking.openURL | Custom link/deep link opener |
| analytics | { screen?: (node) => void, end?: (payload) => void } | No | - | Analytics callbacks for tracking |
Example with All Props
import { Onboarding } from "@codyzk/onboarding
";
import flow from "./flows/onboarding.json";
function App() {
return (
<Onboarding
flow={flow}
theme="auto"
onEnd={(result) => {
console.log("Onboarding ended:", result);
if (result.dest === "paywall") {
navigation.navigate("Paywall", result.payload);
} else {
navigation.navigate("Home");
}
}}
permissionAdapter={customPermissionAdapter}
ratingAdapter={customRatingAdapter}
linkOpener={{
open: async (url) => {
// Custom deep link handling
await Linking.openURL(url);
},
}}
analytics={{
screen: (node) => {
analytics.track("Screen Viewed", {
screenId: node.id,
screenType: node.type,
});
},
end: (payload) => {
analytics.track("Onboarding Completed", payload);
},
}}
/>
);
}Theming
The SDK supports light, dark, and auto theming that matches your host app.
Theme Modes
// Auto mode - follows system appearance (default)
<Onboarding theme="auto" ... />
// Force light mode
<Onboarding theme="light" ... />
// Force dark mode
<Onboarding theme="dark" ... />Per-Flow Theme Override
Set a default theme in your flow JSON:
{
"id": "onboarding_v1",
"version": 1,
"start": "welcome",
"theme": "dark",
"nodes": { ... }
}Priority: Component prop > Flow JSON > "auto" (system default)
Using Theme in Custom Components
import { useTheme } from "@codyzk/onboarding
";
function CustomComponent() {
const theme = useTheme();
return (
<View style={{ backgroundColor: theme.color.surface }}>
<Text style={{ color: theme.color.text }}>Custom Content</Text>
</View>
);
}Permissions
The SDK includes a pluggable permission adapter system that works without native dependencies for MVP testing.
Default Behavior (Stub Adapter)
By default, the SDK uses an in-memory stub that returns deterministic results:
import {
StubPermissionAdapter,
setPermissionAdapter,
} from "@codyzk/onboarding
";
// Configure stub with default outcomes
const adapter = new StubPermissionAdapter({
camera: "denied",
location: "granted",
photoLibrary: "denied",
});
setPermissionAdapter(adapter);
// Later in dev/testing, change outcomes without rebuild
adapter.set("camera", "granted");Permission Types
Supported permission types:
cameramicrophonelocationphotoLibrarycontactscalendarremindersspeechmotionbluetoothnotification
Permission Status
Each permission can have one of three states:
granted- User has granted permissiondenied- User has not granted or explicitly deniedblocked- User has permanently denied (requires system settings)
Using in Flows
Define permission nodes in your flow JSON with optional customization:
{
"p1": {
"type": "permission",
"id": "p1",
"permission": "camera",
"title": "Camera Access Required",
"rationale": "We use your camera to scan documents and create quick posts.",
"allowLabel": "Enable Camera",
"notNowLabel": "Maybe Later",
"onGranted": "next_screen",
"onDenied": "skip_camera_features"
}
}Optional Fields:
title- Screen title (default: "Permission required")rationale- Explanation text (default: "We need access to continue.")allowLabel- Primary button text (default: "Allow")notNowLabel- Secondary button text (default: "Not now")
Custom Adapter (Production)
For production, implement the PermissionAdapter interface:
import { PermissionAdapter, setPermissionAdapter } from "@codyzk/onboarding
";
import {
check,
request,
openSettings,
PERMISSIONS,
} from "react-native-permissions";
class NativePermissionAdapter implements PermissionAdapter {
async check(type: PermissionType): Promise<PermissionStatus> {
const result = await check(PERMISSIONS.IOS[type.toUpperCase()]);
return this.mapStatus(result);
}
async request(type: PermissionType): Promise<PermissionStatus> {
const result = await request(PERMISSIONS.IOS[type.toUpperCase()]);
return this.mapStatus(result);
}
async openSettings(): Promise<void> {
await openSettings();
}
private mapStatus(status: string): PermissionStatus {
if (status === "granted") return "granted";
if (status === "blocked") return "blocked";
return "denied";
}
}
// Swap in production adapter at app startup
setPermissionAdapter(new NativePermissionAdapter());Development Panel
For testing different permission flows, create a dev panel:
import {
StubPermissionAdapter,
setPermissionAdapter,
} from "@codyzk/onboarding
";
// Initialize stub
const adapter = new StubPermissionAdapter();
setPermissionAdapter(adapter);
// Dev UI to toggle outcomes
function PermissionDevPanel() {
return (
<View>
<Button onPress={() => adapter.set("camera", "granted")}>
Grant Camera
</Button>
<Button onPress={() => adapter.set("camera", "denied")}>
Deny Camera
</Button>
<Button onPress={() => adapter.set("location", "granted")}>
Grant Location
</Button>
</View>
);
}Edge Cases
- Unknown permission type: Treated as
denied(won't crash) - Missing
onGrantedoronDenied: Flow ends to prevent getting stuck - Adapter throws: Treated as
deniedto avoid blocking user blockedstatus: Routes toonDeniedfor MVP (later can show settings prompt)
Rating / Review Requests
The SDK includes a pluggable rating adapter for in-app review prompts.
Default Behavior (No-op Adapter)
By default, the SDK uses a no-op adapter that resolves immediately without showing any UI:
import { NoopRatingAdapter, setRatingAdapter } from "@codyzk/onboarding
";
// Default behavior - no-op for MVP
// Nothing happens when review is requestedUsing in Flows
Define rate nodes with two trigger modes:
Immediate Mode (auto-trigger on node):
{
"rate1": {
"type": "rate",
"id": "rate1",
"trigger": "immediate",
"next": "next_screen"
}
}After-Next Mode (show button):
{
"rate2": {
"type": "rate",
"id": "rate2",
"trigger": "after_next",
"next": "next_screen"
}
}Custom Adapter (Production)
For production, implement the RatingAdapter interface:
import { RatingAdapter, setRatingAdapter } from "@codyzk/onboarding
";
import { requestReview } from "react-native-store-review";
class NativeRatingAdapter implements RatingAdapter {
async requestReview(): Promise<void> {
try {
// iOS: StoreReviewController.requestReview()
// Android: Play In-App Review API
await requestReview();
} catch (error) {
// Swallow errors - never block flow
console.error("Review request failed:", error);
}
}
}
// Set at app startup
setRatingAdapter(new NativeRatingAdapter());Timeout Protection
The rate node automatically times out after 2.5 seconds to prevent blocking:
- If the adapter takes too long, flow continues anyway
- User is never stuck waiting
- Ensures smooth progression
Edge Cases
- No adapter provided: Falls back to NoopRatingAdapter (no-op)
- Adapter throws: Error caught, flow continues to next
- Timeout: After 2.5s, flow continues regardless
- Multiple calls: Safe to call multiple times (idempotency handled by adapter)
Flow JSON Schema
Screen Nodes (Component System v1)
Screen nodes use the new component array system with tokens and flexible layouts:
{
type: "screen",
id: string,
schemaVersion: 1,
tokens: {
colors: Record<string, string>, // e.g., { "brand": "#4F46E5" }
spacing: Record<string, number>, // e.g., { "md": 16 }
radii: Record<string, number>, // e.g., { "md": 12 }
typography: Record<string, { // e.g., { "title": { size: 28, weight: 700 } }
size: number,
lineHeight?: number,
weight?: number,
letterSpacing?: number
}>
},
components: Component[], // Array of UI components
next?: string, // Optional next node reference
tags?: string[] // Optional tags for analytics
}Component Types
Box (Container)
{
"type": "box",
"id": "unique-id",
"style": {
"flex": 1,
"flexDirection": "column",
"gap": "md",
"padding": "lg",
"justifyContent": "center",
"alignItems": "center"
},
"children": [
/* nested components */
]
}Text
{
"type": "text",
"id": "unique-id",
"content": "Your text here",
"typography": {
"preset": "title", // or direct values
"size": 24,
"weight": 600
},
"style": {
"color": "text",
"textAlign": "center"
}
}Button
{
"type": "button",
"id": "unique-id",
"label": "Button Text",
"action": {
"kind": "next",
"to": "next-node-id"
},
"style": {
"backgroundColor": "brand",
"padding": "md",
"borderRadius": "md",
"color": "#FFFFFF"
},
"a11yLabel": "Accessibility label"
}Image
{
"type": "image",
"id": "unique-id",
"url": "https://example.com/image.png",
"aspectRatio": 1.6,
"resizeMode": "cover",
"style": {
"borderRadius": "md"
}
}Spacer
{
"type": "spacer",
"id": "unique-id",
"size": "lg" // or numeric: 24
}Divider
{
"type": "divider",
"id": "unique-id",
"style": {
"backgroundColor": "surface",
"height": 1
}
}Other Node Types
Permission Node
{
"type": "permission",
"id": "unique-id",
"permission": "camera",
"title": "Camera Access",
"rationale": "We need camera access to scan documents",
"allowLabel": "Enable Camera",
"notNowLabel": "Maybe Later",
"onGranted": "next-node-id",
"onDenied": "skip-node-id"
}Rate Node
{
"type": "rate",
"id": "unique-id",
"trigger": "immediate", // or "after_next"
"next": "next-node-id"
}End Node
{
"type": "end",
"id": "unique-id",
"result": "home", // or "paywall" | "custom"
"payload": {
"completed": true,
"any": "custom data"
}
}Examples
See example flows in the flows/ directory:
component-system.json- Full featured example with tokens and layoutsREADME.md- Migration guide and detailed documentation
Documentation
- 📖 Implementation Guide - Complete guide to building flows
- 🎨 Token System - Design tokens and theming
- 🧩 Component Reference - All available components
- 🔌 Adapters - Custom permissions and ratings
License
MIT
