Skip to main content

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


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 units
  • height - Height with cm/ft+in units
  • age - Age picker
  • gender - Gender selection
  • name - Name input
  • date - 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 padding and margin
  • 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 contextInput, RadioGroup, CheckboxGroup, DatePicker, WheelPicker, Slider, and DrawingPad elements write into shared state; Text elements 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:

ElementRenders asDirectionPeer dep
YStack<View>column
XStack<View>row
ZStack<View>depth (z-axis)
SafeAreaView<SafeAreaView>columnreact-native-safe-area-context
Text<Text>
RichText<View> wrapping Text childrenwrapping row
Image<Image>
ProgressiveBlurImage<Image> + masked blurred image copydepth (z-axis)@react-native-masked-view/masked-view, expo-linear-gradient, expo-image (all optional)
IconLucide icon— (bundled)
Input<TextInput>
RadioGroupRadio item list
CheckboxGroupCheckbox item list
Button<TouchableOpacity>
CarouselHorizontal slidesreact-native-reanimated-carousel
DatePickerNative date/time picker@react-native-community/datetimepicker
WheelPickerNative scrolling wheel selector@react-native-picker/picker
DrawingPadFreehand draw/signature canvas@shopify/react-native-skia
SliderContinuous numeric slider@react-native-community/slider
ProgressIndicatorLinear bar / circular ring
AnimatedTextCount-up number (UI-thread, native TextInput)
TypewriterTextCharacter-by-character text reveal (staggered entering)
RepeatMaterializes one template per row of props.data; layout-transparent
LottieLottieViewlottie-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-reanimatedpreset 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:

FieldTypeNotes
translateX / translateYnumberpx offset
scale / scaleX / scaleYnumber
rotatenumberdegrees

animation{ entering?, exiting?, layout?, effect? }:

Sub-fieldShapeNotes
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 over easing when 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: false runs 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):

PropTypeNotes
gapnumberSpace between children
padding / paddingHorizontal / paddingVerticalnumber
margin / marginHorizontal / marginVerticalnumber
flex / flexShrink / flexWrapnumber | stringflexShrink 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 / maxHeightnumber
backgroundColor / borderColorstring
borderWidth / borderRadiusnumber
overflow"hidden" | "visible" | "scroll"Use "hidden" to clip rounded corners
opacitynumber

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.

PropTypeNotes
width / height / minWidth / maxWidth / minHeight / maxHeightnumberExplicit height is recommended — absolute children do not size the parent
padding / paddingHorizontal / paddingVerticalnumberApplied to the ZStack container
margin / marginHorizontal / marginVerticalnumber
flex / flexShrink / flexGrownumberApply to the ZStack itself within a parent flex container
alignSelf"auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
backgroundColor / backgroundGradientstring / GradientBackgroundGradient 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 / borderColornumber | string
overflow"hidden" | "visible" | "scroll"Use "hidden" to clip overflowing children to rounded corners
opacitynumber
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"
ZStack behavior

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.

PropTypeNotes
mode"padding" | "margin"Defaults to "padding". "margin" is useful when the SafeAreaView itself has a background that should not extend into the inset region
edgesEdge[] 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 BaseBoxPropsflex, 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": [ /* ... */ ] }
]
}
OnboardingTemplate no longer applies insets

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:

