Page Types
The SDK includes pre-built renderers for common onboarding patterns.
Overview
Each page type is designed for a specific onboarding use case. All types share common properties and are customizable through theming, custom components, or custom renderers.
Available Page Types
Question
Interactive questions with single or multiple choice answers.
Use cases:
- User preferences
- Quiz-style onboarding
- Survey questions
- Profile building
Key features:
- Single or multiple selection
- Customizable answer buttons
- Theme-aware styling
- Support for custom components
Payload:
{
title: string;
subtitle?: string;
answers: Array<{
label: string;
value: string;
}>;
multipleAnswer: boolean;
}
Example:
{
id: "preferences",
type: "Question",
payload: {
title: "What are your goals?",
subtitle: "Select all that apply",
answers: [
{ label: "Build muscle", value: "muscle" },
{ label: "Lose weight", value: "weight" },
{ label: "Improve flexibility", value: "flexibility" },
],
multipleAnswer: true
}
}
Dependencies: None
MediaContent
Display images or videos with title and description.
Use cases:
- Welcome screens
- Feature explanations
- Visual onboarding
- Tutorial steps
Key features:
- Video, Image, Lottie, or Rive support
- Optional title and description
- Centered media display
- Responsive sizing
Payload:
{
title?: string;
description?: string;
media: {
type: "video" | "image" | "lottie" | "rive";
url?: string;
localPathId?: string;
};
}
Example:
{
id: "welcome",
type: "MediaContent",
payload: {
title: "Welcome to FitApp",
description: "Your personal fitness companion",
media: {
type: "image",
url: "https://example.com/welcome.png"
}
}
}
Dependencies: None
Carousel
Multi-slide horizontal pagination with page indicators.
Use cases:
- Feature tours
- Multi-step tutorials
- Swipeable content
- Progressive disclosure
Key features:
- Horizontal scrolling
- Page indicators (dots)
- Per-slide media and text
- Automatic "Next" / "Continue" button labels
Payload:
{
screens: Array<{
title?: string;
description?: string;
media?: MediaSource;
}>;
pagination?: {
show?: boolean; // default true
dotColor?: string; // default theme.colors.neutral.lower
activeDotColor?: string; // default theme.colors.primary
dotWidth?: number; // default 8
dotHeight?: number; // default 8
activeDotWidth?: number; // default 24
activeDotHeight?: number; // default 8
gap?: number; // default 8
position?: "top" | "bottom"; // default "bottom" — dots above or below content
marginTop?: number; // default 20
marginBottom?: number; // default 20
};
}
The pagination object is optional — omit it to keep the default dot styling. All
sub-fields are optional and individually defaulted, so you can override just the
ones you need (e.g. only activeDotColor and position).
Example:
{
id: "tour",
type: "Carousel",
payload: {
screens: [
{
title: "Track Your Progress",
description: "Monitor your fitness journey",
media: { type: "image", url: "https://..." }
},
{
title: "Set Goals",
description: "Define and achieve your targets",
media: { type: "image", url: "https://..." }
}
]
}
}
Dependencies: None
Picker
Type-specific input pickers for structured data.
Use cases:
- Collect specific user data
- Profile information
- Preferences with constrained options
Key features:
- Native picker UI
- Type-specific implementations
- Unit conversions (weight, height)
- Range validation
Supported types:
weight- Weight with kg/lb unitsheight- Height with cm/ft+in unitsage- Age pickergender- Gender selectionname- Name inputdate- Date picker
Payload:
{
title: string;
subtitle?: string;
pickerType: "weight" | "height" | "age" | "gender" | "name" | "date";
defaultValue?: string;
}
Example:
{
id: "user-weight",
type: "Picker",
payload: {
title: "What's your weight?",
pickerType: "weight",
defaultValue: "70-kg"
}
}
Dependencies: @react-native-picker/picker
npx expo install @react-native-picker/picker
Loader
Sequential progress animation with optional carousel.
Use cases:
- Loading states
- Processing animations
- Wait screens with progress
- Educational "Did you know?" moments
Key features:
- Sequential step animations
- Progress bars (0% → 100%)
- Checkmarks on completion
- Optional carousel with images
- Configurable duration
Payload:
{
title: string;
steps: Array<{
label: string; // Text while loading
completed: string; // Text when done
}>;
didYouKnowImages?: Array<MediaSource>;
duration?: number; // Milliseconds per step (default: 2000)
}
Example:
{
id: "analyzing",
type: "Loader",
payload: {
title: "Analyzing your profile",
steps: [
{
label: "Processing data",
completed: "Data processed"
},
{
label: "Building recommendations",
completed: "Recommendations ready"
}
],
duration: 2000
}
}
Dependencies: None
Ratings
App store rating prompts with social proof.
Use cases:
- Request app reviews
- Show user testimonials
- Build trust with social proof
- Increase app ratings
Key features:
- Star rating UI
- Opens native App Store review
- Social proof cards (optional)
- Customizable testimonials
Payload:
{
title: string;
subtitle?: string;
socialProofs?: Array<{
name: string;
rating: number;
comment: string;
avatar?: string;
}>;
}
Example:
{
id: "rate-us",
type: "Ratings",
payload: {
title: "Enjoying FitApp?",
subtitle: "Your feedback helps us improve!",
socialProofs: [
{
name: "Sarah M.",
rating: 5,
comment: "This app changed my life!",
}
]
}
}
Dependencies: expo-store-review
npx expo install expo-store-review
Commitment
User commitment and agreement screens.
Use cases:
- Terms acceptance
- Commitment statements
- Agreements
- Signature collection
Key features:
- Checkbox for agreement
- Optional signature (requires Skia)
- Info boxes for important notes
- Required acknowledgment before continuing
Payload:
{
title: string;
subtitle?: string;
commitmentText: string;
requireSignature?: boolean;
infoBoxes?: Array<{
title: string;
description: string;
icon?: string;
}>;
}
Example:
{
id: "terms",
type: "Commitment",
payload: {
title: "Terms of Service",
commitmentText: "I agree to the Terms of Service and Privacy Policy",
requireSignature: false,
infoBoxes: [
{
title: "Privacy First",
description: "We never share your data without permission"
}
]
}
}
Dependencies (signature only): @shopify/react-native-skia
npx expo install @shopify/react-native-skia
ComposableScreen
Build arbitrary onboarding screens entirely from CMS data — no custom renderer required. You define a tree of layout and media elements that is rendered directly to native components.
Use cases:
- Fully custom layouts without shipping app updates
- Cards, feature highlights, or rich text screens
- Animation-heavy screens (Lottie or Rive)
- Any screen that doesn't fit a pre-built type
Key features:
- Recursive element tree (
YStack,XStack,ZStack,SafeAreaView,Text,RichText,Image,Lottie,Rive,Icon,Video,Input,RadioGroup,CheckboxGroup,Button,DatePicker,WheelPicker,DrawingPad,Slider,ProgressIndicator,AnimatedText,TypewriterText,Carousel,Repeat) - Full flexbox layout support
- Border, radius, overflow, and opacity control
- Dimension constraints (
width,height,min/max) - Spacing with both
paddingandmargin - Text color defaults to
theme.colors.text.primary - Lottie, Rive, Video, DatePicker, WheelPicker, Slider, and DrawingPad as optional peer deps — graceful fallback if not installed (DrawingPad throws an explicit install error)
- Variable context —
Input,RadioGroup,CheckboxGroup,DatePicker,WheelPicker,Slider, andDrawingPadelements write into shared state;Textelements interpolate those values with{{variableName}}expressions - Conditional rendering — every UIElement accepts
renderWhen?: LeafCondition | ConditionGroup; the renderer skips the element and its subtree when the condition is false
Element types:
| Element | Renders as | Direction | Peer dep |
|---|---|---|---|
YStack | <View> | column | — |
XStack | <View> | row | — |
ZStack | <View> | depth (z-axis) | — |
SafeAreaView | <SafeAreaView> | column | react-native-safe-area-context |
Text | <Text> | — | — |
RichText | <View> wrapping Text children | wrapping row | — |
Image | <Image> | — | — |
ProgressiveBlurImage | <Image> + masked blurred image copy | depth (z-axis) | @react-native-masked-view/masked-view, expo-linear-gradient, expo-image (all optional) |
Icon | Lucide icon | — | — (bundled) |
Input | <TextInput> | — | — |
RadioGroup | Radio item list | — | — |
CheckboxGroup | Checkbox item list | — | — |
Button | <TouchableOpacity> | — | — |
Carousel | Horizontal slides | — | react-native-reanimated-carousel |
DatePicker | Native date/time picker | — | @react-native-community/datetimepicker |
WheelPicker | Native scrolling wheel selector | — | @react-native-picker/picker |
DrawingPad | Freehand draw/signature canvas | — | @shopify/react-native-skia |
Slider | Continuous numeric slider | — | @react-native-community/slider |
ProgressIndicator | Linear bar / circular ring | — | — |
AnimatedText | Count-up number (UI-thread, native TextInput) | — | — |
TypewriterText | Character-by-character text reveal (staggered entering) | — | — |
Repeat | Materializes one template per row of props.data; layout-transparent | — | — |
Lottie | LottieView | — | lottie-react-native |
Rive | <Rive> | — | rive-react-native |
Video | <VideoView> | — | expo-video |
Animations, transitions & effects (every element)
Every UIElement inherits an optional transform and animation from BaseBoxProps, so motion can be attached to any element type. The schema mirrors react-native-reanimated — preset values are the exact reanimated builder names and the modifier fields map to builder methods (.duration().delay().springify().easing()). react-native-reanimated >=3 is a peer dependency (already required for Carousel / ProgressIndicator); no extra install.
animation.entering.once — play the entrance exactly once per screen lifetime, on the first render where the element is visible.
Reach for it on anything gated by renderWhen. A false gate returns null, so renderWhen visibility is mount/unmount and reanimated fires entering on mount — meaning a gated element replays its entrance every time the gate flips back to true. There is no payload-level workaround: gte still unmounts when you move backwards past the threshold, and replayWhen is the exact opposite (it remounts on every change).
If that first visible render is the screen's initial mount, the play is deferred until the screen has settled rather than suppressed — an entrance that fires during the host navigator's push transition is half-consumed by it. Deferring keeps the animation; suppressing would have replaced a half-played entrance with none. The wait is a duration (default 350ms, overridable via the enteringSettleDelayMs prop on OnboardingPage, or on ScreenHost for a custom host) rather than a framework signal. InteractionManager.runAfterInteractions fails in two opposite ways — stubbed in RN 0.85+ so it fires immediately, and on earlier versions its queue reportedly does not drain while react-native-screens push transitions are active so it fires late or never — and nothing else is observable. Treat the default as a starting point: if an entrance still reads early, raise the delay before suspecting the mechanism. It therefore covers the entry transition but does not wait on remote images to decode — a cold, slow first load can still outrun it. Later visibility flips never replay, and once wins over replayWhen when both are set.
animation.replayWhen — a variable NAME. Re-fires entering whenever that variable's value changes, so an element re-animates without disappearing first (previously only toggling renderWhen could replay one). It works by remounting the element's subtree, so transient state inside resets and a continuous effect restarts — use it on presentational subtrees rather than around an Input. The initial mount is not a replay.
inset — declarative absolute placement, honoured on ZStack children only:
{ top?, left?, right?, bottom? } // number = px, string = percentage of the stack ("62.1%")
A ZStack wraps each child in an absolutely-positioned full-bleed box and applies one shared justifyContent/alignItems to all of them, so the 9 anchor points are otherwise the only declarative placement. inset replaces hand-computed transform.translateX/translateY for off-anchor layers.
An omitted side is not 0 — that axis falls back to the stack's shared anchor, so { top: 40 } pins vertically while still honouring the horizontal anchor. Supplying one side per axis (top + left) keeps the element content-sized and pinned by that corner; supplying both sides of an axis resolves to a size and stretches it. When an axis is positioned its shared anchor is dropped for that child, so placement is correct at every anchor, not just flex-start. Prefer percentages — px offsets authored against one screen width drift on others. Composes with transform; inert outside a ZStack.
transform — static, also the surface effect animates:
| Field | Type | Notes |
|---|---|---|
translateX / translateY | number | px offset |
scale / scaleX / scaleY | number | |
rotate | number | degrees |
animation — { entering?, exiting?, layout?, effect? }:
| Sub-field | Shape | Notes |
|---|---|---|
entering | { preset, duration?, delay?, easing?, spring? } | Plays once on mount |
exiting | { preset, duration?, delay?, easing?, spring? } | Plays on unmount (e.g. when renderWhen flips false) |
layout | { preset, duration?, spring? } | Animates re-layout when siblings appear/disappear |
effect | { preset, duration?, delay?, easing?, loop?, … } | Continuous loop (withRepeat) |
easing:"linear" \| "ease-in" \| "ease-out" \| "ease-in-out".spring({ damping?, stiffness?, mass? }) mirrors.springify(config)and wins overeasingwhen both are set.- Entering presets (reanimated builder names):
FadeIn(Up/Down/Left/Right),SlideIn(Up/Down/Left/Right),ZoomIn(Rotate/Up/Down/Left/Right/EasyUp/EasyDown),BounceIn(Up/Down/Left/Right),FlipIn(XUp/YLeft/XDown/YRight/EasyX/EasyY),StretchIn(X/Y),RotateIn(DownLeft/DownRight/UpLeft/UpRight),RollIn(Left/Right),PinwheelIn,LightSpeedIn(Left/Right). - Exiting presets: the
…Out…counterparts of the above. - Layout presets:
LinearTransition,FadingTransition,SequencedTransition,JumpingTransition,CurvedTransition,EntryExitTransition. - Effect presets (looping, not reanimated-named):
pulse(minScale/maxScale),fade(minOpacity),rotate(degrees),shimmer,bounce.loop: falseruns the effect once. - Unknown presets degrade to no-op (forward-compatible across reanimated versions). On web some entering presets degrade gracefully (content still mounts).
{
"id": "hero-badge",
"type": "Image",
"props": {
"url": "https://cdn.example.com/badge.png",
"width": 96,
"transform": { "rotate": -4 },
"animation": {
"entering": { "preset": "ZoomInDown", "duration": 600, "delay": 200, "spring": { "damping": 12, "stiffness": 180 } },
"exiting": { "preset": "FadeOut", "duration": 250 },
"effect": { "preset": "pulse", "duration": 1200, "minScale": 0.97, "maxScale": 1.06 }
}
}
}
onPress — make any element tappable (every element)
Every UIElement also inherits an optional onPress: ButtonAction[] from BaseBoxProps — the same action list as Button.actions ("continue", { type: "setVariable", … }, { type: "custom", … }, { type: "purchase", … }, { type: "restore", … }, { type: "dismiss" }, { type: "presentPaywall", … }, { type: "requestPermission", … }, run sequentially, "continue"/"dismiss" terminal; see the Button actions section below). Use it to make a static element react to a tap — a tappable card/Image, a logo that advances the flow, a Text row that fires a custom action.
{
"id": "plan-card",
"type": "YStack",
"props": {
"padding": 16,
"borderRadius": 12,
"onPress": [{ "type": "setVariable", "name": "plan", "value": "pro" }, "continue"]
},
"children": []
}
Runtime ignores onPress on elements that already own a tap / focus / scroll / drag gesture: Button (use actions), RadioGroup, CheckboxGroup, DatePicker, Input, WheelPicker, Slider. The schema still accepts it everywhere (it lives on BaseBoxProps), so wire selection / continue through those elements' own props instead.
Stack props (YStack / XStack):
| Prop | Type | Notes |
|---|---|---|
gap | number | Space between children |
padding / paddingHorizontal / paddingVertical | number | |
margin / marginHorizontal / marginVertical | number | |
flex / flexShrink / flexWrap | number | string | flexShrink defaults to 1 inside XStack |
alignItems | "flex-start" | "center" | "flex-end" | "stretch" | |
alignSelf | "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline" | |
justifyContent | "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | |
width / height / minWidth / maxWidth / minHeight / maxHeight | number | |
backgroundColor / borderColor | string | |
borderWidth / borderRadius | number | |
overflow | "hidden" | "visible" | "scroll" | Use "hidden" to clip rounded corners |
opacity | number |
ZStack props:
ZStack accepts the BaseBoxProps subset listed below, plus justifyContent / alignItems, which anchor each layer's content-sized box within the full-bleed stack (e.g. a floating bottom CTA with justifyContent: "flex-end"). Flow props such as gap and flexWrap are not honored — children are absolutely positioned, so flex flow layout does not apply.
| Prop | Type | Notes |
|---|---|---|
width / height / minWidth / maxWidth / minHeight / maxHeight | number | Explicit height is recommended — absolute children do not size the parent |
padding / paddingHorizontal / paddingVertical | number | Applied to the ZStack container |
margin / marginHorizontal / marginVertical | number | |
flex / flexShrink / flexGrow | number | Apply to the ZStack itself within a parent flex container |
alignSelf | "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline" | |
backgroundColor / backgroundGradient | string / GradientBackground | Gradient is linear { type:"linear", from, to, stops } (from/to are edges: "top", "bottom", "left", "right", "topLeft"…) or radial { type:"radial", center?, radius?, stops } (center {x,y} in 0–1 box fractions, default {0.5,0.5}; radius a 0–1 fraction, default 0.75). Each stop is { color, position? } (position 0–1). Linear needs expo-linear-gradient (optional); radial uses react-native-svg (bundled) |
borderWidth / borderRadius / borderColor | number | string | |
overflow | "hidden" | "visible" | "scroll" | Use "hidden" to clip overflowing children to rounded corners |
opacity | number | |
justifyContent | "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | Anchors each content-sized layer along the vertical axis. A layer that fills (flex/height) ignores it. Default "flex-start" (top) |
alignItems | "flex-start" | "center" | "flex-end" | "stretch" | "baseline" | Anchors each content-sized layer horizontally. Default "stretch" |
Children are layered in declaration order: first child = bottom, last child = top. Each child is wrapped in position: "absolute" filling the container. justifyContent / alignItems on the ZStack position each layer's content-sized box within that fill (so a content-height bottom CTA can float over scrollable content with justifyContent: "flex-end"); a layer that fills via flex/height ignores them. gap and flexWrap still have no effect.
The wrapper for each child uses pointerEvents="box-none", so touches pass through transparent regions to layers underneath.
SafeAreaView props:
SafeAreaView mirrors the props of SafeAreaView from react-native-safe-area-context. Use it to inset content from system UI (notch, home indicator) inside a ComposableScreen. Other renderers wrap themselves in SafeAreaView automatically; ComposableScreen does not, so opt in here when needed.
| Prop | Type | Notes |
|---|---|---|
mode | "padding" | "margin" | Defaults to "padding". "margin" is useful when the SafeAreaView itself has a background that should not extend into the inset region |
edges | Edge[] or { top?, right?, bottom?, left? } | Edge is "top" | "right" | "bottom" | "left". Object form maps each edge to "off" | "additive" | "maximum" for fine-grained control |
All BaseBoxProps | — | flex, padding, backgroundColor, borderRadius, etc. |
SafeAreaView example:
{
"id": "safe-root",
"type": "SafeAreaView",
"props": { "flex": 1, "edges": ["top", "bottom"] },
"children": [
{ "id": "content", "type": "YStack", "props": { "padding": 24, "gap": 16 }, "children": [ /* ... */ ] }
]
}
As of 1.15.0, OnboardingTemplate does not apply safe-area insets. Built-in page types (Question, MediaContent, Carousel, etc.) now wrap themselves with SafeAreaView edges={["top","bottom"]}. For ComposableScreen, place a SafeAreaView UIElement at the root of your tree (or omit it for full edge-to-edge rendering).
Image props:
| Prop | Type | Notes |
|---|---|---|
url | string | Required — remote image URI. In mode: "expression", {{variable}} interpolation runs first |
mode | "plain" | "expression" | expression enables {{var}} interpolation in url, so one element can serve a data-driven set (.../zodiac/{{zodiacSign}}.png) instead of one duplicated subtree per case. References resolve to the variable's value, not its label — a URL segment is a machine identifier, so { value: "aries", label: "Aries" } yields aries. An unknown variable interpolates to an empty string, so keep a fallback segment or a renderWhen guard. Defaults to "plain", which stays fully static and does not subscribe to variable writes |
width | number | Defaults to "100%" |
height | number | When omitted, aspectRatio is used to size the image |
aspectRatio | number | Used when height is absent; defaults to 16/9 |
resizeMode | "cover" | "contain" | "stretch" | "center" | Defaults to "cover". For SVG sources it maps to preserveAspectRatio |
blurRadius | number | Uniform Gaussian blur radius in px (native, no extra dep). 0/omitted = sharp. Ignored for SVGs |
borderRadius / borderWidth | number | |
borderColor | string | |
opacity | number | |
margin / marginHorizontal / marginVertical | number | |
padding / paddingHorizontal / paddingVertical | number |
- WebP / AVIF: when the optional peer dep
expo-imageis installed,Imagerenders through it for reliable WebP/AVIF decoding (React Native's built-inImageis unreliable for WebP on iOS). Withoutexpo-image, it falls back to React Native'sImage. - SVG: a
urlwhose path ends in.svg(query string / hash tolerant) is auto-detected and rendered withreact-native-svg'sSvgUri— no schema change, existing.svgURLs just work.resizeModemaps to the SVGpreserveAspectRatio.
ProgressiveBlurImage props:
A full-bleed image with a gradient-masked Gaussian blur baked in: sharp where the mask is transparent, progressively blurred where it's opaque (the Figma "welcome screen" hero look — sharp subject up top, blurred toward the bottom so overlaid white text stays legible). Self-contained — drop it as the bottom layer of a ZStack with sharp foreground content above it.
| Prop | Type | Notes |
|---|---|---|
url | string | Required — remote image URI |
intensity | number | Required — blur strength, 0–100 (mapped to the blurred copy's blur radius) |
mask | linear { from, to, stops } or radial { type: "radial", center?, radius?, stops } | Required — gradient driving where the blur is strong. Each stop's opacity (0–1) is the blur strength at position (0–1), not a color. Linear: from/to are edges ("top", "bottom", …), type optional. Radial: center {x,y} in 0–1 box fractions (default {0.5,0.5}), radius a 0–1 fraction (default 0.75); position runs center→edge |
tint | "light" | "dark" | "default" | Blur tint. Defaults to "default" |
maxBlurOpacity | number | Clamp on the masked blur layer (0–1). Defaults to 1 |
blurAppear | { delay?, duration?, easing? } | Optional. Fades the blur layer in over the always-visible sharp image: delay (ms, default 0), duration (ms, default 400), easing ("linear" | "ease-in" | "ease-out" | "ease-in-out", default "ease-out"). Omit → blur shows statically at full strength on mount |
resizeMode | "cover" | "contain" | "stretch" | "center" | Defaults to "cover" |
aspectRatio / width / height / borderRadius / opacity / margin* | Standard box props |
The progressive blur masks a blurred copy of the image (not a backdrop BlurView — a masked BlurView has no backdrop to sample and renders transparent on iOS). It needs @react-native-masked-view/masked-view + expo-image (for the blurred copy), all optional. The linear mask renders via expo-linear-gradient (optional); the radial mask renders via react-native-svg (a required dep, always available). When the masked-view native module is missing (incl. a dev-client built before it was installed), the element degrades to a sharp image + a dark gradient scrim derived from the mask (still legible for overlaid text) and never throws.
Text props:
| Prop | Type | Notes |
|---|---|---|
content | string | TextSpan[] | Required. A string, or an array of styled spans for inline rich text (see below) |
mode | "plain" | "expression" | "plain" (default) renders content as-is; "expression" interpolates {{variableName}} patterns from the variable context (in each span's text when content is a span array) |
fontSize / fontWeight / letterSpacing / lineHeight | number | string | |
fontFamily | string | Font family name; must be loaded by the host app (e.g. via expo-font) |
color | string | Defaults to theme.colors.text.primary |
textAlign | "left" | "center" | "right" | |
backgroundColor / borderColor | string | |
padding / paddingHorizontal / paddingVertical | number | |
margin / marginHorizontal / marginVertical | number | |
borderWidth / borderRadius / opacity | number |
Inline rich text (TextSpan):
Set content to an array of spans to style fragments differently within a single line — they render as nested <Text> and wrap together on one baseline. Each span inherits any prop it omits from the parent Text, so a span only declares what it overrides.
{
"type": "Text",
"props": {
"content": [
{ "text": "Lose " },
{ "text": "5kg", "fontWeight": "700", "color": "#E11D48" },
{ "text": " in 30 days — " },
{ "text": "guaranteed", "fontStyle": "italic", "textDecorationLine": "underline" }
],
"fontSize": 16,
"textAlign": "center"
}
}
| Span prop | Type | Notes |
|---|---|---|
text | string | Required. The span's text (interpolated in mode: "expression") |
fontWeight | string | |
fontStyle | "normal" | "italic" | |
fontFamily | string | "inherit" | Defaults to the parent Text's resolved family |
fontSize / letterSpacing | number | |
color | string | |
textDecorationLine | "none" | "underline" | "line-through" | "underline line-through" |
RichText props:
RichText is a wrapping flex row of child Text elements — words and styled "chips" (padded, rounded, rotated) that wrap to the next line and align together. Because each child is a real flex child of a <View> (not a nested <Text>), it honors its own box props (padding, borderRadius, borderWidth, backgroundColor, margin, transform), plus renderWhen and expression mode — none of which inline TextSpans support. Reach for inline spans (Text.content[]) instead when you only need plain character-level styled runs on one baseline.
Plain-text children are automatically split into one item per word so the row wraps word-by-word like a paragraph (a chip then flows inline with naturally-wrapping text). Children that carry box styling (backgroundColor / borderRadius / border / padding) or motion stay atomic — they are the "chips". Because the split preserves spaces as real items, don't set gap when mixing words and chips (it would double-space) — give chips marginHorizontal for spacing instead.
childrenare restricted toTextelements only (a non-Textchild fails schema parse).propsare layout props + allBaseBoxProps+ inherited text-style defaults. The text-style fields (fontSize,fontWeight,fontFamily,fontStyle,color,textAlign,letterSpacing,lineHeight) are handed down to every childText— declare the title's base typography once on the container and only override per-chip on the children (a child prop always wins over the inherited value).
| Prop | Type | Notes |
|---|---|---|
flexWrap | "wrap" | "nowrap" | Defaults to "wrap" |
gap | number | Space between children |
alignItems | "flex-start" | "center" | "flex-end" | "baseline" | "stretch" | "baseline" aligns text baselines across differently-sized segments |
justifyContent | "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | |
fontSize / fontWeight / fontFamily / fontStyle / color / letterSpacing / lineHeight | same as Text | Inherited default for every child Text (child overrides win) |
textAlign | "left" | "center" | "right" | Inherited default for every child Text, and aligns the wrapping row itself: maps to the row's justifyContent (left→flex-start, center→center, right→flex-end) when justifyContent is not set explicitly |
{
"type": "RichText",
"props": { "alignItems": "center", "justifyContent": "center", "fontSize": 22, "fontWeight": "600" },
"children": [
{ "id": "rt-1", "type": "Text", "props": { "content": "Boost your" } },
{
"id": "rt-2",
"type": "Text",
"props": {
"content": "energy", "fontWeight": "700", "color": "#FFFFFF",
"backgroundColor": "#E11D48", "paddingHorizontal": 14, "paddingVertical": 4,
"borderRadius": 200, "marginHorizontal": 4, "transform": { "rotate": -3 }
}
},
{
"id": "rt-3",
"type": "Text",
"renderWhen": { "variable": "name", "operator": "is_not_empty" },
"props": { "content": ", {{name}}!", "mode": "expression" }
}
]
}
Input props:
| Prop | Type | Notes |
|---|---|---|
variableName | string | Context key — value is written to shared composableVariables on every keystroke |
placeholder | string | Placeholder text |
placeholderColor | string | Defaults to theme.colors.text.tertiary |
defaultValue | string | Initial value; also written to context on mount if variableName is set |
keyboardType | "default" | "email-address" | "numeric" | "phone-pad" | "decimal-pad" | "url" | "number-pad" | "ascii-capable" | "numbers-and-punctuation" | "name-phone-pad" | "twitter" | "web-search" | "visible-password" | Defaults to "default" |
returnKeyType | "done" | "go" | "next" | "search" | "send" | "default" | "emergency-call" | "google" | "join" | "route" | "yahoo" | "none" | "previous" | Defaults to "done" |
autoCapitalize | "none" | "sentences" | "words" | "characters" | Defaults to "sentences" |
secureTextEntry | boolean | Mask input for passwords; defaults to false |
maxLength | number | Maximum character count |
multiline | boolean | Enable multi-line input; defaults to false |
numberOfLines | number | Visible line count when multiline is true |
editable | boolean | Defaults to true |
autoFocus | boolean | Focus on mount and open the keyboard; defaults to false |
color | string | Text color; defaults to theme.colors.text.primary |
fontSize | number | Defaults to 16 |
textAlign | "left" | "center" | "right" | |
padding / paddingHorizontal / paddingVertical | number | Inner padding; defaults to 12 |
backgroundColor | string | Defaults to theme.colors.neutral.lowest |
borderWidth | number | Defaults to 1 |
borderRadius | number | Defaults to 8 |
borderColor | string | Defaults to theme.colors.neutral.low |
alignSelf | "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline" | |
width / height | number | |
opacity | number | |
margin / marginHorizontal / marginVertical | number |
RadioGroup props:
| Prop | Type | Notes |
|---|---|---|
items | Array<{ label?: string; value: string; subLabel?: string; image?: ItemImage }> | Required — list of options. label is optional (omit for a sub-label-only row); value is required; subLabel is an optional secondary line under the label; image is an optional thumbnail stacked above the label (see ItemImage below) |
variableName | string | Context key — selected entry (value + label) is written to shared composableVariables |
defaultValue | string | Pre-selects the matching item on mount; also written to context |
direction | "vertical" | "horizontal" | Layout direction; defaults to "vertical" |
gap | number | Space between items; defaults to 8 |
itemAlignItems | "flex-start" | "center" | "flex-end" | "stretch" | Cross-axis alignment of each item's contents (tick ↔ content column, and image ↔ label inside it); defaults to "center" |
itemGap | number | Spacing (px) between an item's inner pieces: tick ↔ content, and image ↔ text inside the content column; defaults to 12 |
itemBackgroundColor | string | Unselected item background; defaults to "transparent" |
itemSelectedBackgroundColor | string | Selected item background; defaults to primary + 15% opacity |
itemBorderColor | string | Unselected item border; defaults to theme.colors.neutral.low |
itemSelectedBorderColor | string | Selected item border; defaults to theme.colors.primary |
itemBorderRadius | number | Defaults to 8 |
itemBorderWidth | number | Defaults to 1 |
itemColor | string | Unselected label color; defaults to theme.colors.text.primary |
itemSelectedColor | string | Selected label color; defaults to theme.colors.primary |
showTick | boolean | Show/hide the radio circle indicator. When false, the label and selected background/border still render. Defaults to true |
tickPosition | "start" | "end" | Tick placement relative to the label; defaults to "start" |
tickColor | string | Tick color (border + dot) when unselected; defaults to theme.colors.neutral.medium |
tickSelectedColor | string | Tick color (border + dot) when selected; defaults to theme.colors.primary |
tickBorderRadius | number | Corner radius of the tick element; defaults to tickSize / 2 (full circle) |
tickSize | number | Diameter of the tick circle in px; the inner dot scales with it; defaults to 20 |
haptic | "none" | "light" | "medium" | "heavy" | "soft" | "rigid" | Tactile feedback fired on selection. Maps to expo-haptics ImpactFeedbackStyle. Opt-in — omit or "none" for no feedback. Requires the optional expo-haptics peer dep (no-op if not installed) |
itemFontSize | number | |
itemFontWeight | string | |
itemFontFamily | string | |
itemSubLabelColor | string | Sub-label color when unselected; defaults to theme.colors.text.secondary |
itemSelectedSubLabelColor | string | Sub-label color when selected; falls back to itemSubLabelColor then the selected label color |
itemSubLabelFontSize | number | Defaults to the caption text-style size |
itemSubLabelFontWeight | string | |
itemSubLabelFontFamily | string | Falls back to itemFontFamily, then the theme font |
itemSubLabelFontStyle | "normal" | "italic" | |
itemPadding | number | Defaults to 12 when no itemPaddingHorizontal/Vertical is set |
itemPaddingHorizontal / itemPaddingVertical | number | |
itemShadowColor | string | Per-item iOS drop shadow color. Setting only this defaults itemShadowOpacity to 1, itemShadowRadius to 4 |
itemShadowOffset | { width: number; height: number } | Per-item shadow offset |
itemShadowOpacity | number | 0–1 |
itemShadowRadius | number | Shadow blur radius |
itemElevation | number | Per-item Android elevation shadow |
alignSelf | "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline" | |
width / height | number | Container dimensions |
margin / marginHorizontal / marginVertical | number | |
padding / paddingHorizontal / paddingVertical | number | |
borderWidth / borderRadius / borderColor | number | string | Container border |
opacity | number |
CheckboxGroup props:
| Prop | Type | Notes |
|---|---|---|
items | Array<{ label?: string; value: string; subLabel?: string; image?: ItemImage }> | Required — values must be unique. label is optional (omit for a sub-label-only row); subLabel is an optional secondary line under the label; image is an optional thumbnail stacked above the label (see ItemImage below) |
variableName | string | Context key — selected values stored as JSON.stringify(string[]), label as comma-joined display names |
defaultValues | string[] | Pre-selects matching items on mount; must reference valid item values |
direction | "vertical" | "horizontal" | Layout direction; defaults to "vertical" |
gap | number | Space between items; defaults to 8 |
itemAlignItems | "flex-start" | "center" | "flex-end" | "stretch" | Cross-axis alignment of each item's contents (tick ↔ content column, and image ↔ label inside it); defaults to "center" |
itemGap | number | Spacing (px) between an item's inner pieces: tick ↔ content, and image ↔ text inside the content column; defaults to 12 |
itemBackgroundColor | string | Unselected item background; defaults to "transparent" |
itemSelectedBackgroundColor | string | Selected item background; defaults to primary + 10% opacity |
itemBorderColor | string | Unselected item border; defaults to theme.colors.neutral.low |
itemSelectedBorderColor | string | Selected item border; defaults to theme.colors.primary |
itemBorderRadius | number | Defaults to 8 |
itemBorderWidth | number | Defaults to 1 |
itemColor | string | Unselected label color; defaults to theme.colors.text.primary |
itemSelectedColor | string | Selected label color; defaults to theme.colors.primary |
showTick | boolean | Show/hide the checkbox box indicator. When false, the label and selected background/border still render. Defaults to true |
tickPosition | "start" | "end" | Tick placement relative to the label; defaults to "start" |
tickColor | string | Tick border color when unselected; defaults to theme.colors.neutral.medium |
tickSelectedColor | string | Tick border + fill color when selected; defaults to theme.colors.primary |
tickBorderRadius | number | Corner radius of the tick element; defaults to 4 |
tickSize | number | Side length of the tick box in px; the checkmark scales with it; defaults to 20 |
haptic | "none" | "light" | "medium" | "heavy" | "soft" | "rigid" | Tactile feedback fired on toggle. Maps to expo-haptics ImpactFeedbackStyle. Opt-in — omit or "none" for no feedback. Requires the optional expo-haptics peer dep (no-op if not installed) |
itemFontSize | number | |
itemFontWeight | string | |
itemFontFamily | string | |
itemSubLabelColor | string | Sub-label color when unselected; defaults to theme.colors.text.secondary |
itemSelectedSubLabelColor | string | Sub-label color when selected; falls back to itemSubLabelColor then the selected label color |
itemSubLabelFontSize | number | Defaults to the caption text-style size |
itemSubLabelFontWeight | string | |
itemSubLabelFontFamily | string | Falls back to itemFontFamily, then the theme font |
itemSubLabelFontStyle | "normal" | "italic" | |
itemPadding | number | Defaults to 12 when no itemPaddingHorizontal/Vertical is set |
itemPaddingHorizontal / itemPaddingVertical | number | |
itemShadowColor | string | Per-item iOS drop shadow color. Setting only this defaults itemShadowOpacity to 1, itemShadowRadius to 4 |
itemShadowOffset | { width: number; height: number } | Per-item shadow offset |
itemShadowOpacity | number | 0–1 |
itemShadowRadius | number | Shadow blur radius |
itemElevation | number | Per-item Android elevation shadow |
alignSelf | "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline" | |
width / height | number | Container dimensions |
margin / marginHorizontal / marginVertical | number | |
padding / paddingHorizontal / paddingVertical | number | |
borderWidth / borderRadius / borderColor | number | string | Container border |
opacity | number |
ItemImage (per-item image on RadioGroup / CheckboxGroup) — an optional thumbnail rendered on top of the label/sub-label in a vertical column (image → label → subLabel):
| Field | Type | Notes |
|---|---|---|
url | string | Required — image URL. .svg URLs render via react-native-svg; rasters via expo-image (when installed) or RN Image |
width / height | number | Image size in px |
aspectRatio | number | Width:height ratio (use with one of width/height) |
resizeMode | "cover" | "contain" | "stretch" | "center" | Defaults to "contain" |
borderRadius | number | Rounded corners (clipped) |
Button props:
| Prop | Type | Notes |
|---|---|---|
label | string | Required — button text |
variant | "filled" | "outlined" | "ghost" | Visual style; filled = solid primary background, outlined = border only, ghost = no background or border |
actions | ButtonAction[] | Ordered list run on press. See Button actions below. When omitted (and no legacy action), the press is a no-op |
action | "continue" | Deprecated — back-compat alias. When actions is absent and action === "continue", runtime treats it as actions: ["continue"] |
backgroundColor | string | Overrides variant background color |
color | string | Label text color |
fontSize | number | |
fontWeight | string | |
fontFamily | string | |
textAlign | "left" | "center" | "right" | |
alignSelf | "auto" | "flex-start" | "center" | "flex-end" | "stretch" | |
width / height | number | |
margin / marginHorizontal / marginVertical | number | |
padding / paddingHorizontal / paddingVertical | number | |
borderWidth / borderRadius / borderColor | number | string | |
opacity | number | |
shadowColor / shadowOffset / shadowOpacity / shadowRadius / elevation | string / { width, height } / number / number / number | iOS shadow + Android elevation. From BaseBoxProps — accepted on every element, applied by the Button renderer |
disabledWhen | LeafCondition | ConditionGroup | When truthy against current variables, blocks press actions and renders the disabled style |
disabledBackgroundColor / disabledColor | string | Deprecated — use disabledStyle. Fallback only when disabledStyle is absent |
pressedStyle | Partial<ButtonProps> | Style overrides merged while the button is held. Cannot nest pressedStyle/disabledStyle |
disabledStyle | Partial<ButtonProps> | Style overrides merged while disabled. Supersedes disabledBackgroundColor/disabledColor |
transitionDurationMs | number | Opacity animation duration (ms) between rest/pressed/disabled. Default 150 |
haptic | "none" | "light" | "medium" | "heavy" | "soft" | "rigid" | Tactile feedback fired on press (before actions run). Maps to expo-haptics ImpactFeedbackStyle. Opt-in — omit or "none" for no feedback. Requires the optional expo-haptics peer dep (no-op if not installed) |
Per-state styling
pressedStyle and disabledStyle are partial overrides merged on top of the base props for their state. Each accepts the overridable visual props (BaseBoxProps including shadow, plus variant, backgroundColor, color, fontSize, fontWeight, fontFamily, fontStyle, textAlign). The renderer animates opacity between states over transitionDurationMs; non-opacity props (color, shadow) switch instantly.
{
"type": "Button",
"props": {
"label": "Continue",
"actions": ["continue"],
"backgroundColor": "#6C63FF",
"shadowColor": "#000",
"shadowOffset": { "width": 0, "height": 4 },
"shadowOpacity": 0.18,
"shadowRadius": 8,
"elevation": 4,
"transitionDurationMs": 180,
"pressedStyle": { "opacity": 0.7, "backgroundColor": "#4f46e5" },
"disabledStyle": { "backgroundColor": "#d1d5db", "color": "#6b7280", "shadowOpacity": 0, "elevation": 0 }
}
}
Button actions
Button.actions is an ordered array. Each entry is one of:
-
"continue"— advances to the next onboarding step (calls theonContinuecallback wired to the renderer). -
{ type: "custom", function: string, variables?: string[], onResolve?: ButtonAction[], onError?: ButtonAction[], retry?: { maxAttempts: number, delayMs?: number, timeoutMs?: number } }— invokes a developer-injected handler registered onOnboardingProvider.customActions.variableslists the keys to read from the live ComposableScreen variable map and forward to the handler. The handler is awaited, so an async one gates the flow.onResolveruns when its promise resolves;onErrorruns when it fails (a throw with every attempt spent, an attempt pastretry.timeoutMs, or an unregisteredfunctionname). NEITHER is terminal — the enclosing list carries on after both, as it does forpurchase/restore— so an async gate's"continue"belongs insideonResolve, where it runs only on success, while a"continue"after the action advances on every outcome.retry.maxAttemptsis the TOTAL attempts counting the first (1..10, required whenretryis present; absentretrymeans one attempt),delayMsis a fixed pause between them (0..10000), andtimeoutMsbounds ONE attempt (1000..300000, no default — absent means unbounded). A retried handler must be idempotent: every attempt re-invokes the same function with the same press-time variables, and a timed-out attempt is an ordinary failed attempt, so a request that reached the server and lost only the answer to the clock is sent again —retryon a create-or-charge endpoint is N writes for one press. While the list is awaiting, the runtime publishesactions.pendingandactions.pending.<elementId>as"true"in the variable map, sorenderWhen/disabledWhencan show a pending state with no host code; a second press on the same element is dropped while it runs — on aButtonand on the genericonPressof any element alike. Inside aRepeat, spell the per-element key with the TEMPLATE id (actions.pending.row-cta); each row resolves it to its own materialized element. Acustomaction counts as a way off the screen only when the resolve path (onResolveor anything after the action) AND the failure path (onErroror anything after the action) each reach a"continue"/{type:"dismiss"}.[{custom}, "continue"]satisfies both;{custom, onResolve:["continue"]}on its own satisfies neither. -
{ type: "setVariable", name: string, value: string, label?: string, valueMode?: "literal" | "expression", kind?: "int" | "float" | "string", arrayOp?: "append" | "remove" | "toggle" }— writes to the variable map ({ value, label }). Pair with"continue"to set a discriminator a downstreamnextSteprule can branch on; the value is visible to the same-tick branch evaluation. WithvalueMode: "expression"thevalueis evaluated rather than stored verbatim. Beyond+ - * /and parens it has a function stdlib: numericmin(a,b,…)max(a,b,…)abs(a)round(a[,digits])clamp(a,lo,hi); datesaddDays(date,n)format(date,spec[,locale])—dateis an ISO string (whatDatePickerstores) or the"now"sentinel, andspecisDatePicker's Intlformatvocabulary, either a baredateStylename ("medium") orkey:valuepairs ("weekday:long, day:numeric"), NOT aYYYY-MM-DDtoken language; listinglist(x[,conjunction])("A, B and C")join(x[,separator])count(x)plural(n,one,other)—xis an untagged multi-select variable and its member LABELS are used (an explicitkind:"string"is taken at its word and read as raw values). A scalar answer counts as one member, also by its label. A variable holding a NUMBER is not a list —count({{age}})fails rather than answering 1, becauseplural({{age}}, ...)is what that wanted — and neither is a value that parses as JSON without being astring[]("[1,2,3]",{"a":1}). String literals are quoted, and a literal's CONTENTS interpolate —list({{goals}}) + " for {{name}}"reads "… for Ada", it does not emit the braces. It runs at PRESS time only — there is no mount hook andText mode:"expression"interpolates rather than evaluating, so a computed headline must be written to a variable by an earlier press (usually the previous screen's Continue) and then plainly interpolated. A template that attempts a call and fails stores the EMPTY STRING and warns, so a brokenformat(...)shows as a blank, not as literal source text. An UNSET variable reads as0wherever it is data (so{{counter}} + 1works before the first press, andcount({{skipped}})is 0) but is REFUSED as aclampbound or arounddigit count —clamp({{score}}, {{floor}}, {{ceiling}})with unseeded bounds used to report 0 for a score of 42. The taint follows the VALUE, so it also refuses one that reached a bound through arithmetic or a function —addDays({{d}}, {{weeks}} * 7),clamp({{trialDays}}, 1, 90)— but it is dropped where the result could have come from an untainted argument:max({{trialDays}}, 7)is the explicit default it looks like, as ismax({{seeded_zero}}, {{absent}}). A{{ref}}inside a quoted LITERAL is guarded the same way when it is configuration —join({{goals}}, "{{sep}}")withsepunset used to run the members together — whilecount()is deliberately exempt, sincecount({{skipped}})is a real zero. "Attempts a call" means a stdlib name — or ANY unknown identifier glued to a(, not just a close misspelling — sits in front of a(whose contents could actually BE arguments. Prose keeps its fallback to plain interpolation, so"{{n}} day(s)"still reads "3 day(s)","{{n}} min(s) left"reads "3 min(s) left" (a bare word between the parens means they are punctuation, not a call), and"Goals ({{n}})"reads "Goals (2)" (Goalsis not a stdlib name, and an unglued(after an UNKNOWN identifier is never a call). But"Save(50)","Total({{n}})"and"Step({{n}}/3)"DO blank and warn, because the identifier is glued to the paren and no bare word appears anywhere — one space ("Save (50)") is the fix for an unknown name only. A stdlib name is called whether or not the paren is glued, so"max (2)"evaluates to"2"; text that must stay text needsvalueMode: "literal". A stdlib name whose whole template takes a bare-word argument,count(goals)with the braces forgotten, keeps its text and warns instead, since it is indistinguishable frommin(s). There is NO implicit concatenation, though:list({{goals}}) and moreis a FAILED call (blank + warning), not prose — writelist({{goals}}) + " and more". WitharrayOpthe target is treated as aCheckboxGroup-style multi-select (JSON-encodedstring[]) andvalue/labelare the single member toappend(add, dedup),remove(drop), ortoggle(flip — matches a checkbox tap); the stored label stays comma-joined like a real checkbox andkindis ignored. This lets any element (viaonPress) add or remove a chip from a multi-select without aCheckboxGroupwidget. -
{ type: "purchase", product: string, onSuccess?: ButtonAction[], onCancel?: ButtonAction[], onError?: ButtonAction[] }— buys a resolved store product.productis a product slot key (e.g."yearly") or an interpolable{{var}}ref — typically the value aRadioGroupbound tovariableName: "plan"just wrote. A{{var}}ref resolves to the variable's underlyingvalue(the machine key), not itslabel— so aRadioGroupitem authored as{ value: "yearly", label: "Yearly" }(nicer display text, distinct machine value — the normal pattern) still resolves{{plan}}to"yearly"and finds the right product, regardless of what the label says. RunsonSuccesswhen the purchase completes,onCancelwhen the user backs out,onErroron a store-level failure; a"pending"result (Ask-to-Buy / deferred transaction) runs none of them — the host app owns that case. All three follow-up lists are optional. -
{ type: "restore", onSuccess?: ButtonAction[], onNothingToRestore?: ButtonAction[], onError?: ButtonAction[] }— restores prior purchases. RunsonSuccesswhen a purchase is restored,onNothingToRestorewhen the user had nothing to restore,onErroron failure. All three follow-up lists are optional. -
{ type: "dismiss" }— finishes the current screen with a{ status: "dismissed" }outcome. In a paywall (see Paywalls), this resolves the pendingpresent()call — upgraded to"purchased"/"cancelled"instead if a purchase actually completed during this presentation. Outside a paywall (an onboarding step), it behaves like an early, unconditional exit from the screen. -
{ type: "presentPaywall", placement: string }— asks the host to present a paywall by placement (see Paywalls). Works from an onboarding step or from a paywall's own content alike (a paywall can open another paywall). Not terminal — the action chain continues after it. Warns viaconsole.warnand does nothing if noPaywallProvideris mounted anywhere above the host. -
{ type: "requestPermission", kind: PermissionKind, onGranted?: ButtonAction[], onDenied?: ButtonAction[], onUnavailable?: ButtonAction[] }— asks the OS for a permission, then runs the branch matching the answer. The hooks are ordinary nestedButtonAction[], recursed exactly likepurchase's, which is what lets ONE press advance on a grant and do something different on a refusal —[{ type: "custom" }, "continue"]could ask and advance, but a custom handler's return value is discarded, so a grant and a refusal could only diverge on a later screen through a variable the handler wrote.kindis one of six, each asked through an optional Expo peer dep the app installs itself (nothing is bundled):kindneeds notificationsexpo-notificationsappTrackingTransparencyexpo-tracking-transparencylocationWhenInUseexpo-location(foreground only)cameraexpo-cameramicrophoneexpo-audio, orexpo-cameraphotoLibraryexpo-image-picker, orexpo-media-libraryNot terminal in itself — put
"continue"inside a hook to advance.onGrantedoronDeniedwith no actions declared logs aconsole.warnrather than silently doing nothing.onUnavailableis the "this build cannot ask" branch — the module is not installed, or the platform has no such permission. Omitted, the runtime does not runonDeniedin its place: nobody refused anything, so writing a refusal (asetVariable, an analytics event, arenderWhengate that later reads it) would record a decision the user never made. Instead, if the author put a"continue"/{dismiss}anywhere in the ask — i.e. the press was the screen's way forward — the runtime completes the screen with that same action, preferring{dismiss}when the ask can reach both, and logs aconsole.errornaming the missing module. Reusing the author's own outcome matters on aPaywallstep: a bare"continue"is what that step's hard gate reads as "advance", so an ask authored with{dismiss}on both branches keeps the gate shut instead of handing out the gated content. If the ask was never a way forward (a "turn on notifications" button beside its own Skip CTA), nothing runs beyond that error. A declaredonUnavailablealways wins, including an empty one.Asking twice does not re-prompt: iOS shows its system dialog once and every later request resolves immediately with the standing answer — but the matching hook still runs, so a second press repeats whatever it does.
requestPermissionon the host is an override for the six kinds above, not a way to add a seventh. A host passes its own resolver viaOnboardingPage'srequestPermissionprop (orPaywallHost's, for a paywall presented throughpresent()) and returnsundefinedfor any kind it does not handle, which falls back to the bundled Expo resolver. Use it when the app already owns a permission flow for one of these kinds — its own notification opt-in, say — and wants the authored CTA to run that instead.HealthKit and Screen Time / Family Controls are NOT reachable this way today. Both need entitlements and a config plugin that belong to the app rather than to a library, so they are not members of
kind— and becausekindis a closed enum, a payload naming"healthKit"or"screenTime"failsinvalid_unionand takes the whole screen to the error boundary before any resolver is consulted. Adding them means adding the kind to the schema first:react-native-onboarding#261. Until then, a host-owned permission of that sort stays a{ type: "custom" }action.Reading a permission's current status is not available. This is a press action; there is no way to skip a screen because the permission was already granted, and no variable carries the outcome to a later step.
Requires an installed SDK that ships this action — check before publishing one. No version number is quoted here on purpose: the release that carries it is decided when it is cut, so a hardcoded floor in this page would be wrong in the dangerous direction (understating it green-lights publishing to an audience that loses the screen). The check that cannot go stale is the app's own copy:
grep -o requestPermission node_modules/@rocapine/react-native-onboarding/dist/steps/common.types.js— no match means that build rejects the action. The forward-compat strip described under Forward compatibility is keyed to unknown element types;ButtonActionis a plain union, so an app whose SDK predates the action fails the payload withinvalid_unionand the whole screen falls back to the error boundary — no CTA, and no back chevron on adisplayProgressHeader: falsestep. Nothing yet transmits the installed SDK version to Studio (react-native-onboarding#233/onboarding-studio#313), so shipping this action to an audience that may be on an older build is a capability-floor decision rather than a safe default. Giving the runtime the same tolerance for an unknown action that it has for an unknown element is tracked asreact-native-onboarding#262; until that lands, an older build loses the screen rather than the action.
Execution rules:
- Sequential. Each action awaits the previous before starting.
- Async-aware. If a custom handler returns a
Promise, the runtime awaits it.purchase/restorealways await the store round-trip before moving on. "continue"and"dismiss"are terminal — any actions after either are ignored.- Every way a custom handler can fail is one rule: a throw with the retry budget spent, an attempt past
retry.timeoutMs, or afunctionname with no matching key incustomActions. All three log viaconsole.error, runonErrorif it is declared, and let the rest of the chain run — the same aspurchase/restore. Put what must only happen on success inonResolve. - Without
retry.timeoutMs, a handler whose promise never settles is awaited for the life of the screen:actions.pending.<elementId>stays"true", so a CTA gated on it withdisabledWhennever comes back. purchase/restoreneed aProductProviderconfigured onOnboardingProvider(or, for a paywall, onPaywallProvider— see below). Without one — or when the runtime resolves{ status: "error" }and noonErrorlist is declared — the action logs viaconsole.warninstead of failing silently.
Selling with purchase / restore
Resolved store products are projected into the same variable map as flat dotted keys, so prices interpolate like any other variable: product.<slot>.price, product.<slot>.pricePerDay, product.<slot>.pricePerWeek, product.<slot>.savingsPct, product.<slot>.trialDays, plus products.loaded / products.purchasing / products.error. Never author a price into the payload — always read it from these variables, and gate the plan list / buy button on products.loaded so nothing is tappable before products resolve:
{
"id": "buy-yearly",
"type": "Button",
"renderWhen": { "variable": "products.loaded", "operator": "eq", "value": "true" },
"props": {
"label": "Start free trial — {{product.yearly.price}}",
"actions": [
{
"type": "purchase",
"product": "yearly",
"onSuccess": ["continue"],
"onError": [{ "type": "custom", "function": "trackPurchaseError" }]
}
]
}
}
Distinguishing "still loading" from "failed to load." products.loaded is "false" for BOTH cases — it only means the store round-trip hasn't reached "ready" yet, not why. A paywall that treats the two as one thing shows a permanent spinner instead of telling the user something actually broke, which is worse than the App-Review-rejection risk the products.loaded gate above exists to avoid. Key the failure branch on products.error being non-empty instead, and the loading branch on products.error still being empty so it doesn't also match the failed case:
{
"id": "loading-text",
"type": "Text",
"renderWhen": {
"logic": "and",
"conditions": [
{ "variable": "products.loaded", "operator": "eq", "value": "false" },
{ "variable": "products.error", "operator": "is_empty" }
]
},
"props": { "content": "Loading plans…" }
}
{
"id": "error-text",
"type": "Text",
"renderWhen": { "variable": "products.error", "operator": "is_not_empty" },
"props": { "content": "We couldn't load pricing. Please check your connection and try again later." }
}
There is no ButtonAction to retry loading products — the runtime has no reload() (tracked as a follow-up in the spec). A failed load can only offer dismiss and rely on the host re-presenting the paywall later; don't paper over the gap with a { type: "custom" } action naming a function no host implements.
Guarding a purchase against a double-tap. products.purchasing is "true" for the duration of the store round-trip a purchase action started. Disable the buy button for that window with disabledWhen, and pair it with a visible label explaining why — a disabled button with no explanation looks broken, and a label with no disabledWhen doesn't actually stop the second tap:
{
"id": "buy-yearly",
"type": "Button",
"renderWhen": { "variable": "products.loaded", "operator": "eq", "value": "true" },
"props": {
"label": "Start free trial",
"disabledWhen": { "variable": "products.purchasing", "operator": "eq", "value": "true" },
"actions": [{ "type": "purchase", "product": "{{plan}}" }]
}
}
{
"id": "purchasing-text",
"type": "Text",
"renderWhen": { "variable": "products.purchasing", "operator": "eq", "value": "true" },
"props": { "content": "Completing your purchase…" }
}
Both patterns ship as seeded templates in Onboarding Studio's template palette ("Paywall — pricing block with failure state" and "Paywall — purchase button with in-flight state") and are exercised together in the SDK's own example/app/example/paywall.tsx.
{
"id": "primary-cta",
"type": "Button",
"props": {
"label": "Get Started",
"variant": "filled",
"actions": [
{ "type": "custom", "function": "trackCta", "variables": ["name", "plan"] },
"continue"
]
}
}
Register the matching handlers on OnboardingProvider:
import { OnboardingProvider } from "@rocapine/react-native-onboarding";
<OnboardingProvider
client={client}
customActions={{
trackCta: async ({ variables }) => {
// variables → { name?: { value, label? }, plan?: { value, label? } }
await analytics.track("cta_pressed", {
name: variables.name?.value,
plan: variables.plan?.value,
});
},
}}
>
{children}
</OnboardingProvider>
See Custom Actions for the full handler signature and patterns.
DatePicker props:
| Prop | Type | Notes |
|---|---|---|
variableName | string | Context key — selected date stored as ISO 8601 string (value) and locale-formatted string (label, e.g. "Apr 23, 2026") |
defaultValue | string | ISO date string, or "now" for the current date/time at render; used as initial value if no persisted value exists |
minimumDate | string | ISO date string, or "now" for the current date/time at render — earliest selectable date |
maximumDate | string | ISO date string, or "now" for the current date/time at render — latest selectable date |
mode | "date" | "time" | "datetime" | Defaults to "date" |
display | "default" | "spinner" | "calendar" | "clock" | "compact" | "inline" | Platform-specific display style; iOS defaults to "spinner", Android defaults to "default" |
textColor | string | Picker text color; defaults to theme.colors.text.primary |
accentColor | string | Picker accent/highlight color; defaults to theme.colors.primary |
locale | string | BCP 47 locale tag (e.g. "fr-FR") — applied to both the native picker and the formatted label |
format | object | Intl.DateTimeFormatOptions subset controlling the displayed/stored label (not the native wheel): weekday, year, month, day, hour, minute, second ("numeric" | "2-digit", plus "long" | "short" | "narrow" for weekday/month), hour12 (boolean), hourCycle ("h11" | "h12" | "h23" | "h24"), dateStyle/timeStyle ("full" | "long" | "medium" | "short"). Forwarded to toLocaleTimeString/toLocaleString/toLocaleDateString per mode. e.g. { "hour": "2-digit", "minute": "2-digit", "hour12": false } → "14:30". Don't combine dateStyle/timeStyle with component fields (hour/day/…) — Intl throws. Omit for the default medium-style label |
alignSelf | "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline" | |
width / height | number | Container dimensions |
margin / marginHorizontal / marginVertical | number | |
padding / paddingHorizontal / paddingVertical | number | |
borderWidth / borderRadius / borderColor | number | string | Container border |
opacity | number |
On Android, DatePicker renders as a Pressable showing the current value. Tapping opens the native modal picker. On iOS it renders inline.
Dependencies: @react-native-community/datetimepicker
npx expo install @react-native-community/datetimepicker
WheelPicker props:
Renders a scrolling wheel selector (native iOS wheel / Android dropdown) bound to a variable. Provide exactly one of items or range — supplying both, or neither, is a schema error.
| Prop | Type | Notes |
|---|---|---|
variableName | string | Context key — selected entry is written to shared composableVariables (value = item value, label = item label) |
defaultValue | string | Must match one of the available item values; seeds the variable on mount |
items | Array<{ label: string; value: string }> | Explicit options. Mutually exclusive with range; values must be unique |
range | { min: number; max: number; step?: number; unit?: string } | Numeric range that auto-generates options. step defaults to 1; unit is appended to each label as "<value> <unit>" (e.g. "70 kg"). Mutually exclusive with items |
itemColor | string | Text color of the wheel items; defaults to theme.colors.text.primary |
itemFontSize | number | Font size of the wheel items (iOS itemStyle) |
itemFontFamily | string | Font family of the wheel items (iOS itemStyle) |
alignSelf | "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline" | |
width / height | number | Container dimensions |
margin / marginHorizontal / marginVertical | number | |
padding / paddingHorizontal / paddingVertical | number | |
borderWidth / borderRadius / borderColor | number | string | Container border |
opacity | number |
Dependencies: @react-native-picker/picker (the same peer dep the Picker step type uses)
npx expo install @react-native-picker/picker
Slider props:
Renders a continuous numeric slider bound to a variable. The selected value is stored as a float string (kind: "float") in the variable — read it back downstream with a {{variableName}} expression or a renderWhen condition.
| Prop | Type | Notes |
|---|---|---|
variableName | string | Context key — the current value is written to shared composableVariables as a float string (value) |
defaultValue | number | Initial value; defaults to min (else 0). Seeds the variable on mount if variableName is set |
min | number | Minimum value; defaults to 0 |
max | number | Maximum value; defaults to 1 |
step | number | Step increment; 0 (default) means continuous |
minimumTrackTintColor | string | Color of the filled (left) portion of the track; defaults to theme.colors.primary |
maximumTrackTintColor | string | Color of the remaining (right) portion of the track |
thumbTintColor | string | Color of the draggable thumb |
disabled | boolean | Disables interaction |
All BaseBoxProps | — | width, height, margin*, padding*, alignSelf, opacity, etc. |
Dependencies: @react-native-community/slider (optional peer dep — the element degrades to an empty box when it is not installed)
npx expo install @react-native-community/slider
ProgressIndicator props:
Renders a linear bar or circular ring. By default the value range is 0–100 (a percentage), but minValue/maxValue let it represent any range — e.g. an animated count-up from 0 to N. The value can be static (value), bound to a variable (variableName), or animated on mount (autoplay). To show the count-up as plain text, set autoplay + variableName, then read {{var}} in a Text element (mode: "expression") — the variable carries the real value, no math needed.
| Prop | Type | Notes |
|---|---|---|
variant | "linear" | "circular" | Defaults to "linear" |
variableName | string | Bound variable. When autoplay is on, the current step-snapped value (in [minValue, maxValue]) is written each step hop; when off, the indicator reflects/animates to this variable's value |
value | number | Static value (in [minValue, maxValue]) used when no variableName is set |
autoplay | boolean | Animate from initialValue to maxValue on mount |
loop | boolean | Repeat the autoplay animation |
initialValue | number | Starting value (in [minValue, maxValue]); defaults to minValue |
minValue | number | Lower bound of the value range; defaults to 0 |
maxValue | number | Upper bound of the value range; defaults to 100 |
step | number (> 0) | Quantization step for the label/variable; defaults to 1. Bounds JS callbacks to (maxValue − minValue) / step per sweep — use a coarse step for large ranges (e.g. maxValue: 5000, step: 50) to avoid a per-step re-render storm |
labelSuffix | string | Appended after the label value; defaults to "%". Set "" or a unit (e.g. " kg") for non-percentage ranges |
duration | number | Animation duration in ms; defaults to 1000 |
delay | number | Delay in ms before the animation starts; defaults to 0 |
easing | "linear" | "ease-in" | "ease-out" | "ease-in-out" | Defaults to "ease-in-out" |
color | string | Progress fill color; defaults to theme.colors.primary |
trackColor | string | Track color; defaults to theme.colors.neutral.lower |
thickness | number | Bar height (linear) / ring stroke width (circular) |
size | number | Circular diameter in px; defaults to 120 |
showLabel | boolean | Show the value label (value + labelSuffix) |
labelColor | string | Label text color; defaults to theme.colors.text.primary |
All BaseBoxProps | — | width, height, margin*, padding*, alignSelf, opacity, etc. |
Dependencies: None
AnimatedText props:
Animates a number from from to to and renders it as formatted text. The animation runs entirely on the UI thread (reanimated) and writes the value straight into a native TextInput — so it produces no React re-render per frame and never touches the variable store. This is the performant way to show an animated stat (e.g. "+1,028,709 members"); it renders the number only, so compose any static label as a sibling Text. Prefer it over an autoplay ProgressIndicator bound to a variable when all you need is an animated number — the variable-bound approach re-renders the whole screen on every step.
| Prop | Type | Notes |
|---|---|---|
to | number | Required. End value of the count |
from | number | Start value; defaults to 0 |
duration | number | Animation duration in ms; defaults to 1000 |
delay | number | Delay in ms before the count starts; defaults to 0 |
easing | "linear" | "ease-in" | "ease-out" | "ease-in-out" | Defaults to "ease-out" |
autoplay | boolean | Animate on mount; defaults to true |
loop | boolean | Repeat the count indefinitely; defaults to false |
decimals | number (int ≥ 0) | Decimal places in the displayed number; defaults to 0 |
thousandsSeparator | string | Grouping separator for the integer part (e.g. 1,028,709); defaults to ",". Set "" to disable grouping |
fontSize / fontWeight / fontFamily / fontStyle / color / textAlign / letterSpacing / lineHeight | — | Text styling, same as Text (color defaults to theme.colors.text.primary) |
All BaseBoxProps | — | width, height, margin*, padding*, alignSelf, opacity, etc. |
Dependencies: None (uses react-native-reanimated, already required by the UI package)
TypewriterText props:
Reveals its content string one character at a time — each character mounts its own react-native-reanimated entering animation, with a per-character delay of delay + charIndex * stagger. This is the classic typewriter / staggered-headline reveal. It is distinct from the whole-block animation.entering every element has (which fades the entire string in at once) and from AnimatedText (which animates a number, not a reveal). It renders text only and never writes a variable; text wraps by whole words (characters never break mid-word).
| Prop | Type | Notes |
|---|---|---|
content | string | Required. Text to reveal. In mode: "expression", {{variable}} interpolation runs before the text is split into characters. Plain string only (no spans) |
mode | "plain" | "expression" | expression enables {{var}} interpolation; defaults to "plain" |
preset | entering preset | Reanimated entering preset applied per character (same set as animation.entering.preset: FadeIn/FadeInUp/FadeInDown/SlideIn*/ZoomIn*/BounceIn*/FlipIn*/etc., plus "none" to disable the per-character animation); defaults to "FadeInDown" |
duration | number | Per-character animation length in ms; defaults to 400 |
delay | number | Lead-in in ms before the first character animates; defaults to 0 |
stagger | number | Gap in ms between consecutive characters — the typewriter knob; defaults to 45 |
easing | "linear" | "ease-in" | "ease-out" | "ease-in-out" | Defaults to "ease-out"; ignored when spring is set |
spring | { damping?, stiffness?, mass? } | Reanimated .springify() config; wins over easing when present |
loop | boolean | Replay the reveal on a loop (remounts the characters each cycle); defaults to false |
loopDelay | number | Pause in ms between loop cycles (only when loop); defaults to 1200 |
cursor | boolean | Show a blinking typewriter caret after the text; defaults to false |
cursorChar | string | Caret glyph when cursor is on; defaults to "|" |
reserveSpace | boolean | Lay the fully-revealed text out invisibly up front and overlay the animation on it, so the element never grows mid-reveal and never pushes siblings down. Only meaningful with cursor (without it every character is mounted from frame 0, so the box is already stable). Because it measures the real resolved string it stays correct per locale — unlike a hardcoded wrapper height. Defaults to false |
fontSize / fontWeight / fontFamily / fontStyle / color / textAlign / letterSpacing / lineHeight | — | Text styling, same as Text (textAlign maps to row justification) |
All BaseBoxProps | — | width, height, margin*, padding*, alignSelf, opacity, etc. |
Dependencies: None (uses react-native-reanimated, already required by the UI package)
DrawingPad props:
A freehand drawing surface (signature, doodle, annotation). The user draws with a finger; on each completed stroke the drawing is serialized into the bound variable(s) — an SVG path string into variableName and/or a base64 image data URI into imageVariableName. Writing on stroke-end (not per touch-move) avoids a per-frame setVariable re-render storm. The SVG variable is compact/vector and re-rendered with react-native-svg; the image variable is a drop-in Image source / upload payload.
| Prop | Type | Notes |
|---|---|---|
variableName | string | Receives the SVG path d string; label = "Drawing captured" once drawn (empty string when cleared) |
imageVariableName | string | Receives a base64 data URI (data:image/png;base64,…). Omit if you only need the SVG |
strokeColor | string | Pen color; defaults to theme.colors.text.primary |
strokeWidth | number (> 0) | Pen width in px; defaults to 2 |
backgroundColor | string | Canvas fill (also painted into the exported image); defaults to theme.colors.neutral.lowest |
clearable | boolean | Show a clear button once drawn; defaults to true |
clearButtonPosition | "top-left" | "top-right" | "bottom-left" | "bottom-right" | Corner the clear button sits in; defaults to "top-right" |
clearButtonOffset | number | Distance (px) from the two nearest edges; defaults to 8 |
clearButtonSize | number | Clear button diameter in px; defaults to 32 (glyph scales to half) |
clearButtonColor | string | Clear button background; defaults to theme.colors.neutral.higher |
clearButtonIconColor | string | Clear button glyph color; defaults to theme.colors.text.opposite |
clearButtonLabel | string | Clear button glyph/label; defaults to "✕" |
imageFormat | "png" | "jpeg" | Encode format for imageVariableName; defaults to "png" |
All BaseBoxProps | — | width, height (defaults to 200), borderWidth (default 2), borderRadius (default 16), borderColor (default theme.colors.primary), margin*, padding*, alignSelf, opacity, etc. |
Dependencies: @shopify/react-native-skia (mandatory for this element — used for both the live canvas and serialization; throws an explicit install error if missing)
npx expo install @shopify/react-native-skia
Repeat props:
Materializes its children (one template) once per row of data. It renders no view of its own — the rows become direct children of the enclosing container, so that container's gap, direction and alignment apply per row exactly as if the rows had been written by hand. For that reason its props do not extend BaseBoxProps.
| Prop | Type | Notes |
|---|---|---|
data | Array<Record<string, string | number | boolean>> | Required. One record per materialization, authored in the payload. Flat scalars only — nested objects aren't addressable as {{item.x}} and are rejected |
as | string | Prefix the row's fields are exposed under; defaults to "item" |
keyField | string | Row field used for each materialized element's id suffix and React key (card → card__aries). Falls back to the row index. Prefer setting it — ids stay meaningful and identity survives a reorder of data |
Each row's fields are readable as {{item.<field>}} and as renderWhen variables (item.index is always present). A renderWhen on the template gates per row, so Repeat also covers the "show exactly one of N" case — there is no separate Match element. The scope reaches press-time actions too, so a repeated card can write its own row's value with { "type": "setVariable", "valueMode": "expression", "value": "{{item.id}}" }; scoped values are read-only, and every row's child writes the same variable name, distinguishing itself by the value.
data is authored in the payload rather than sourced from a variable holding JSON, so copy and asset URLs stay in the studio where designers and the translation pipeline are. For the same reason a translatable row string carries its own literal i18n key ("titleKey": "zodiac_aries_title"), never a computed one — key coverage is measured by scanning payloads for literal key strings, so a computed key reports the screen fully translated while its rows ship untranslated.
Carousel props:
| Prop | Type | Notes |
|---|---|---|
carouselType | "normal" | "left-align" | "parallax" | "stack" | Layout mode — see below; defaults to "normal" |
autoPlay | boolean | Auto-advance slides; defaults to false |
autoPlayInterval | number | Milliseconds between auto-advances; defaults to 3000 |
loop | boolean | Loop back to first slide after last; defaults to true |
showDots | boolean | Show Pagination.Basic pill dots; defaults to true |
dotColor | string | Inactive dot color; defaults to theme.colors.neutral.low |
activeDotColor | string | Active dot color; defaults to theme.colors.primary |
dotWidth / dotHeight | number | Inactive dot size; default 20 / 4 |
activeDotWidth / activeDotHeight | number | Active dot size; default to dotWidth / dotHeight when unset |
dotsGap | number | Gap between dots; defaults to 8 |
dotsPosition | "top" | "bottom" | Render dot row above or below the carousel; defaults to "bottom" |
dotsMarginTop / dotsMarginBottom | number | Dot-row margins; default 12 / 0 |
defaultIndex | number | null | Slide shown on mount; only applied at mount |
variableName | string | Writes the settled slide index to the variable store on snap (0, 1, 2) — use for branching, analytics and {{var}} text |
progressVariableName | string | Publishes the continuous swipe position as a screen-scoped animated variable, updated every frame, so sibling elements can gate renderWhen on the finger rather than on the settled slide. Always normalized to [0, childCount): the underlying library value is unbounded when loop is on (lap two would report 3, 4, …), so the raw value would silently break gates after the first lap. Note eq compares the rounded value, so eq 1 flips once the swipe passes halfway. UI-thread only — it is not written to the store, so {{var}} text cannot read it |
height | number | Slide height in dp; defaults to 220 |
width | number | Slide width; defaults to useWindowDimensions().width (overridden for stack and left-align) |
borderRadius | number | Applied to each slide's inner View |
All BaseBoxProps | — | margin, padding, alignSelf, borderColor, borderWidth, opacity, etc. applied to outer container |
carouselType modes:
| Mode | Width | Library prop | Description |
|---|---|---|---|
"normal" | 100 % window | — | Full-width paged carousel |
"parallax" | 100 % window | mode="parallax" | Depth-zoom effect on adjacent slides |
"stack" | 75 % window | mode="horizontal-stack" | Stacked card effect; multiple slides visible |
"left-align" | 82 % window | — | Next slide peeks in from the right; overflow: "visible" on container |
Slides are arbitrary UIElement trees — place any renderable element (Image, Text, YStack, etc.) as children. Each child becomes one slide.
Dependencies: react-native-reanimated-carousel
npx expo install react-native-reanimated-carousel
Variable context
Input, RadioGroup, CheckboxGroup, DatePicker, WheelPicker, Slider, and DrawingPad elements write into a shared composableVariables map stored in OnboardingProgressContext. This state persists across navigation between ComposableScreen steps, so values collected on an early screen are available on later screens.
Each entry is a structured object { value: string; label?: string }:
| Element | value | label |
|---|---|---|
Input | Raw text string | — |
RadioGroup | Selected item value (e.g. "monthly") | Selected item label (e.g. "Monthly") |
CheckboxGroup | JSON.stringify(string[]) of selected values | Comma-joined display labels (e.g. "Health, Fitness") |
DatePicker | ISO 8601 string (e.g. "1990-01-01T00:00:00.000Z") | Locale-formatted date (e.g. "Jan 1, 1990") |
WheelPicker | Selected item value (e.g. "70") | Selected item label (e.g. "70 kg") |
DrawingPad | SVG path string (variableName) / base64 image data URI (imageVariableName) | "Drawing captured" when drawn (SVG var only) |
Slider | Current value as a float string (e.g. "0.5") | — |
Text elements with mode: "expression" interpolate {{variableName}} patterns in their content string from that map. The renderer prefers label over value when both are present. If a key has no value yet, the pattern is replaced with an empty string.
[
{
"id": "name-input",
"type": "Input",
"props": {
"variableName": "name",
"placeholder": "Enter your name",
"autoCapitalize": "words",
"borderRadius": 12
}
},
{
"id": "greeting",
"type": "Text",
"props": {
"content": "Hello {{name}}!",
"mode": "expression",
"fontSize": 18,
"fontWeight": "600",
"textAlign": "center"
}
}
]
OnboardingProgressProvider must wrap your app for the variable context to work. If you use @rocapine/react-native-onboarding-ui, wrap your root layout with it:
import { OnboardingProgressProvider } from "@rocapine/react-native-onboarding-ui";
export default function RootLayout() {
return (
<OnboardingProgressProvider>
{/* ... */}
</OnboardingProgressProvider>
);
}
Without the provider the context falls back to its default (empty variables, no-op setter) — Input values will not be saved and expression Text will render blank for any {{variable}}.
Conditional rendering (renderWhen)
Every UIElement variant accepts an optional renderWhen field — a
LeafCondition or ConditionGroup evaluated against the live variable
map. When the condition is false the element (and its entire subtree
for containers) is skipped — no node mounts, no layout cost, no
side-effects from Lottie / Video / Input.
Same condition schema as Button.disabledWhen and nextStep.branches[].condition:
{
"id": "lifetime-badge",
"renderWhen": {
"variable": "plan",
"operator": "eq",
"value": "lifetime"
},
"type": "Text",
"props": {
"content": "🎉 Best value — lifetime access!",
"fontSize": 14,
"fontWeight": "600",
"textAlign": "center"
}
}
Use ConditionGroup for AND / OR composition:
{
"id": "yearly-or-lifetime-note",
"renderWhen": {
"logic": "or",
"conditions": [
{ "variable": "plan", "operator": "eq", "value": "yearly" },
{ "variable": "plan", "operator": "eq", "value": "lifetime" }
]
},
"type": "Text",
"props": { "content": "Best long-term value" }
}
Operators. Binary operators compare the variable to value (required):
eq, neq, gt, lt, gte, lte, contains, in, not_in. Unary
operators test the variable alone and take no value:
| Operator | True when |
|---|---|
is_empty | value is an empty/whitespace string, an empty array, or unset/null |
is_not_empty | negation of is_empty |
is_null | value is unset or null only (a set-but-empty "" is not null) |
is_not_null | negation of is_null |
{ "id": "greeting", "renderWhen": { "variable": "name", "operator": "is_not_empty" }, "type": "Text", "props": { "content": "Hi {{name}}!", "mode": "expression" } }
Prefer is_not_empty over { "operator": "neq", "value": "" } — it is
type-aware and reads clearly. A binary operator missing value is a schema
error; a unary operator ignores any value.
in / not_in take a list. Three shapes mean the same list, so a
membership gate can be authored against a literal, or against another
variable's selection:
{ "variable": "item.value", "operator": "not_in", "value": ["health", "sport"] }
{ "variable": "item.value", "operator": "not_in", "value": "{{selected}}" } // multi-select variable
{ "variable": "item.value", "operator": "not_in", "value": ["{{selected}}"] } // same, array-wrapped
A CheckboxGroup stores its selection as a JSON-encoded array string, and a
reference to it is decoded back into a list before the comparison. Members
compare stringified, so a numeric Repeat row field matches a string member
(1 is in ["1","2"]), and contains against an array variable follows the
same rule. An empty list — and a reference to a variable nobody has written,
in either shape — has no members: in matches nothing, not_in matches
everything. A
right-hand side that is no list at all ("operator": "in", "value": "male")
reads as a one-member list and logs a warning; author ["male"] or use eq.
Element-level defaults (Carousel.defaultIndex, RadioGroup.defaultValue,
CheckboxGroup.defaultValues, Input.defaultValue,
DatePicker.defaultValue, WheelPicker.defaultValue, Slider.defaultValue) are overlaid onto the variable map
synchronously on first render — so renderWhen and {{var}}
interpolation see defaults from the very first frame, before any user
interaction.
renderWhen vs Branch.conditionrenderWhen— hides a single element inside a screen.Branch.condition(step.nextStep.branches[].condition) — picks the next step in the flow when continue is pressed.
Pick renderWhen for in-screen toggles (validation text, plan-specific
copy, optional sections). Pick Branch.condition to skip whole steps.
- Start — set
configuration.startStepIdto the id of the step the flow should begin on (optional; defaults to the first step). Resolve it withresolveStartStepNumber/ theuseOnboardingStart()hook. - End — set a
nextStep.defaultTargetStepIdor anybranches[].targetStepIdto the reserved sentinel"__END__"to end the onboarding from any step (no trailing screen).resolveNextStepNumberreturnsnullfor it, and the host firesonCompleteviacompleteOnboarding(). See Core Concepts → Start & End Nodes.
Icon props:
| Prop | Type | Notes |
|---|---|---|
name | string | Required — Lucide icon name e.g. "Star", "Heart", "CheckCircle" |
size | number | Icon width & height in dp; defaults to 24 |
color | string | Stroke color; defaults to theme.colors.text.primary |
strokeWidth | number | Line stroke weight; defaults to 2 |
fill | string | Fill color (any CSS color). Omit ⇒ Lucide default "none" (outlined). Set to the same value as color for a fully filled icon, or to a tinted color for a soft look. Works best with closed-path icons (Star, Heart, Bookmark, Circle, CheckCircle2). |
fillOpacity | number | 0–1. Lower values tint the fill against the stroke. Defaults to 1. |
width / height | number | Wrapper View dimensions |
margin / marginHorizontal / marginVertical | number | |
padding / paddingHorizontal / paddingVertical | number | |
borderWidth | number | |
borderRadius | number | |
borderColor | string | |
opacity | number |
Icon uses Lucide React Native, which is bundled with the UI package — no extra install needed. If an unknown icon name is passed the element renders nothing.
Lottie props:
| Prop | Type | Notes |
|---|---|---|
source | string | Required — remote .json URL |
width | number | Defaults to "100%" |
height | number | Defaults to 200 |
autoPlay | boolean | Defaults to true |
loop | boolean | Defaults to true |
speed | number | Playback speed multiplier |
opacity | number | |
margin / marginHorizontal / marginVertical | number | |
padding / paddingHorizontal / paddingVertical | number | |
borderWidth / borderRadius / borderColor | number | string | Applied via a wrapping View |
Dependencies: lottie-react-native
npx expo install lottie-react-native
If lottie-react-native is not installed, the element renders a placeholder view with an install hint instead of crashing.
Rive props:
| Prop | Type | Notes |
|---|---|---|
url | string | Required — remote .riv URL |
width | number | Defaults to "100%" |
height | number | Optional. When omitted, aspectRatio sizes the box |
aspectRatio | number | Used when height is absent; falls back to 1 if neither height / flex / aspectRatio / minHeight / maxHeight is set, because rive-react-native doesn't expose the artboard's intrinsic ratio to JS |
autoplay | boolean | Defaults to true |
fit | "Contain" | "Cover" | "Fill" | "FitWidth" | "FitHeight" | "None" | "ScaleDown" | "Layout" | |
alignment | "TopLeft" | "TopCenter" | "TopRight" | "CenterLeft" | "Center" | "CenterRight" | "BottomLeft" | "BottomCenter" | "BottomRight" | |
artboardName | string | Target artboard in the .riv file |
stateMachineName | string | State machine to drive |
opacity | number | |
margin / marginHorizontal / marginVertical | number | |
padding / paddingHorizontal / paddingVertical | number | |
borderWidth / borderRadius / borderColor | number | string | Applied via a wrapping View |
Dependencies: rive-react-native
npx expo install rive-react-native
If rive-react-native is not installed, the element renders a placeholder view with an install hint instead of crashing.
Video props:
| Prop | Type | Notes |
|---|---|---|
url | string | Required — remote video URL |
width | number | Defaults to "100%" |
height | number | Defaults to 200 |
autoPlay | boolean | Start playing on mount; defaults to false |
loop | boolean | Loop playback; defaults to false |
muted | boolean | Mute audio; defaults to true (required for autoplay on iOS) |
controls | boolean | Show native playback controls; defaults to false |
opacity | number | |
margin / marginHorizontal / marginVertical | number | |
padding / paddingHorizontal / paddingVertical | number | |
borderWidth | number | Applied via a wrapping View |
borderRadius | number | Applied via a wrapping View |
borderColor | string | Applied via a wrapping View |
Dependencies: expo-video
npx expo install expo-video
If expo-video is not installed, the element renders a placeholder view with an install hint instead of crashing.
Payload:
{
elements: UIElement[]; // Recursive tree of layout and media elements
}
Example:
{
"id": "feature-highlight",
"type": "ComposableScreen",
"payload": {
"elements": [
{
"id": "card",
"type": "YStack",
"props": {
"padding": 24,
"gap": 12,
"borderWidth": 1,
"borderRadius": 16,
"borderColor": "#E0E0E0",
"overflow": "hidden"
},
"children": [
{
"id": "hero",
"type": "Image",
"props": {
"url": "https://example.com/hero.png",
"height": 180,
"resizeMode": "cover",
"borderRadius": 12
}
},
{
"id": "title",
"type": "Text",
"props": {
"content": "Welcome aboard",
"fontSize": 24,
"fontWeight": "700"
}
},
{
"id": "row",
"type": "XStack",
"props": { "gap": 8, "alignItems": "center" },
"children": [
{
"id": "badge",
"type": "YStack",
"props": {
"backgroundColor": "#E8F5E9",
"borderRadius": 8,
"padding": 8
},
"children": [
{
"id": "badge-text",
"type": "Text",
"props": { "content": "New", "fontSize": 12, "color": "#2E7D32" }
}
]
},
{
"id": "label",
"type": "Text",
"props": { "content": "Personalized for you", "fontSize": 14 }
}
]
}
]
}
]
}
}
ZStack example — image with text overlay:
{
"id": "hero-overlay",
"type": "ZStack",
"props": {
"height": 200,
"borderRadius": 16,
"overflow": "hidden",
"marginVertical": 8
},
"children": [
{
"id": "bg",
"type": "Image",
"props": { "url": "https://example.com/hero.jpg", "height": 200, "resizeMode": "cover" }
},
{
"id": "overlay",
"type": "YStack",
"props": { "backgroundColor": "rgba(0,0,0,0.45)", "padding": 20, "justifyContent": "flex-end" },
"children": [
{
"id": "label",
"type": "Text",
"props": { "content": "Your headline here", "fontSize": 18, "fontWeight": "700", "color": "#fff" }
}
]
}
]
}
Forward compatibility (unknown element types): the element schema only knows the element types shipped in your installed SDK version. When a screen published from Studio uses a newer element type, the SDK omits that element and its children and renders the rest of the screen — it no longer fails the whole screen — and logs a console.warn naming the type and its path in the tree. Everything else is still validated strictly, so a genuine payload error (an enum value that doesn't exist, a missing id) still surfaces as a parse error rather than a quietly missing element. If omitting an element leaves nothing on the screen that can advance the step — the continue button is authored inside the element tree, so a newer element used as the screen's root container takes the CTA down with it — the SDK renders its own Continue button and logs a console.error, so nobody is stranded on a screen with no way out. Two practical consequences: upgrade the SDK to render a newly added element type, and treat that fallback button as a safety net rather than a design, since the screen the user sees is still missing whatever the element was for. KNOWN_ELEMENT_TYPES is exported from @rocapine/react-native-onboarding if you want to check a payload against the installed build. One nuance on "can advance the step": a requestPermission action counts as a way forward only when a grant and a non-grant both reach a "continue"/dismiss — a CTA holding its only "continue" in onGranted reads as no way forward, because a refusal (or a build without the optional module) would strand the user.
Dependencies: None for YStack, XStack, ZStack, Text, Image, Icon, Input, RadioGroup, CheckboxGroup, Button, ProgressIndicator. The optional haptic prop on Button / RadioGroup / CheckboxGroup uses expo-haptics (optional peer dep) — it silently no-ops if not installed, so no dependency is required unless you opt into haptics. SafeAreaView requires react-native-safe-area-context (already a peer dep of @rocapine/react-native-onboarding-ui). See the Carousel, Lottie, Rive, Video, DatePicker, WheelPicker, and Slider sections above for elements with peer deps.
Common Properties
All page types share these properties:
{
id: string; // Unique identifier
type: string; // Discriminated union field
name: string; // Display name (for debugging)
displayProgressHeader: boolean; // Show/hide progress bar
payload: object; // Type-specific data
customPayload: object | null; // Your custom fields
continueButtonLabel?: string; // Optional CTA text
figmaUrl?: string | null; // Design reference
}
Customization Levels
Each page type supports three levels of customization:
Level 1: Theming
Apply theme tokens to all screens:
<OnboardingProvider
theme={{
colors: { primary: "#FF5733" },
typography: { fontFamily: { title: "CustomFont" } }
}}
/>
Level 2: Custom Components
Replace specific UI components (currently available for Question type):
<OnboardingProvider
customComponents={{
QuestionAnswerButton: MyCustomButton
}}
/>
Learn more about custom components →
Level 3: Custom Renderers
Complete control over specific screens:
// In your routing file
if (step.id === "custom-screen") {
return <MyCustomRenderer step={step} onContinue={onContinue} />;
}
Learn more about custom renderers →
Media Support
Several page types support media with the MediaSourceSchema:
{
type: "video" | "image" | "lottie" | "rive";
url?: string; // Remote URL
localPathId?: string; // Local asset path
}
Supported media types:
- video: Video files (implementation pending)
- image: PNG, JPG, WebP via React Native Image
- lottie: Lottie animations via
lottie-react-native - rive: Rive animations via
rive-react-native
Using Individual Renderers
You can use renderers directly without OnboardingPage:
import { MediaContentRenderer, MediaContentStepType } from "@rocapine/react-native-onboarding";
const step: MediaContentStepType = {
id: "welcome",
type: "MediaContent",
name: "Welcome",
displayProgressHeader: true,
payload: {
title: "Welcome!",
description: "Let's get started",
media: { type: "image", url: "https://..." },
},
customPayload: null,
continueButtonLabel: "Get Started",
figmaUrl: null,
};
<MediaContentRenderer step={step} onContinue={handleContinue} />
Next Steps
- 🎨 Customization Overview - Learn about customization levels
- 📘 API Reference - Complete type definitions
- 🚀 Getting Started - Build your first onboarding flow