PropTypeNotes
urlstringRequired — 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
widthnumberDefaults to "100%"
heightnumberWhen omitted, aspectRatio is used to size the image
aspectRationumberUsed when height is absent; defaults to 16/9
resizeMode"cover" | "contain" | "stretch" | "center"Defaults to "cover". For SVG sources it maps to preserveAspectRatio
blurRadiusnumberUniform Gaussian blur radius in px (native, no extra dep). 0/omitted = sharp. Ignored for SVGs
borderRadius / borderWidthnumber
borderColorstring
opacitynumber
margin / marginHorizontal / marginVerticalnumber
padding / paddingHorizontal / paddingVerticalnumber
Image format support
  • WebP / AVIF: when the optional peer dep expo-image is installed, Image renders through it for reliable WebP/AVIF decoding (React Native's built-in Image is unreliable for WebP on iOS). Without expo-image, it falls back to React Native's Image.
  • SVG: a url whose path ends in .svg (query string / hash tolerant) is auto-detected and rendered with react-native-svg's SvgUri — no schema change, existing .svg URLs just work. resizeMode maps to the SVG preserveAspectRatio.

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.

PropTypeNotes
urlstringRequired — remote image URI
intensitynumberRequired — blur strength, 0100 (mapped to the blurred copy's blur radius)
masklinear { 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"
maxBlurOpacitynumberClamp 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
Optional peer deps + graceful degradation

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:

PropTypeNotes
contentstring | 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 / lineHeightnumber | string
fontFamilystringFont family name; must be loaded by the host app (e.g. via expo-font)
colorstringDefaults to theme.colors.text.primary
textAlign"left" | "center" | "right"
backgroundColor / borderColorstring
padding / paddingHorizontal / paddingVerticalnumber
margin / marginHorizontal / marginVerticalnumber
borderWidth / borderRadius / opacitynumber

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 propTypeNotes
textstringRequired. The span's text (interpolated in mode: "expression")
fontWeightstring
fontStyle"normal" | "italic"
fontFamilystring | "inherit"Defaults to the parent Text's resolved family
fontSize / letterSpacingnumber
colorstring
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.

  • children are restricted to Text elements only (a non-Text child fails schema parse).
  • props are layout props + all BaseBoxProps + inherited text-style defaults. The text-style fields (fontSize, fontWeight, fontFamily, fontStyle, color, textAlign, letterSpacing, lineHeight) are handed down to every child Text — 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).
PropTypeNotes
flexWrap"wrap" | "nowrap"Defaults to "wrap"
gapnumberSpace 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 / lineHeightsame as TextInherited 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 (leftflex-start, centercenter, rightflex-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:

PropTypeNotes
variableNamestringContext key — value is written to shared composableVariables on every keystroke
placeholderstringPlaceholder text
placeholderColorstringDefaults to theme.colors.text.tertiary
defaultValuestringInitial 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"
secureTextEntrybooleanMask input for passwords; defaults to false
maxLengthnumberMaximum character count
multilinebooleanEnable multi-line input; defaults to false
numberOfLinesnumberVisible line count when multiline is true
editablebooleanDefaults to true
autoFocusbooleanFocus on mount and open the keyboard; defaults to false
colorstringText color; defaults to theme.colors.text.primary
fontSizenumberDefaults to 16
textAlign"left" | "center" | "right"
padding / paddingHorizontal / paddingVerticalnumberInner padding; defaults to 12
backgroundColorstringDefaults to theme.colors.neutral.lowest
borderWidthnumberDefaults to 1
borderRadiusnumberDefaults to 8
borderColorstringDefaults to theme.colors.neutral.low
alignSelf"auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
width / heightnumber
opacitynumber
margin / marginHorizontal / marginVerticalnumber

RadioGroup props:

PropTypeNotes
itemsArray<{ 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)
variableNamestringContext key — selected entry (value + label) is written to shared composableVariables
defaultValuestringPre-selects the matching item on mount; also written to context
direction"vertical" | "horizontal"Layout direction; defaults to "vertical"
gapnumberSpace 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"
itemGapnumberSpacing (px) between an item's inner pieces: tick ↔ content, and image ↔ text inside the content column; defaults to 12
itemBackgroundColorstringUnselected item background; defaults to "transparent"
itemSelectedBackgroundColorstringSelected item background; defaults to primary + 15% opacity
itemBorderColorstringUnselected item border; defaults to theme.colors.neutral.low
itemSelectedBorderColorstringSelected item border; defaults to theme.colors.primary
itemBorderRadiusnumberDefaults to 8
itemBorderWidthnumberDefaults to 1
itemColorstringUnselected label color; defaults to theme.colors.text.primary
itemSelectedColorstringSelected label color; defaults to theme.colors.primary
showTickbooleanShow/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"
tickColorstringTick color (border + dot) when unselected; defaults to theme.colors.neutral.medium
tickSelectedColorstringTick color (border + dot) when selected; defaults to theme.colors.primary
tickBorderRadiusnumberCorner radius of the tick element; defaults to tickSize / 2 (full circle)
tickSizenumberDiameter 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)
itemFontSizenumber
itemFontWeightstring
itemFontFamilystring
itemSubLabelColorstringSub-label color when unselected; defaults to theme.colors.text.secondary
itemSelectedSubLabelColorstringSub-label color when selected; falls back to itemSubLabelColor then the selected label color
itemSubLabelFontSizenumberDefaults to the caption text-style size
itemSubLabelFontWeightstring
itemSubLabelFontFamilystringFalls back to itemFontFamily, then the theme font
itemSubLabelFontStyle"normal" | "italic"
itemPaddingnumberDefaults to 12 when no itemPaddingHorizontal/Vertical is set
itemPaddingHorizontal / itemPaddingVerticalnumber
itemShadowColorstringPer-item iOS drop shadow color. Setting only this defaults itemShadowOpacity to 1, itemShadowRadius to 4
itemShadowOffset{ width: number; height: number }Per-item shadow offset
itemShadowOpacitynumber01
itemShadowRadiusnumberShadow blur radius
itemElevationnumberPer-item Android elevation shadow
alignSelf"auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
width / heightnumberContainer dimensions
margin / marginHorizontal / marginVerticalnumber
padding / paddingHorizontal / paddingVerticalnumber
borderWidth / borderRadius / borderColornumber | stringContainer border
opacitynumber

CheckboxGroup props:

PropTypeNotes
itemsArray<{ 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)
variableNamestringContext key — selected values stored as JSON.stringify(string[]), label as comma-joined display names
defaultValuesstring[]Pre-selects matching items on mount; must reference valid item values
direction"vertical" | "horizontal"Layout direction; defaults to "vertical"
gapnumberSpace 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"
itemGapnumberSpacing (px) between an item's inner pieces: tick ↔ content, and image ↔ text inside the content column; defaults to 12
itemBackgroundColorstringUnselected item background; defaults to "transparent"
itemSelectedBackgroundColorstringSelected item background; defaults to primary + 10% opacity
itemBorderColorstringUnselected item border; defaults to theme.colors.neutral.low
itemSelectedBorderColorstringSelected item border; defaults to theme.colors.primary
itemBorderRadiusnumberDefaults to 8
itemBorderWidthnumberDefaults to 1
itemColorstringUnselected label color; defaults to theme.colors.text.primary
itemSelectedColorstringSelected label color; defaults to theme.colors.primary
showTickbooleanShow/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"
tickColorstringTick border color when unselected; defaults to theme.colors.neutral.medium
tickSelectedColorstringTick border + fill color when selected; defaults to theme.colors.primary
tickBorderRadiusnumberCorner radius of the tick element; defaults to 4
tickSizenumberSide 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)
itemFontSizenumber
itemFontWeightstring
itemFontFamilystring
itemSubLabelColorstringSub-label color when unselected; defaults to theme.colors.text.secondary
itemSelectedSubLabelColorstringSub-label color when selected; falls back to itemSubLabelColor then the selected label color
itemSubLabelFontSizenumberDefaults to the caption text-style size
itemSubLabelFontWeightstring
itemSubLabelFontFamilystringFalls back to itemFontFamily, then the theme font
itemSubLabelFontStyle"normal" | "italic"
itemPaddingnumberDefaults to 12 when no itemPaddingHorizontal/Vertical is set
itemPaddingHorizontal / itemPaddingVerticalnumber
itemShadowColorstringPer-item iOS drop shadow color. Setting only this defaults itemShadowOpacity to 1, itemShadowRadius to 4
itemShadowOffset{ width: number; height: number }Per-item shadow offset
itemShadowOpacitynumber01
itemShadowRadiusnumberShadow blur radius
itemElevationnumberPer-item Android elevation shadow
alignSelf"auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
width / heightnumberContainer dimensions
margin / marginHorizontal / marginVerticalnumber
padding / paddingHorizontal / paddingVerticalnumber
borderWidth / borderRadius / borderColornumber | stringContainer border
opacitynumber

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):

FieldTypeNotes
urlstringRequired — image URL. .svg URLs render via react-native-svg; rasters via expo-image (when installed) or RN Image
width / heightnumberImage size in px
aspectRationumberWidth:height ratio (use with one of width/height)
resizeMode"cover" | "contain" | "stretch" | "center"Defaults to "contain"
borderRadiusnumberRounded corners (clipped)

Button props:

PropTypeNotes
labelstringRequired — button text
variant"filled" | "outlined" | "ghost"Visual style; filled = solid primary background, outlined = border only, ghost = no background or border
actionsButtonAction[]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"]
backgroundColorstringOverrides variant background color
colorstringLabel text color
fontSizenumber
fontWeightstring
fontFamilystring
textAlign"left" | "center" | "right"
alignSelf"auto" | "flex-start" | "center" | "flex-end" | "stretch"
width / heightnumber
margin / marginHorizontal / marginVerticalnumber
padding / paddingHorizontal / paddingVerticalnumber
borderWidth / borderRadius / borderColornumber | string
opacitynumber
shadowColor / shadowOffset / shadowOpacity / shadowRadius / elevationstring / { width, height } / number / number / numberiOS shadow + Android elevation. From BaseBoxProps — accepted on every element, applied by the Button renderer
disabledWhenLeafCondition | ConditionGroupWhen truthy against current variables, blocks press actions and renders the disabled style
disabledBackgroundColor / disabledColorstringDeprecated — use disabledStyle. Fallback only when disabledStyle is absent
pressedStylePartial<ButtonProps>Style overrides merged while the button is held. Cannot nest pressedStyle/disabledStyle
disabledStylePartial<ButtonProps>Style overrides merged while disabled. Supersedes disabledBackgroundColor/disabledColor
transitionDurationMsnumberOpacity 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 the onContinue callback 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 on OnboardingProvider.customActions. variables lists 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. onResolve runs when its promise resolves; onError runs when it fails (a throw with every attempt spent, an attempt past retry.timeoutMs, or an unregistered function name). NEITHER is terminal — the enclosing list carries on after both, as it does for purchase/restore — so an async gate's "continue" belongs inside onResolve, where it runs only on success, while a "continue" after the action advances on every outcome. retry.maxAttempts is the TOTAL attempts counting the first (1..10, required when retry is present; absent retry means one attempt), delayMs is a fixed pause between them (0..10000), and timeoutMs bounds 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 — retry on a create-or-charge endpoint is N writes for one press. While the list is awaiting, the runtime publishes actions.pending and actions.pending.<elementId> as "true" in the variable map, so renderWhen/disabledWhen can show a pending state with no host code; a second press on the same element is dropped while it runs — on a Button and on the generic onPress of any element alike. Inside a Repeat, spell the per-element key with the TEMPLATE id (actions.pending.row-cta); each row resolves it to its own materialized element. A custom action counts as a way off the screen only when the resolve path (onResolve or anything after the action) AND the failure path (onError or 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 downstream nextStep rule can branch on; the value is visible to the same-tick branch evaluation. With valueMode: "expression" the value is evaluated rather than stored verbatim. Beyond + - * / and parens it has a function stdlib: numeric min(a,b,…) max(a,b,…) abs(a) round(a[,digits]) clamp(a,lo,hi); dates addDays(date,n) format(date,spec[,locale])date is an ISO string (what DatePicker stores) or the "now" sentinel, and spec is DatePicker's Intl format vocabulary, either a bare dateStyle name ("medium") or key:value pairs ("weekday:long, day:numeric"), NOT a YYYY-MM-DD token language; listing list(x[,conjunction]) ("A, B and C") join(x[,separator]) count(x) plural(n,one,other)x is an untagged multi-select variable and its member LABELS are used (an explicit kind:"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, because plural({{age}}, ...) is what that wanted — and neither is a value that parses as JSON without being a string[] ("[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 and Text 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 broken format(...) shows as a blank, not as literal source text. An UNSET variable reads as 0 wherever it is data (so {{counter}} + 1 works before the first press, and count({{skipped}}) is 0) but is REFUSED as a clamp bound or a round digit 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 is max({{seeded_zero}}, {{absent}}). A {{ref}} inside a quoted LITERAL is guarded the same way when it is configuration — join({{goals}}, "{{sep}}") with sep unset used to run the members together — while count() is deliberately exempt, since count({{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)" (Goals is 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 needs valueMode: "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 from min(s). There is NO implicit concatenation, though: list({{goals}}) and more is a FAILED call (blank + warning), not prose — write list({{goals}}) + " and more". With arrayOp the target is treated as a CheckboxGroup-style multi-select (JSON-encoded string[]) and value/label are the single member to append (add, dedup), remove (drop), or toggle (flip — matches a checkbox tap); the stored label stays comma-joined like a real checkbox and kind is ignored. This lets any element (via onPress) add or remove a chip from a multi-select without a CheckboxGroup widget.

  • { type: "purchase", product: string, onSuccess?: ButtonAction[], onCancel?: ButtonAction[], onError?: ButtonAction[] } — buys a resolved store product. product is a product slot key (e.g. "yearly") or an interpolable {{var}} ref — typically the value a RadioGroup bound to variableName: "plan" just wrote. A {{var}} ref resolves to the variable's underlying value (the machine key), not its label — so a RadioGroup item 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. Runs onSuccess when the purchase completes, onCancel when the user backs out, onError on 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. Runs onSuccess when a purchase is restored, onNothingToRestore when the user had nothing to restore, onError on 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 pending present() 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 via console.warn and does nothing if no PaywallProvider is 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 nested ButtonAction[], recursed exactly like purchase'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.

    kind is one of six, each asked through an optional Expo peer dep the app installs itself (nothing is bundled):

    kindneeds
    notificationsexpo-notifications
    appTrackingTransparencyexpo-tracking-transparency
    locationWhenInUseexpo-location (foreground only)
    cameraexpo-camera
    microphoneexpo-audio, or expo-camera
    photoLibraryexpo-image-picker, or expo-media-library

    Not terminal in itself — put "continue" inside a hook to advance. onGranted or onDenied with no actions declared logs a console.warn rather than silently doing nothing. onUnavailable is the "this build cannot ask" branch — the module is not installed, or the platform has no such permission. Omitted, the runtime does not run onDenied in its place: nobody refused anything, so writing a refusal (a setVariable, an analytics event, a renderWhen gate 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 a console.error naming the missing module. Reusing the author's own outcome matters on a Paywall step: 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 declared onUnavailable always 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.

    requestPermission on the host is an override for the six kinds above, not a way to add a seventh. A host passes its own resolver via OnboardingPage's requestPermission prop (or PaywallHost's, for a paywall presented through present()) and returns undefined for 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 because kind is a closed enum, a payload naming "healthKit" or "screenTime" fails invalid_union and 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; ButtonAction is a plain union, so an app whose SDK predates the action fails the payload with invalid_union and the whole screen falls back to the error boundary — no CTA, and no back chevron on a displayProgressHeader: false step. 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 as react-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/restore always 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 a function name with no matching key in customActions. All three log via console.error, run onError if it is declared, and let the rest of the chain run — the same as purchase/restore. Put what must only happen on success in onResolve.
  • 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 with disabledWhen never comes back.
  • purchase/restore need a ProductProvider configured on OnboardingProvider (or, for a paywall, on PaywallProvider — see below). Without one — or when the runtime resolves { status: "error" } and no onError list is declared — the action logs via console.warn instead 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:

PropTypeNotes
variableNamestringContext key — selected date stored as ISO 8601 string (value) and locale-formatted string (label, e.g. "Apr 23, 2026")
defaultValuestringISO date string, or "now" for the current date/time at render; used as initial value if no persisted value exists
minimumDatestringISO date string, or "now" for the current date/time at render — earliest selectable date
maximumDatestringISO 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"
textColorstringPicker text color; defaults to theme.colors.text.primary
accentColorstringPicker accent/highlight color; defaults to theme.colors.primary
localestringBCP 47 locale tag (e.g. "fr-FR") — applied to both the native picker and the formatted label
formatobjectIntl.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 / heightnumberContainer dimensions
margin / marginHorizontal / marginVerticalnumber
padding / paddingHorizontal / paddingVerticalnumber
borderWidth / borderRadius / borderColornumber | stringContainer border
opacitynumber
Android

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.

PropTypeNotes
variableNamestringContext key — selected entry is written to shared composableVariables (value = item value, label = item label)
defaultValuestringMust match one of the available item values; seeds the variable on mount
itemsArray<{ 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
itemColorstringText color of the wheel items; defaults to theme.colors.text.primary
itemFontSizenumberFont size of the wheel items (iOS itemStyle)
itemFontFamilystringFont family of the wheel items (iOS itemStyle)
alignSelf"auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
width / heightnumberContainer dimensions
margin / marginHorizontal / marginVerticalnumber
padding / paddingHorizontal / paddingVerticalnumber
borderWidth / borderRadius / borderColornumber | stringContainer border
opacitynumber

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.

PropTypeNotes
variableNamestringContext key — the current value is written to shared composableVariables as a float string (value)
defaultValuenumberInitial value; defaults to min (else 0). Seeds the variable on mount if variableName is set
minnumberMinimum value; defaults to 0
maxnumberMaximum value; defaults to 1
stepnumberStep increment; 0 (default) means continuous
minimumTrackTintColorstringColor of the filled (left) portion of the track; defaults to theme.colors.primary
maximumTrackTintColorstringColor of the remaining (right) portion of the track
thumbTintColorstringColor of the draggable thumb
disabledbooleanDisables interaction
All BaseBoxPropswidth, 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.

PropTypeNotes
variant"linear" | "circular"Defaults to "linear"
variableNamestringBound 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
valuenumberStatic value (in [minValue, maxValue]) used when no variableName is set
autoplaybooleanAnimate from initialValue to maxValue on mount
loopbooleanRepeat the autoplay animation
initialValuenumberStarting value (in [minValue, maxValue]); defaults to minValue
minValuenumberLower bound of the value range; defaults to 0
maxValuenumberUpper bound of the value range; defaults to 100
stepnumber (> 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
labelSuffixstringAppended after the label value; defaults to "%". Set "" or a unit (e.g. " kg") for non-percentage ranges
durationnumberAnimation duration in ms; defaults to 1000
delaynumberDelay in ms before the animation starts; defaults to 0
easing"linear" | "ease-in" | "ease-out" | "ease-in-out"Defaults to "ease-in-out"
colorstringProgress fill color; defaults to theme.colors.primary
trackColorstringTrack color; defaults to theme.colors.neutral.lower
thicknessnumberBar height (linear) / ring stroke width (circular)
sizenumberCircular diameter in px; defaults to 120
showLabelbooleanShow the value label (value + labelSuffix)
labelColorstringLabel text color; defaults to theme.colors.text.primary
All BaseBoxPropswidth, 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.

PropTypeNotes
tonumberRequired. End value of the count
fromnumberStart value; defaults to 0
durationnumberAnimation duration in ms; defaults to 1000
delaynumberDelay in ms before the count starts; defaults to 0
easing"linear" | "ease-in" | "ease-out" | "ease-in-out"Defaults to "ease-out"
autoplaybooleanAnimate on mount; defaults to true
loopbooleanRepeat the count indefinitely; defaults to false
decimalsnumber (int ≥ 0)Decimal places in the displayed number; defaults to 0
thousandsSeparatorstringGrouping separator for the integer part (e.g. 1,028,709); defaults to ",". Set "" to disable grouping
fontSize / fontWeight / fontFamily / fontStyle / color / textAlign / letterSpacing / lineHeightText styling, same as Text (color defaults to theme.colors.text.primary)
All BaseBoxPropswidth, 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).

PropTypeNotes
contentstringRequired. 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"
presetentering presetReanimated 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"
durationnumberPer-character animation length in ms; defaults to 400
delaynumberLead-in in ms before the first character animates; defaults to 0
staggernumberGap 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
loopbooleanReplay the reveal on a loop (remounts the characters each cycle); defaults to false
loopDelaynumberPause in ms between loop cycles (only when loop); defaults to 1200
cursorbooleanShow a blinking typewriter caret after the text; defaults to false
cursorCharstringCaret glyph when cursor is on; defaults to "|"
reserveSpacebooleanLay 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 / lineHeightText styling, same as Text (textAlign maps to row justification)
All BaseBoxPropswidth, 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.

PropTypeNotes
variableNamestringReceives the SVG path d string; label = "Drawing captured" once drawn (empty string when cleared)
imageVariableNamestringReceives a base64 data URI (data:image/png;base64,…). Omit if you only need the SVG
strokeColorstringPen color; defaults to theme.colors.text.primary
strokeWidthnumber (> 0)Pen width in px; defaults to 2
backgroundColorstringCanvas fill (also painted into the exported image); defaults to theme.colors.neutral.lowest
clearablebooleanShow 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"
clearButtonOffsetnumberDistance (px) from the two nearest edges; defaults to 8
clearButtonSizenumberClear button diameter in px; defaults to 32 (glyph scales to half)
clearButtonColorstringClear button background; defaults to theme.colors.neutral.higher
clearButtonIconColorstringClear button glyph color; defaults to theme.colors.text.opposite
clearButtonLabelstringClear button glyph/label; defaults to "✕"
imageFormat"png" | "jpeg"Encode format for imageVariableName; defaults to "png"
All BaseBoxPropswidth, 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.

PropTypeNotes
dataArray<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
asstringPrefix the row's fields are exposed under; defaults to "item"
keyFieldstringRow field used for each materialized element's id suffix and React key (cardcard__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:

PropTypeNotes
carouselType"normal" | "left-align" | "parallax" | "stack"Layout mode — see below; defaults to "normal"
autoPlaybooleanAuto-advance slides; defaults to false
autoPlayIntervalnumberMilliseconds between auto-advances; defaults to 3000
loopbooleanLoop back to first slide after last; defaults to true
showDotsbooleanShow Pagination.Basic pill dots; defaults to true
dotColorstringInactive dot color; defaults to theme.colors.neutral.low
activeDotColorstringActive dot color; defaults to theme.colors.primary
dotWidth / dotHeightnumberInactive dot size; default 20 / 4
activeDotWidth / activeDotHeightnumberActive dot size; default to dotWidth / dotHeight when unset
dotsGapnumberGap between dots; defaults to 8
dotsPosition"top" | "bottom"Render dot row above or below the carousel; defaults to "bottom"
dotsMarginTop / dotsMarginBottomnumberDot-row margins; default 12 / 0
defaultIndexnumber | nullSlide shown on mount; only applied at mount
variableNamestringWrites the settled slide index to the variable store on snap (0, 1, 2) — use for branching, analytics and {{var}} text
progressVariableNamestringPublishes 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
heightnumberSlide height in dp; defaults to 220
widthnumberSlide width; defaults to useWindowDimensions().width (overridden for stack and left-align)
borderRadiusnumberApplied to each slide's inner View
All BaseBoxPropsmargin, padding, alignSelf, borderColor, borderWidth, opacity, etc. applied to outer container

carouselType modes:

ModeWidthLibrary propDescription
"normal"100 % windowFull-width paged carousel
"parallax"100 % windowmode="parallax"Depth-zoom effect on adjacent slides
"stack"75 % windowmode="horizontal-stack"Stacked card effect; multiple slides visible
"left-align"82 % windowNext 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 }:

Elementvaluelabel
InputRaw text string
RadioGroupSelected item value (e.g. "monthly")Selected item label (e.g. "Monthly")
CheckboxGroupJSON.stringify(string[]) of selected valuesComma-joined display labels (e.g. "Health, Fitness")
DatePickerISO 8601 string (e.g. "1990-01-01T00:00:00.000Z")Locale-formatted date (e.g. "Jan 1, 1990")
WheelPickerSelected item value (e.g. "70")Selected item label (e.g. "70 kg")
DrawingPadSVG path string (variableName) / base64 image data URI (imageVariableName)"Drawing captured" when drawn (SVG var only)
SliderCurrent 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"
}
}
]
note

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:

OperatorTrue when
is_emptyvalue is an empty/whitespace string, an empty array, or unset/null
is_not_emptynegation of is_empty
is_nullvalue is unset or null only (a set-but-empty "" is not null)
is_not_nullnegation 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.condition
  • renderWhen — 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 & end nodes
  • Start — set configuration.startStepId to the id of the step the flow should begin on (optional; defaults to the first step). Resolve it with resolveStartStepNumber / the useOnboardingStart() hook.
  • End — set a nextStep.defaultTargetStepId or any branches[].targetStepId to the reserved sentinel "__END__" to end the onboarding from any step (no trailing screen). resolveNextStepNumber returns null for it, and the host fires onComplete via completeOnboarding(). See Core Concepts → Start & End Nodes.

Icon props:

PropTypeNotes
namestringRequired — Lucide icon name e.g. "Star", "Heart", "CheckCircle"
sizenumberIcon width & height in dp; defaults to 24
colorstringStroke color; defaults to theme.colors.text.primary
strokeWidthnumberLine stroke weight; defaults to 2
fillstringFill 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).
fillOpacitynumber0–1. Lower values tint the fill against the stroke. Defaults to 1.
width / heightnumberWrapper View dimensions
margin / marginHorizontal / marginVerticalnumber
padding / paddingHorizontal / paddingVerticalnumber
borderWidthnumber
borderRadiusnumber
borderColorstring
opacitynumber

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:

PropTypeNotes
sourcestringRequired — remote .json URL
widthnumberDefaults to "100%"
heightnumberDefaults to 200
autoPlaybooleanDefaults to true
loopbooleanDefaults to true
speednumberPlayback speed multiplier
opacitynumber
margin / marginHorizontal / marginVerticalnumber
padding / paddingHorizontal / paddingVerticalnumber
borderWidth / borderRadius / borderColornumber | stringApplied via a wrapping View

Dependencies: lottie-react-native

npx expo install lottie-react-native
tip

If lottie-react-native is not installed, the element renders a placeholder view with an install hint instead of crashing.

Rive props:

PropTypeNotes
urlstringRequired — remote .riv URL
widthnumberDefaults to "100%"
heightnumberOptional. When omitted, aspectRatio sizes the box
aspectRationumberUsed 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
autoplaybooleanDefaults to true
fit"Contain" | "Cover" | "Fill" | "FitWidth" | "FitHeight" | "None" | "ScaleDown" | "Layout"
alignment"TopLeft" | "TopCenter" | "TopRight" | "CenterLeft" | "Center" | "CenterRight" | "BottomLeft" | "BottomCenter" | "BottomRight"
artboardNamestringTarget artboard in the .riv file
stateMachineNamestringState machine to drive
opacitynumber
margin / marginHorizontal / marginVerticalnumber
padding / paddingHorizontal / paddingVerticalnumber
borderWidth / borderRadius / borderColornumber | stringApplied via a wrapping View

Dependencies: rive-react-native

npx expo install rive-react-native
tip

If rive-react-native is not installed, the element renders a placeholder view with an install hint instead of crashing.

Video props:

PropTypeNotes
urlstringRequired — remote video URL
widthnumberDefaults to "100%"
heightnumberDefaults to 200
autoPlaybooleanStart playing on mount; defaults to false
loopbooleanLoop playback; defaults to false
mutedbooleanMute audio; defaults to true (required for autoplay on iOS)
controlsbooleanShow native playback controls; defaults to false
opacitynumber
margin / marginHorizontal / marginVerticalnumber
padding / paddingHorizontal / paddingVerticalnumber
borderWidthnumberApplied via a wrapping View
borderRadiusnumberApplied via a wrapping View
borderColorstringApplied via a wrapping View

Dependencies: expo-video

npx expo install expo-video
tip

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" } }
}}
/>

Learn more about theming →

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