@rocapine/react-native-onboarding-ui
Changelog
All notable changes to @rocapine/react-native-onboarding-ui are documented
here.
[Unreleased]
Added
-
requestPermissionButtonAction dispatch (#196) — the renderer half of the new headless action.elements/permissions.tsasks through whichever optional Expo module is installed (expo-notifications,expo-tracking-transparency,expo-location,expo-camera,expo-audio,expo-image-picker,expo-media-library— all newly declared as OPTIONAL peer deps), andrunActionsroutes the answer toonGranted/onDenied/onUnavailable.Two decisions worth knowing, because both depart from a nearby precedent:
- The
requireis lazy, not module-level likehaptics.ts. Requiring seven native modules on any screen that merely renders a Button would pull in import-time side effects (notification handlers, listeners) nothing on that screen asked for. - A missing module is not a silent no-op. Haptics can vanish unnoticed; a
permission gate can be the only thing between the user and the next screen.
Absence resolves a distinct
"unavailable"outcome.
New
ScreenHost.requestPermissionoverrides the bundled resolver per kind; returnundefinedfor a kind you do not handle and the bundled one runs. It is reachable from every surface that renders authored elements — the newrequestPermissionprop onOnboardingPage(which forwards it to aComposableScreenstep AND to aPaywallstep in flow position) and a matching prop onPaywallHostfor a paywall presented throughpresent(). It overrides the six kinds; it does not add a seventh, sincePermissionKindSchemais closed — HealthKit / Screen Time need the kind added to the headless schema before any resolver can be reached for them.New exports:
PermissionResolver,PermissionOutcome,PermissionModuleCandidate,PermissionKind— a host implementing the documented HealthKit / Screen Time escape hatch has to be able to name the resolver's own types.Optional means optional to Metro, and that is a syntax requirement. Each module is
required directly inside a literaltryblock, from a name-keyed table of loaders (permissionModuleLoaders). Metro marks a dependency optional only when the firstBlockStatementwithin three statements above the call is aTryStatement's own block (isOptionalDependency,@expo/metro-config), so the first version of this file — which put the samerequires inside arrows that a try/catch helper invoked — made all seven MANDATORY: the example app failed to bundle on ios, android and web withUnable to resolve module expo-notifications, and any consumer app that had not installed all seven would have done the same. The shape is now asserted at source level (Runtime/__tests__/permissionModules.test.ts), because nothing observable at runtime distinguishes the two forms. Found in review round 1 of #196."unavailable"no longer borrowsonDenied, and no longer dead-ends. Review round 1, findings 1 and 2. The first version ranonDeniedwhenonUnavailablewas absent, which failed in both directions at once: it executed the refusal branch —setVariables, analytics, whatever arenderWhenorresolveNextStepNumberlater reads — for a user who was never asked, signalled only by aconsole.warn; and it rescued nobody whenonDeniedwas absent too, so anonGranted-only CTA on a build with none of the optional modules logged one error and did nothing, for 100% of that build's users, on a screen that may have no back chevron either. Now: a declaredonUnavailablealways wins; otherwise the runtime writes nothing and completes the screen whencompletingActionKindsays the ask was the press's way forward, using that same completing action — the ask's own{dismiss}in preference to its"continue"— and logs aconsole.errornaming the missing module either way. Thedeniedoutcome deliberately gets no such rescue: "stay here until you allow it" is authored intent, expressed by omittingonDenied.The outcome of that substitution is load-bearing, and round 1 got it wrong (review round 2, finding 1). It called
onContinue()with no outcome, which is the one call every host reads as "advance" — includingPages/Paywall/Renderer's hard gate, sinceshouldAdvanceOnComplete(undefined)istrue. An ask authored{dismiss}on both branches, on aPaywallstep whose author wrote no way past the paywall at all, therefore handed the gated content to every user of a build that had not installed the optional module, signalled by a singleconsole.error. Nobody was asked anything, so the substitute is now the LESS permissive of the outcomes the author authored: free on the surfaces where the two coincide (an onboarding step ignores the outcome and still advances; apresent()ed paywall resolves as dismissed), and the gate holds on the one where they do not.A terminal action nested in a hook now ends the whole press. Also review round 1 (finding 4), and see
### Changedbelow — this alterspurchase/restorefor payloads already published.runActionsreturns whether the press completed the screen and every recursion propagates it, so[{requestPermission, onGranted:["continue"]}, "continue"]— a defensive trailing escape, whichhasCompletingActionaccepts either way — advances once instead of twice. It calledonContinuetwice before: a duplicaterouter.pushin the example host, and a silently SKIPPED screen in a host that advances by incrementing an index. Fixed centrally, sopurchase.onSuccess/restore.onSuccess(which had the same defect and no test) are covered too.Runtime/elements/completingActions.tsmirrors the headlessactionsCanCompleteandcompletingActionKindrather than importing them: the packages are joined by a peer-dependency RANGE, so this package's runtime must not branch on the other one's installed build, and the headless index cannot be imported from this Node test suite at all. A parity table inRuntime/__tests__/requestPermission.test.tsholds the two equal — for the boolean AND for the kind, since a divergence in the kind is a paywall gate that opens on one package pairing and holds on another.Runtime/__tests__/hostResolverWiring.test.tsWALKS THE SOURCE TREE for<ScreenRenderercallers rather than filtering its own hardcoded list of three, which is what it did in round 1 and which could never have seen a fourth (review round 2, finding 3 — verified by adding a fourth unwired host builder: every test in the file passed, and now they do not).Not verified on a device. There is no device test framework in this repo (#216 is open) and a system permission dialog cannot be driven headless or in a web preview. Covered: schema round-trip, dispatch against a stubbed resolver and against an injected module loader (including the two-candidate fallbacks for
microphone/photoLibrary), headless↔UI mirror parity for both the action schema andactionsCanComplete, the module-absent path, the resolver reaching all three host builders, and the example app bundling on ios- android with none of the seven installed. Every real grant/deny is unverified until someone runs it on hardware.
- The
Changed
-
A
"continue"/{dismiss}nested in apurchaseorrestorehook now ends the whole press (#196, review round 1 finding 4 / round 2 finding 7). This changes what an ALREADY-PUBLISHED payload does, so it is called out here rather than only under### Addedfor the new action that exposed it.runActionsrecursed intoonSuccess/onCancel/onError/onPending/onNothingToRestoreand discarded the result, so the outer list carried on after a nested terminal action. Two consequences, both real:[{purchase, onSuccess:["continue"]}, "continue"]calledonContinueTWICE — a duplicaterouter.push, or a silently skipped screen in a host that advances by incrementing an index. This is the defect.[{purchase, onSuccess:["continue"]}, {custom:"trackPurchase"}]rantrackPurchaseafter the screen was already gone, and no longer does. If you rely on that ordering, move the action INTO the hook, before the"continue", where it always ran and still runs.
The narrower reading — "a terminal action ends only the list it is in" — was rejected because a nested
"continue"genuinely completes the screen, and nothing after that point has a screen to act on. -
A throwing
customhandler aborts its own action list, not the whole press (review round 2, finding 2). Unchanged from before #196, and stated because round 1 briefly changed it: the abort returnedtrue, which propagated out of every recursion. A throwing analytics call inpurchase.onSuccessthen ate the trailing"continue"and left a user who had ALREADY PAID sitting on the paywall, where pressing again re-ranpurchase(). The return value means "the screen was completed"; a thrown handler leaves it very much present, so it returnsfalseand an outer list — including the author's own escape — still runs.
[1.75.0] - 2026-09-07
Added
-
A function stdlib for
setVariable valueMode: "expression"— date maths, clamping, and grammatical listing, so a "your goal date is 3 April" or a "2 goals: sleep and energy" headline is authored instead of hand-rolled in app code. The engine could tokenize{{var}}, numeric literals, parens and+ - * /and nothing else: a leading letter failed to tokenize, and the whole template then degraded silently to plain interpolation, so the only rounding an author had was an incidentalMath.truncon int-tagged values.Eleven functions, over new string-literal and comma tokens: numeric
min,max,abs,round(a[, digits]),clamp(a, lo, hi); datesaddDays(date, n),format(date, spec[, locale]); listinglist(x[, conjunction]),join(x[, separator]),count(x),plural(n, one, other). Two rules shape it. Dates reuse what exists — a date is an ISO string (whatDatePickerstores) or the"now"sentinelDatePickeralready accepts, andformat's spec vocabulary is theDatePicker.formatprop's Intl subset, so there is no second date-format language and noYYYY-MM-DDtokens. A multi-select resolves to its member labels, matching interpolation's label-first precedence — as does a scalar answer — while string concat of the same variable still yields its raw value exactly as before. "Multi-select" means an untagged entry, which is whatCheckboxGroupandarrayOpwrite; an entry explicitly taggedkind: "string"is taken at its word and read as raw values.It runs at press time only. The engine has one call site — a
setVariableaction — and actions only run from a press handler.Text mode: "expression"interpolates rather than evaluating, so a computed headline must be written to a variable by an earlier press and then plainly interpolated; there is no render-time filter syntax.A quoted literal's contents interpolate, so
{{var}}means the same thing everywhere in a template:list({{goals}}) + " for {{name}}"reads "… for Ada" instead of emitting the braces to the user. A literal with no{{passes through byte-identical, which is the common case for a spec, a separator or a plural form; one that references a variable which does not exist is refused in those positions rather than silently becoming empty.pluralchecks its COUNT and both forms — both forms because an absent reference in a form is an authoring error whichever one the count selects, and the count because otherwise an unseeded one picks a form silently andplurallaunders it into whatever consumes the result.format's locale is guarded too, and it is the nastiest of the set:"en{{sfx}}"withsfxunset resolves to the valid"en"rather than to nothing, so the date still rendered with its day and month swapped.Two known limits, both deliberate:
count()does not taint, becausecount({{skipped}})= 0 is a real answer and the shippedplural(count({{goals}}), …)pattern depends on it — so wrapping a name incount()defeats the guard — andasDatestill accepts anyDate.parse-able string, including a bare integer, which no taint can reach because the numbers involved are seeded. Both are filed rather than hidden. One edge moves relative to before the stdlib: a template that is entirely one quoted string is a literal now, so"{{name}}"storesAdarather than"Ada"with the quotes — the quote characters are delimiters, not content.Failure is loud, which is the one behaviour change. A template with no call still falls back to plain interpolation, because
"Hello {{name}}"is a legitimate expression-mode value — and so is"{{n}} day(s)". "Attempts a call" is a property of the token stream rather than of the substringword(: it needs a stdlib name (or an unknown name glued to its(, i.e. a probable misspelling) in front of parentheses whose contents could actually be arguments. A bare word is never a legal argument, so one between the parens means they are punctuation —"{{n}} day(s)"and"{{n}} min(s) left"are prose even thoughminis a real function — and whitespace before the(means the same for an UNKNOWN name, so"Goals ({{n}})"and"Save (50)"are prose too. A stdlib name is called glued or not, so"max (2)"is a call; prose starting with one needsvalueMode: "literal". A misspelledaddDay({{d}}, 1)still fails loudly. So does a valid call with prose beside it: this grammar has no implicit concatenation, solist({{goals}}) and moreis a broken call rather than prose and must be writtenlist({{goals}}) + " and more"— otherwise the evaluator's own source text ends up in the variable. A template that attempts a call and fails stores the empty string and warns once, rather than interpolating broken source text into a variable a headline would then display verbatim. The one residue: prose whose only word is glued to a parenthesised value with no space ("Save(50)") still reads as a misspelled call and blanks; add the space. The same rule applies wherever the alternative was a believable constant: a value that parses as JSON but is not astring[]("[1,2,3]",{"a":1}) fails the list helpers instead of counting as one member, a variable holding a number failscount()rather than answering 1, and anaddDaysoffset that lands outside the representableDaterange fails instead of throwing aRangeErrorout of the press handler.One asymmetry inside that rule, because it decides a real case. An absent variable still reads as numeric 0 wherever it is data — that sentinel is what makes increment-before-seed arithmetic and
count()on a screen the user skipped work — but it is refused wherever it is configuration: aclampbound or arounddigit count. Answering from the sentinel there turned a typo'd variable name into a plausible constant with no warning:clamp({{score}}, {{floor}}, {{ceiling}})reported 0 for a score of 42, because0 > 0is false so the range check passed;clamp({{score}}, {{floor}}, 3)reported 3; andround(42.75, {{digits}})reported 43. All three now warn and store the empty string.clamp's first argument stays data, so an untouched counter still clamps to its floor. 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, and so ismax({{seeded_zero}}, {{absent}}). The same rule now covers a{{ref}}inside a quoted LITERAL used as configuration:join({{goals}}, "{{sep}}")withsepunset ran the members together andlist({{goals}}, "{{conj}}")left a double space, both silently.count()stays exempt on purpose —count({{skipped}})is a real zero — which does meanround({{pct}}, count({{digits}}))answers rather than failing. A day count is configuration too:addDays("now", {{trialDays}})withtrialDaysunset used to return the start date unchanged, so a headline reading"your trial ends {{trialEnd}}"showed today. A free-text answer that merely looks bracketed ("[not json]") is still one member. Where a machine key would reach prose because a member label cannot be recovered —labelis the ", "-joined member labels and one of them contains ", " too — the list helpers keep using the raw values (an empty sentence is worse for the end user) but say so in a warning.Three more places where the answer was plausible rather than right, all found by review and each now pinned by a test that fails when its guard is deleted. A stdlib call with a bare-word argument —
count(goals), the braces forgotten, orcount(goals) + " goals"— used to store its own source text into a variable a headline then displayed, with no warning; it still keeps the text (it cannot be told apart from the{{n}} min(s) leftidiom, and blanking real copy would be worse) but now warns and names the arguments to brace. Aformatspec made only of hour modifiers ("hour12:true") selected no component at all, sotoLocaleStringfell back to a full date+time; it is rejected. And both{{var}}resolvers now trim a spaced reference like the expression tokenizer already did, so{{ plan }}no longer renders in aTextwhile resolving to an empty product slot key in apurchaseaction.Two smaller robustness fixes with observable edges.
+and-now carry theNumber.isFinitecheck*and/always had, so an overflowing sum falls back to plain interpolation like every other failed arithmetic instead of storing the literal string"Infinity"taggedkind: "int"— which every downstream reader parsed asNaN. Reaching it needs a ~308-digit literal. And variable lookup useshasOwnProperty, so{{toString}}and{{valueOf}}no longer findObject.prototypemembers:{{toString}} + 1used to throw aTypeErrorout of the press handler, which is the dead-button failure this engine is built to avoid.
Fixed
-
An element carrying
flextogether withonPress,animationortransformno longer collapses to height 0. An author writes oneflex: 1;renderElementwas emitting it on every box it wraps the element in — thePressablefor a genericonPress,AnimatedBox's outer view, its inner static-transform view, and the element's own root. In React Nativeflex: Nexpands to{ flexGrow: N, flexShrink: 1, flexBasis: 0 }, so a nested copy contributes zero main size: a wrapper whose own main size is auto measured 0, its children went on painting at full size over whatever followed, and every press target in a row landed on the same point. A tappable or animated card, tile or option authored the obvious way —flex: 1plusonPress— was therefore unusable, and nothing warned: both props are legal, and it does not reproduce on react-native-web, so a Studio canvas or web preview showed the screen correct right up to the device. (The divergence is intrinsic sizing: CSS resolves an auto-sized flex container from its items' max-content contributions, Yoga from their flex base sizes, and aflex: Nitem's base is 0.)The rule now, owned in one place (
Runtime/elements/wrapperLayout.ts): the outermost box carries the parent-facing props —flex,flexGrow,flexShrink,alignSelf— and every box below it fills withflexGrow: 1+flexShrink: 1, neverflex. The five renderers that nest their own box inside aGradientBoxfill the same way, and the element renderers themselves are unchanged: they still readprops.flex, and the renderer hands them a demoted element.Two things to know when you upgrade.
flexGrow: 1was the working workaround and still behaves exactly as before. ButflexGrow(orflexShrink) together withanimation/transformnow takes effect —AnimatedBoxnever forwarded those, so they sat inert on the inner box and the wrapper stayed content-sized; a screen relying on that will see those elements grow into their row.flexGrow+onPressis unaffected. -
A
Textwith bothflexand abackgroundGradientno longer renders an empty pill. The gradient fork nests the<Text>inside aGradientBoxthat carries the box layout, and every parent-facing key on that inner text was already suppressed under a gradient —flexGrow,alignSelf,width,height,minWidthall readp.backgroundGradient ? undefined : …. Exactly two were missed:flexandflexShrink. So the inner text gotflexBasis: 0and measured 0, while theGradientBoxtook its height from padding alone — nothing was line-determined, and nothing reserved space for the glyphs. The gradient band rendered with no label in it at all, and an author had no way to diagnose that: the payload is valid, the element is present, the text is simply invisible. Measured on device, the twoTextnodes had no layout frame at all before andh=16.0after, with everything below the row one line height higher.Independent of the collapse above — it needs no wrapper and bit an unwrapped, plainly authored element — and fixed by the same rule: the inner text now takes the fill contract instead of the parent-facing
flex. -
An element gated on
{{ref}}no longer vanishes on the UI thread.renderWhenhas two evaluators: the store-backedevaluateCondition, and the UI-thread fast path inRuntime/elements/animatedGate.tsthat lets an element react to an animated numeric sweep without a re-render per frame. Now that the headless evaluator resolves{{name}}references in a condition's value, the fast path had to resolve them to the same number — it did not.toScalarpassed the string through unchanged,"{{threshold}}"reached the worklet, andparseFloatturned it intoNaN: every comparison false.renderElementseeds the gate's visibility from the (correct) headless evaluator and the animated reaction then overrides it, so a visible element silently disappeared — worse than the uniformly-broken behaviour before, because the two paths now disagreed.References are resolved at both
toScalarcall sites, the single leaf and the one-level and/or group, since fixing only the leaf would leave the band form (gte lo AND lt hi) broken — and that is the shape a threshold-driven loader actually authors. An unresolved or non-numeric reference still returnsnulland falls back to the store path, so the two evaluators agree on the one case where there is no number to compare.The variable map is threaded into
buildAnimatedGatePlanrather than a reference simply disqualifying the plan, because for this shape falling back is a regression and not the free "no fast path" the file documents elsewhere: an autoplayProgressIndicatorwrites its bound variable to the store at the sweep boundaries only, so a mid-sweep threshold evaluated against the store never fires at all. To keep that from costing anything, the plan is memoized on a newanimatedGateRefKey— it changes only when a value one of this condition's references resolves to changes, and is""for a condition holding no reference, so the common case still keys onelementalone and the gate'suseAnimatedReactionmapper is not rebuilt on unrelated writes.Button props.disabledWhenandRichTextchild gating call the headless evaluator directly, so they gain variable-to-variable comparison from the same change. Refs #217. -
The ComposableScreen renderer now survives an element type this build does not know, and never leaves the user with nothing to press. It parsed the step with a throwing
ComposableScreenStepTypeSchema.parseinsidewithErrorBoundary, so a single unknown element type took the whole screen — and the fallback has no interactive control. Unknown element types are now omitted before that parse (the strip itself is in the headless package) andconsole.warned. Not dev-gated: a published screen running ahead of the installed SDK is precisely what a host needs to see in production logs.Keyed to this package's own element union, not the headless one.
getRenderableElementTypes()derives from theUIElementSchemamirror inUI/Runtime/types.ts— the schema that actually parses the payload and backsrenderElement's dispatch. The two packages are joined by a peer-dependency range, so an installed app can resolve them at different versions, and keying the strip on the other package's list is wrong in both directions: strip an element this build can draw (warning that a known type is unknown), or keep one it cannot and throw the whole screen anyway, as before the fix.When a strip leaves nothing that can complete the step, the renderer passes
OnboardingTemplateits own themedbuttonand logs aconsole.errornaming the step. The label is a hardcodedContinue, because the element carrying the authored copy is exactly what was stripped, and an untranslated word beats a screen the user cannot leave. A visible CTA rather than an automaticonContinue(): what survived the strip is still authored content worth showing, and auto-advancing would rip through every consecutive screen built on the same new element without the user seeing any of them. It is the answer both existing boundaries already give — an unknown step type renders a Continue button, and a paywall whose elements fail to parse callsonContinue()so the user is not trapped.Paywall parse boundaries are deliberately unchanged and stay strict: a paywall that cannot parse never opens and resolves
{status:"error"}, which beats a full-screen modal missing its purchase or dismiss control.
[1.74.1] - 2026-09-02
Version bump only — this release is entirely in the headless package: the
onboarding's audience params are now pinned when it is served, so a user
property written mid-onboarding no longer blanks and refetches the app, and
useOnboardingStep / useOnboardingStart share the gate's query. Nothing in
this package changed.
[1.74.0] - 2026-08-27
Version bump only — this release is entirely in the headless package (the
userProperties store and register(moment, feature)). Nothing in this package
changed.
Worth knowing anyway, because it changes what a host must wire: audience
targeting now reads a persisted user-property store rather than only the
customAudienceParams prop, so OnboardingProvider holds its query for one
AsyncStorage read on mount. Pass fontsFallback and that frame is already
covered. See the headless CHANGELOG.
[1.73.0] - 2026-08-27
Added
-
Paywallstep renderer (UI/Pages/Paywall/) — renders a paywall in flow position, wrapped inOnboardingTemplatelike any neighbouring step, so the progress header applies and the onboarding advances past it. The third consumer ofScreenRenderer, afterPaywallHostand the ComposableScreen adapter; it is the sibling of that adapter and shares its shape.HARD GATE: only a purchase advances. This needs no purchase tracking, because an authored paywall already distinguishes the outcomes in its action list —
{type:"purchase", onSuccess:[{type:"continue"}]}callscomplete()with no outcome (advance),{type:"dismiss"}calls it with{status:"dismissed"}(stay). A custom screen is handed the same callback, so the gate applies to it identically."pending"does not advance: a Stripe Payment Link resolves pending, meaning unconfirmed, and advancing would grant access for a payment that may never complete. A Stripe paywall on such a step needs anonPendingbranch.Three structural cases SKIP the step with a named diagnosis rather than trap the user — a paywall that cannot appear must not brick a paid funnel: no ancestor
PaywallProvider, a moment absent from a settled catalog (a mis-typed key, an unpublished paywall, or a waterfall that matched nothing), and a paywall that cannot render (elements that fail validation, or an unregistered custom screen). Each log names what was wrong and what was available.A
"revalidating"catalog that lacks the moment waits rather than skipping — it may be about to deliver it, and skipping would lose a sale to a race. Conversely a paywall already in hand renders during a revalidation instead of flashing a spinner. -
resolvePaywallStepDecision/shouldAdvanceOnComplete— the pure halves, exported and unit-tested. Extracted for the same reasonresolvePaywallModalDecisionwas: this package has no render harness, so the decision is where testable behaviour has to live.
Changed
-
Register
customScreensonPaywallProvider, notPaywallHost. The host's prop still works and wins where passed — no existing integration breaks — but it is invisible to aPaywallonboarding step, which renders custom screens itself and never goes throughPaywallHost.PaywallHostnow falls back to the provider's registry when its own prop is absent; the two are deliberately not merged, so "which map is this id missing from" stays answerable. -
CustomPaywallScreenProps/CustomPaywallScreensmoved to the headless package.UI/Paywall/CustomPaywallScreen.tsis now a re-export, so the deep-import path 1.72.0 introduced resolves unchanged. -
UI/types.ts'sOnboardingStepTypeunion gainsPaywallStepType. Worth noting for anyone switching exhaustively over it.
[1.72.0] - 2026-08-27
Added
-
PaywallHostrenders host-registered custom screens. NewcustomScreensprop — a map from the studio'scustomScreenIdto a component — so a paywall the author set to Render mode "Custom screen" draws your own screen instead of an element tree:const SCREENS = { "paywall-native-v2": NativePaywall }; // module scope: stable
<PaywallHost customScreens={SCREENS} />PaywallHost's only prop, and its first. Registered here rather than onPaywallProvider(wherecustomActionslives) because the provider is the headless half and has no business holding a map of React components. -
CustomPaywallScreenProps/CustomPaywallScreens— the contract a registered screen implements:payload(the product map, neverundefined— an absentcustomPayloadon the wire arrives as{}),complete, andpaywall(id/name/moment/customScreenId, enough to report a conversion without also handing over anelementstree it has no use for). No product runtime, deliberately: see the headless changelog.completeMUST be called on every exit path, including your screen's own close button — until it is, the paywall stays active and every laterpresent()resolves"already-presenting". The acknowledgement timeout covers a presentation that never appeared, not one never closed.
Changed
-
A custom screen renders inside the SAME
Modalas an element tree, so it inherits theonShowacknowledgement (the iOS refused-presentation recovery), AndroidonRequestClose, and the nestedSafeAreaProviderwithout doing anything. That is the whole reason this lives in the SDK rather than being left to each host to rebuild. It is also wrapped in the same error boundary, so a crash in the host's own screen resolves"render-error"instead of trapping the user behind an escape-less full-screen Modal. -
resolvePaywallModalDecisiondoes not call the element parser in custom mode — skipped, not merely ignored. Flipping a paywall to custom does not destroy its element tree (so flipping back restores it), and that leftover tree must not be parsed or rendered on the way past. Two new decisions,"show-custom"and"unknown-custom-screen". -
No theme background is drawn behind a custom screen. The registered component owns the whole surface; wrapping it would mean a host fighting a colour it never asked for.
[1.71.0] - 2026-08-26
Added
-
onPendingon thepurchaseButtonAction. A"pending"purchase result now dispatches its own follow-up actions instead of only logging a warning. This is not a rare branch: a Stripe Payment Link purchase always resolves"pending", becausepurchase()opens the link and the browser takes over — so before this, a Stripe buy button could not dismiss the paywall or navigate, and the user returned from Safari to an untouched screen.Deliberately its own hook rather than falling through to
onSuccess: pending means unconfirmed, and routing it to success would let a paywall grant access for a purchase that may never complete. Never grant access fromonPending— read entitlement state.Optional and additive; a purchase action with no
onPendingstill warns, as before.
[1.70.0] - 2026-08-26
Fixed
PaywallHost's parse-error log readactivePaywall.placement, which the rename removed fromPaywall— sobuild:uifailed whilepackages/onboarding's own type-check passed. The two are separate workspaces; only the monorepo-root scripts see across them.
Note
- The
placementargument onpresentPaywall(placement)and on thepresentPaywallButtonAction is unchanged. That is element-contract surface, not thePaywallwire type, and renaming it would touch all five element mirrors.
[1.69.0] - 2026-08-21
Notes
- No UI changes. Version moves in lockstep with
@rocapine/react-native-onboarding, which addscatalogStatus/productsStatustousePaywall()so a host can tell a settled catalog from one that is still being revalidated behind a cache hit. See that package's changelog.
[1.68.2] - 2026-08-21
Fixed
- A
Carousel's pagination dots announced "Slide 1 of 6 - undefined" to screen readers, once per dot. The library builds each dot's accessibility label asSlide ${i+1} of ${n} - ${carouselName}and interpolates it unguarded even thoughcarouselNameis optional (Pagination/Custom/index.tsx:84), so omitting the prop puts the literal string "undefined" into speech.CarouselElementnow passes the element's authoredname— already the human label for the element everywhere else, and it distinguishes two carousels on one screen — falling back to"Carousel"when unnamed, because passing an absentnamethrough would reproduce the same bug. Found on device via an accessibility inspector.
Notes
- Testing 1.68.1's Carousel fix with Metro already running will show the OLD crash. Metro caches module resolution, so after reinstalling
react-native-reanimated-carouselthe app keeps red-boxing until the bundler is restarted — which reads as "the fix didn't land". Restart Metro after the reinstall. (Recorded here because it cost a real testing cycle.) - 1.68.1's narrowed peer range does downgrade an existing install, not just a fresh one: an app that had transitively resolved 5.1.1 dropped to 4.0.3 on reinstall, verified with
npm ls. Caveat on generalising — that app never listed the package in its ownpackage.json, so npm had no direct dependency entry to honour. A host that pinned 5.x explicitly still has to change its own manifest. dotsMarginTopis the gap ABOVE the dots container; the space between the dots and whatever follows isdotsMarginBottom, which defaults to 0. Both are applied to the pagination container itself, not to the slides.
[1.68.1] - 2026-08-21
Fixed
Carouselcould not render at all in a fresh install — red box on device, paywall dead. The peer range forreact-native-reanimated-carouselwas"*"and not optional, so npm resolved the newest major and every fresh install got v5. v4 exports Carousel as a DEFAULT (export default Carousel); v5 removed that and exports named (export { Carousel }), so the default import inCarouselElement.tsxboundundefinedand React threw "Element type is invalid … got: undefined. Check the render method ofCarousel."Paginationis named in both majors and resolved fine, which is exactly what made the failure look like an element bug rather than a dependency one. This repo never saw it because its devDependency pins^4.0.3. Found on a real device; nothing offline could have caught it, because a payload cannot express a runtime peer requirement and the tree was schema-valid throughout.- The peer range is now
^4.0.0, matching the major the code is written against and tested on. That is the whole fix: fresh installs resolve v4 again and the existing default import is correct.
Notes
- How it failed is worse than that it failed. The render error is caught by
PaywallContent's error boundary, which resolvescomplete({status:"error"})— so on a home placement it is a silently missed impression, and a host that falls through to another paywall engine on"error"sees conversion quietly route away with no red box in production. A dead element and an invisible one are not the same severity. - v5 is a migration, not an import fix, and the range must not be widened without it. v5 also drops
autoPlay,autoPlayInterval,snapEnabled,pagingEnabled,modeandmodeConfig— all used by this element and all authored in payloads — and narrowsonProgressChangefrom(offsetProgress, absoluteProgress)to(progress)while the element reads the second argument. It additionally requiresreact-native>= 0.80,react-native-reanimated>= 4.1 andreact-native-worklets. Reasoning recorded at the import so a future "modernize the import" change cannot land alone. - Same class elsewhere, not fixed here. An audit of both packages found many peers still on
"*": required ones includereact-native-safe-area-context,react,react-nativeand@types/react; optional ones includeexpo-image,expo-video,expo-haptics,lottie-react-native,rive-react-native,@react-native-picker/picker,@react-native-community/slideranddatetimepicker. Each needs its own judgement about which majors the code supports, so they are not swept into a bug fix. Separately,react-native-reanimatedis used but declared as a peer nowhere, so a host missing it gets a runtime failure with no npm warning.
[1.68.0] - 2026-08-21
Fixed
- Corrected the comment defending the display-only interpolation constraint added in 1.67.1. The behaviour was and is right —
handleSelect/handleTogglestoreitem.labelraw while only the rendered text is interpolated — but the stated reason was not: it claimed this "keeps{{plan.label}}meaningful", and there is no such accessor. The variable bag is flat, so{{plan.label}}resolves to"". The real reader of a stored label is plain{{plan}}throughinterpolate, which prefers a variable'slabelwhileinterpolateIdentifierprefers itsvalue. That makes the risk larger than described: interpolating before the store would make every{{plan}}in aText— a plain, common way to echo the chosen plan — render "$39.99" where the author meant "Quarterly". A comment that defends a constraint by naming a mechanism that does not exist invites a future refactor to conclude the constraint is vacuous and remove it, so this is a correctness risk in prose rather than a cosmetic fix. Both element files now state the mechanism with the two functions' outputs side by side.
Notes
- No behaviour change in this package. Version moves in lockstep with
@rocapine/react-native-onboarding, which addspricePerDay.
[1.67.1] - 2026-08-21
Fixed
RadioGroupandCheckboxGroupitem text did not interpolate{{variables}}.{item.label}and{item.subLabel}were rendered raw —interpolateappeared nowhere in either file, whileTextElementhas always used it. The practical effect: a price could not appear on a plan card, so a multi-plan paywall (the canonical subscription paywall, and the one job these elements exist for) could not show per-plan pricing, strikethrough comparisons, or any product value at all. Authors were pushed into laying prices out in a parallel row that only aligns while every item happens to be the same width. Both elements already subscribe to the variable store viauseVariables(), so this needed no new plumbing.- Interpolation is DISPLAY-only, deliberately.
handleSelect/handleTogglestill passitem.labelraw intosetVariable(name, { value, label }). Interpolating before the store would put a resolved price into the selected entry'slabel, andinterpolatefavours a variable'slabelover itsvalue— so{{thatVariable}}would silently start rendering a price elsewhere on the screen. The machine-identifier path (purchase.productviainterpolateIdentifier, which readsvaluefirst) is unaffected either way, but only because these two stay display-only.accessibilityLabelinterpolates too — a screen reader must not read a raw template. - Mirrored the discriminated-union conversion in
UI/Runtime/types.ts— see the headless 1.67.1 entry for what it fixes. The UI copy is the onePaywallHostactually parses with, so it carries the crash fix.
[1.67.0] - 2026-08-21
Fixed
- A paywall that failed validation reported nothing at all.
PaywallHostparseselementsbefore opening the Modal (so a malformed payload can never reach the escape-less fullScreen Modal), but the decision kept onlysuccessand threw theZodErroraway — so a paywall that could never render resolved a bare"error"with no log at any level, and the cause was only reachable by fetching the served payload and re-running the schema by hand. It cost two multi-hour investigations to identify a real case by elimination: aButtonauthored withvariant: "plain", which is not infilled|outlined|ghost. The validation error is now carried on the decision and logged with the failing path AND the value the author actually wrote, e.g.0.children.0.children.0.props.variant: Invalid option: expected one of "filled"|"outlined"|"ghost" (authored value: "plain"). Almost always a CMS data bug the host cannot fix and the author must, so the message says so. PaywallHostnow confirms the paywall actually appeared, via the Modal'sonShow→acknowledgePresentation. Without that signal, a presentation the platform silently refused was indistinguishable from one the user was reading, and the refused case wedged every laterpresent()call for the life of the process — see the headless 1.67.0 entry.parse-errorandrender-errornow reach the caller as reasons. Both went through theScreenHostnarrowing wrapper, which reduces an outcome to{status}and dropped the reason; they now resolve the pendingpresent()directly.
Added
describePaywallParseError(error, elements?)— renders a Zod failure as one line naming the offending paths and the authored values. Exported and pure because this package has no render harness, and the entire value of the message is that it is precise. It walks the issue tree: the element schema is a 26-member union of unions, so the top-level issue is alwaysinvalid_union/ "Invalid input" at the array index, and reporting only that prints0: Invalid input— no better than the discarded error it replaces. It also ranks paths the author actually WROTE above missing-prop complaints from non-matching variants, which is what separates "your data is wrong" from "you are not a Text element"; in the real case, depth alone surfacedprops.content/url/intensity: expected string, received undefinedwhile the true cause sat at the same depth.
[1.66.0] - 2026-08-19
Fixed
entering.oncedefeated the entrance it was meant to protect (1.65.0, device-confirmed).decideEnteringPlayreturned a two-valued{ play: boolean }, andOnceAnimatedBoxmapped bothfalsecases to "stripentering" — which renders anAnimated.Viewat full opacity. But "already played" and "not settled yet" are opposites: the first must render VISIBLE, the second must render HIDDEN. So a held element sat fully visible with no entrance, then blinked to opacity 0 and re-faded when the hold released — no entrance to see, plus a flash that did not exist beforeonce. Now three states (hold/play/done), withhiddendistinguishingholdfromdonein the OUTPUT rather than only in intent. The hide is applied on the wrapper while no entering builder is attached, and releasing the hold changes the key, so opacity and the builder never coexist and cannot fight.enteringSettleDelayMswas unreachable from the documented entry path. 1.65.0 put it onScreenHost, reasoning that the host is the only party that knows its navigator's transition duration — correct, exceptOnboardingPagebuilds theScreenHostitself, so every consumer entering through it gotundefinedand was pinned to the 350ms default with no override. The escape hatch the 1.65.0 docs point at ("if an entrance still reads early, raise the delay before suspecting the mechanism") could not be taken, which also made the intended diagnosis — telling "the default doesn't match your transition" apart from "the deferral is broken" — impossible. Now threadedOnboardingPage→ComposableScreenRenderer→ScreenHost, followingkeyboardVerticalOffset's existing path exactly.
Added
- A compile-time reachability gate on
OnboardingPage, so this class of defect cannot ship again. EveryScreenHostfield not explicitly declared internally-provided must be settable fromOnboardingPageProps, asserted in the type system with no runtime cost. Adding a new host field now fails the build until it is either threaded throughOnboardingPageor deliberately marked as SDK-owned. Verified against both regressions: removingenteringSettleDelayMsfrom the props (the 1.65.0 bug) and adding an unthreaded host field each produceType 'false' does not satisfy the constraint 'true'.OnboardingPagePropsis now exported, which consumers wrapping the component wanted anyway.
Notes
- The gate exists because nothing else could catch this: the tests exercise
ScreenRendererwith a hand-built host — the one caller for whom every field is trivially reachable — so the consumer path (OnboardingPage→ComposableScreenRenderer→ host) was never the thing under test. Thanks to the consuming session for proposing the check. - A host that renders
ScreenRendererdirectly with its ownScreenHostwas never affected; this only fixes theOnboardingPagepath, which is the one nearly everyone uses. PaywallHoststill hardcodes no delay, so a paywall'sentering.onceuses the default. Left alone deliberately: a modal presentation is a different transition from a stack push, and no one has asked for it — worth revisiting if a paywall ever needs a deferred entrance.
[1.65.0] - 2026-08-19
Added
OnceAnimatedBoxand a screen-scoped entering latch backanimation.entering.once. The latch is a plain mutableSetbehind a stable object, and the decision is derived from a value sampled once per mount — both deliberate:markPlayedruns while the animation is in flight, so a reactive latch (or a live re-read on any unrelated re-render) would flip the decision to "already played", change the wrapper key, remount the element and cut the running animation off at the knees.- The settled signal is a duration, injectable by the host via the new optional
ScreenHost.enteringSettleDelayMs(defaultDEFAULT_ENTERING_SETTLE_MS, 350ms). Treat the default as a starting point, not a measurement — one app using a react-native-screens push shell measured ~520ms for a safe reveal, so if an entrance still reads early, raise the delay before suspecting the mechanism. It is not a framework signal becauseInteractionManager.runAfterInteractionsfails in two opposite ways depending on version, so checking whether it works in yours is not a route back to it: it is stubbed in RN 0.85+ (a baresetImmediate— fires on the next tick and defers nothing), and on earlier versions where it is implemented, its queue reportedly does not drain whilereact-native-screenspush transitions are active (fires late or never — and react-native-screens is the default for a native stack). Separately, RN'sImagehas never registered an interaction handle in any version, so it would not have covered decode either way. The host is the only party that knows its own navigator's transition duration, which is why the knob lives onScreenHost. - Kept in its own
EnteringLatchContextrather than folded intoAnimatedVariablesContext. That registry has the right lifetime and stability, but its contract is "SharedValues a producer animates on the UI thread"; overloading it with an unrelated latch would make its name a lie.
Notes
- Scoped per screen, so "once" means once on that screen and each screen defers its own arrival.
- With no provider above it (a renderer used outside
ScreenRenderer) the context fails open:settled: true, sooncedegrades to "play on first mount, never again" rather than going silent.
[1.64.0] - 2026-08-19
Added
ProgressBarhonoursbackButtonStrokeWidthandpaddingTop. The chevron's stroke weight was hardcoded at2; the header's top padding was the bare safe-area inset. Both are now configurable fromconfiguration.progressHeader, withpaddingTopadded to the inset rather than replacing it so a payload cannot push the header under the notch.
Notes
- Defaults reproduce the previous rendering exactly.
- The reserved right spacer column still cannot be removed — see the headless
1.64.0notes. A fork whose track reaches the right padding edge will see it pull inward, andtrackFlexcannot shrink the column to zero.
[1.63.0] - 2026-08-19
Added
Repeatrenderer — layout-transparent materialization. Returns a fragment rather than a view of its own, so the rows become direct children of the enclosing stack and that stack'sgap/direction/alignment apply per row exactly as if the rows had been hand-written. Its props deliberately do not extendBaseBoxProps: with no view to style, box props would silently do nothing. Row scope is published on two paths, because the runtime reads variables two ways and a row needs both —VariablesContextfor render-time reads ({{item.x}},renderWhen) and a derivedRenderContextwith a wrappedgetVariablesfor press-time reads, sincerunActionsreads the live store ref rather than context. Without the second half a repeated card could be drawn but not answered (asetVariableexpression on{{item.id}}would resolve empty). Ids are suffixed per row so N materializations never collide.ReplayingAnimatedBoxbacksanimation.replayWhen, deriving the wrapper's React key from the watched variable soenteringre-fires on a write. Split fromAnimatedBoxrather than callinguseVariables()inside it: a context subscription bypassesReact.memo, so subscribing in the shared component would re-render every animated element on every variable write.ProgressBarhonoursconfiguration.progressHeader— colours,height,borderRadius,paddingHorizontal/paddingBottom,gap,trackFlex, back-button colour/size/visibility. It reads the config itself viauseProgressHeaderConfig()rather than taking props, because the header is host-rendered and prop-threading would need every host to change code. Resolution is explicit prop → configuration → theme → default; the config outranks the theme because a theme-only knob could not reach the screen at all (nothing in the SDK readsconfiguration.theme). It also styles the back button's container (fill, border colour/width, square size, radius), not just the chevron — an explicitbackButtonContainerSizecentres the glyph and drops the default padding so the chip measures exactly as authored. Every default reproduces the previous rendering;borderRadiusnow derives from the height instead of a fixed10, which is visually identical at the default and keeps a taller configured bar a pill.insetapplied onZStackchildren, dropping the opposite side's0and the shared anchor per positioned axis so the child ends up content-sized and corner-pinned.Imageplain/expression component split, mirroringText, so the static case (nearly every image) keeps memo-skipping on variable writes.Carouselpublishes its normalized swipe position into the screen-scoped animated-variable registry, consumed on the UI thread byGatedElement.progressitself stays raw, becausePaginationand thescrollTo({ count: index - progress.value })arithmetic depend on the library's unwrapped scale.
Changed
RenderContext.renderChildrenaccepts an optional thirdctxOverride.Repeatuses it to render each row against a derived context; every other caller is unaffected.- Peer dependency on
@rocapine/react-native-onboardingtightened to^1.63.0(was^1.23.0).ProgressBarnow importsuseProgressHeaderConfig, which does not exist in earlier headless versions, so the old range permitted a combination that crashes at runtime. The two packages have always shared a version by policy; the range now says so.
Fixed
ErrorBoundary'sonErrornote corrected. It justified withholding an escape callback from the onboarding host with "a back button already exists OUTSIDE that boundary" — which is conditional on the step opting into the progress header.ProgressBar's whole subtree, back chevron included, sits behindisProgressBarVisible(activeStep.displayProgressHeader), so on a header-off step a caught error leaves no exit in either direction. Comment-only; giving the host an escape is a product decision.
[1.62.0] - 2026-08-17
Added
PaywallHost— renders the active paywall in a fullScreen Modal. Mount once as a sibling of the app, alongsidePaywallProvider. It readsusePaywallHost()for which paywall (if any) is active and renders it using the sameScreenRendererengine as aComposableScreenonboarding step — a paywall is authored with the exact same elements andButton.actions. Android hardware back resolves exactly like the in-contentdismissaction, so a user is never trapped inside a paywall; its own nestedSafeAreaProvidermeans an authoredSafeAreaViewmeasures real insets even though aModalpresents into a separate native view hierarchy from the app root.dismissandpresentPaywallpress-action dispatch inrunActions.presentPaywallis wired into the onboarding adapter's ownScreenHosttoo (Pages/ComposableScreen/Renderer.tsx), so apresentPaywallaction fired from an ordinary onboarding step reaches a real paywall host when one is mounted.ScreenElementsSchemais now exported from the package root (alongside the existingUIElement/UIElementSchema) — the schemaPaywallHostparses a paywall'selementswith.
Fixed
ErrorBoundary's Zod-error formatting works again. It readerror.errors, a property that doesn't exist on Zod 4'sZodError(renamed to.issues) — every schema validation failure in aComposableScreenstep or a paywall showed the generic "An error occurred while formatting the Zod error" fallback instead of the actual path/message detail. Fixed; the two@ts-ignoresuppressions that hid the original type error are also removed.
[1.61.0] - 2026-08-13
Added
ScreenHost.products— the renderer can now surface live store prices.ScreenHostandRenderContextgain an optionalproducts?: ProductRuntime.ScreenRendereroverlays the resolved products into the variable bag (viawithProductVariables), so any existing element interpolates them:{{product.yearly.pricePerWeek}},renderWhenonproducts.loaded, and so on. No new element type was needed.purchaseandrestoredispatch inrunActions. Both readctx.products. Follow-up arrays (onSuccess/onCancel/onNothingToRestore/onError) are fullButtonAction[]run through a nested dispatch, so a"continue"insideonSuccessstill works and stays terminal for that nested run.withProductVariables(Runtime/variables.ts) — pure overlay in which product values win over author variables. Prices are facts read from the store; a displayed price must match what the store charges.
Changed
- Product actions never fail silently.
purchaseandrestorewarn rather than no-op when there is no product runtime, when the named product slot is unresolved, when the result ispending(Ask-to-Buy / deferred transactions), and when the result is an error with noonErrorarm declared. A silent no-op is indistinguishable from a working buy button.
Notes
ProductRuntimesits inRenderContext's dependency array, so it must be referentially stable across variable writes —useProductsguarantees this. An unstable one re-renders every memoized element on every write, and no type error or test in this repo catches it. The contract is documented onScreenHost.productsand at thectxmemo inScreenRenderer.tsx.- Onboarding behaviour is unchanged for hosts without billing; see the headless
1.61.0notes.
[1.60.0] - 2026-08-13
Added
ScreenRenderer+ScreenHost— the rendering engine is now screen-agnostic.ScreenRenderer({ elements, host })renders aUIElementtree against an injectedScreenHost(variables,setVariable,complete,customActions,keyboardVerticalOffset) and knows nothing about onboarding.ComposableScreenRendereris now a thin onboarding adapter that builds a host from the onboarding contexts and suppliesOnboardingTemplate; a paywall renderer becomes a sibling adapter over the same engine. New exports from the package root:ScreenRenderer,noopScreenHost, and the typesScreenHost/ScreenRendererProps.
Changed
- Element runtime moved to
UI/Runtime/.UI/Pages/ComposableScreen/elements/*is nowUI/Runtime/elements/*, and theUIElementunion mirror isUI/Runtime/types.ts.UI/Pages/ComposableScreen/types.tskeeps the onboarding step schema and re-exports the runtime types, so existing deep imports still resolve.UI/Runtime/no longer imports fromPages/,Templates/, or the onboarding provider — that decoupling is what lets a second host reuse the engine. - No behaviour change for onboarding. The element memoization architecture is preserved exactly:
RenderContextstays referentially stable across variable writes (its dependency set is unchanged), and volatile variable maps still travel throughVariablesContext. Rendered tree, keyboard-avoidance offset, and the root-background handling are identical.
Notes
ScreenRenderer'selementsprop must be referentially stable across renders — it drives every element's memoization. The onboarding adapter satisfies this via its[step]-memoized parse; a custom host that re-parses or re-maps elements each render would silently lose element memoization with no type error.
[1.59.2] - 2026-07-24
Added
TypewriterTextacceptspreset: "none"to disable the per-character animation. Previously the preset was always an entering builder and omitting it fell back to the"FadeInDown"default, so there was no way to turn the animation off. With"none", hold-layout mode renders the full text immediately;cursormode still types progressively (the typing clock is separate from the entering builder), just without a fade on each character.
[1.59.1] - 2026-07-23
Changed
- Version sync with headless
1.59.1— no UI-package code changes. The headless release fixes the start-node lookup souseOnboardingStart()readsconfiguration.startStepIdinstead ofmetadata.startStepId(@rocapine/react-native-onboarding1.59.1).
[1.59.0] - 2026-07-17
Changed
- Version sync with headless
1.59.0— no UI-package code changes. The release adds an explicit start node, end-via-branching (theONBOARDING_END_STEP_IDsentinel), and anonCompletecompletion callback in the headless SDK (@rocapine/react-native-onboarding1.59.0). Renderers are unaffected: the CTA still calls the hostonContinue; the host decides between advancing and callingcompleteOnboarding().
[1.58.0] - 2026-07-16
Added
runActionspasses asetVariablesetter tocustomaction handlers. The dispatcher now calls host handlers with{ variables, setVariable };setVariableis the render context's setter (setVariableAndSync), so a handler write propagates to both the UI variable store and the headless branching store. Pairs with the headlessCustomActionHandlertype change (onboarding 1.58.0).
[1.57.4] - 2026-07-09
Fixed
- Onboarding images no longer flash / re-decode every time they're shown.
ImageandProgressiveBlurImagerendered through expo-image with nocachePolicy, so it used the default"disk"— memory-less, meaning every time an image view mounted (each step navigation) it re-read and re-decoded from disk, producing a blank-then-pop flash. Both render sites now usecachePolicy="memory-disk", keeping the decoded bitmap in memory (with disk fallback) for instant re-display. Pairs with the headlesspreloadAssetschange so prefetched images are warmed into memory, not just disk.
[1.57.3] - 2026-07-09
Fixed
- Bordered, rounded
Imageelements now fill their frame with no white corner gap (ROC-2984 finding #1). When anImagehas a shadow and/orbackgroundGradient, it renders through a wrapperViewthat paints the border while the raster image fills the content box inside it. The inner image was clipped to the outerborderRadius, over-rounding its corners relative to the border's concentric inner edge (radius = outer −borderWidth) and leaving a white gap at the corners — most visible on theweek-good/week-badoption photos (borderRadius: 24,borderWidth: 2). The inner image is now clipped to the concentric inner radius (max(0, borderRadius − borderWidth)) so its corners sit flush inside the frame. Images with noborderRadiusare unchanged, and the shadow-bearing wrapper is left un-clipped (addingoverflow:hiddenthere would clip the iOS shadow — the reason the wrapper/inner split exists).
[1.57.2] - 2026-07-09
Fixed
- ComposableScreen no longer flashes a grey band between the content and the keyboard (ROC-2984 finding #2). The root
KeyboardAvoidingViewhad no background, so the keyboard-height padding it inserts on keyboard open (behavior:"padding"on iOS) exposed the greyOnboardingTemplatecontainer (themeneutral.lowest) behind it. The renderer now paints that padding region with the step's own outermost element background (elements[0].props.backgroundColor) — but only when that first element is a full-bleed, unconditional root (flexorheight:"100%", norenderWhen), so a content-sized, gated, or decorative first element can't overpaint the themeable page. Purely additive — a true no-op otherwise (the themeable page background still shows through), with no change to keyboard-avoidance behavior or the public API. Known gap: a root whose background is abackgroundGradient(no solidbackgroundColor) is not yet covered — the band can still appear there.
[1.57.1] - 2026-07-09
Fixed
- Threshold-based loaders animate smoothly again (
renderWhenreacts to a sweepingProgressIndicator). A stepped loader — oneProgressIndicator(autoplay) driving sibling checkmarks viarenderWhenthresholds (e.g.loaderProgress gte 33) — previously stayed on the first step for the whole sweep and then flipped every step to done at once. That was because the boundary-only variable write (the [1.57.0] re-render fix) means the store variable only changes at0/max, so the intermediate thresholds never fired. The autoplayProgressIndicatornow also publishes its live sweep as a screen-scoped animated value, and arenderWhenthat depends solely on that variable is evaluated from the live value on the UI thread, flipping only its own node as each threshold is crossed. The store write stays boundary-only, so the re-render fix is fully preserved.
Added (internal)
AnimatedVariablesContext— a stable, screen-scoped registry of animated variables (reanimatedSharedValues published by autoplayProgressIndicators). Separates ephemeral screen-animation state from durable "concept" variables in the store. Internal toComposableScreen; no schema or public API change. Conditions that mix variables, nest groups, or use non-numeric operators are unaffected and still evaluate against the store.
[1.57.0] - 2026-07-01
Changed
- ComposableScreen no longer re-renders the whole element tree on every variable write. The
RenderContextis split into a stable slice (theme, setVariable, onContinue, customActions, renderChildren) passed by identity-compared props and a volatileVariablesContextconsumed viauseVariables(). Element components are memoized and dispatch flows through a memoizedElementHost(non-gated) /GatedElement(renderWhen), so a write (Input keystroke, Carousel page change,ProgressIndicatorautoplay tick) now re-renders only the components that actually read that variable. Purely internal — no schema or API change.
Fixed
- In-flight animations no longer reset when an unrelated variable changes. The prior full-tree re-render churned the
react-native-reanimatedmapper graph, visibly resetting running animations on sibling elements mid-sweep; memoization isolates each write to its consumers.
[1.56.0] - 2026-06-30
Added
TypewriterTextrenderer — reveals text character-by-character with a staggeredreact-native-reanimatedentering animation. Each character is a realAnimated.Textflex item (not an inline span, so transform-based presets likeFadeInDownwork); in the default hold-layout mode characters are grouped by word (no mid-word breaks) and hold their layout from frame 0 (no reflow), revealed via per-char entering delay.cursormode switches to a true progressive typewriter — characters mount one perstaggerso the line grows left-to-right and a blinking caret follows the last typed character.loopreplays the reveal by re-keying the characters each cycle. Font resolution viauseResolvedFontStyle(called once);textAlignmaps to row justification.
[1.55.1] - 2026-06-26
Added
ZStackjustifyContent/alignItems— anchor each content-sized layer within the full-bleed stack. A layer that fills (flex/height) ignores them; the per-child wrapper staysbox-none, so a content-height bottom CTA can float over a scrollable layer (justifyContent: "flex-end") while the scroll behind it keeps receiving touches. Defaults (flex-start/stretch) preserve prior behavior.
[1.55.0] - 2026-06-25
Added
- Radial
backgroundGradient—GradientBoxnow renders a{ type: "radial", center?, radius?, stops }gradient viareact-native-svg(a bundled dep, always available — unlike linear, which needs the optionalexpo-linear-gradient).centerdefaults to{ 0.5, 0.5 }andradiusto0.75(both 0–1 box fractions,objectBoundingBoxunits → ellipse on a non-square box). Stops without an explicitpositionare distributed evenly. The radial branch sizes to its content identically to the linear / plain-View paths.
[1.54.0] - 2026-06-23
Changed
- Version parity bump. No UI changes; released in lockstep with
@rocapine/react-native-onboarding1.54.0 (headlesscacheKeyoption +clearCache()).
[1.53.0] - 2026-06-22
Added
- Configurable
DatePickerlabel format — theDatePickerrenderer now honors the newformatprop (Intl.DateTimeFormatOptionssubset), passing it to thetoLocale*Stringcall matchingmodeso the Android trigger text and the stored variablelabelreflect the author's chosen format (12h/24h, day/month/year style, etc.). Falls back to the existing medium-style defaults whenformatis omitted.
Fixed
DatePickerlabel now respectslocale— the label formatter previously ignored thelocaleprop (always used the device default); it is now threaded intoformatDate, so the displayed/stored label localizes alongside the native picker.
[1.52.0] - 2026-06-22
Added
useProgressHeaderInsethook. Returns the ProgressBar overlap a screen must add below its own top safe-area inset (headerHeight - insets.top, clamped to 0; naturally 0 when the bar is hidden). Built on the new headlessuseOnboardingHeaderHeight.
Changed
ProgressBarself-measures and publishes its height. It now reports its real footprint viaonLayoutinto the headless context (and resets to 0 when hidden), so step content can lay out below it without a hardcoded guess. Host apps need no change — they already render<ProgressBar/>.OnboardingTemplateuses the measured header inset. The hardcodedpaddingTop: 40(whendisplayProgressHeader) is replaced by the real measured overlap, fixing over/under-padding on devices whose status-bar inset differs from the old guess.- ComposableScreen
KeyboardAvoidingViewoffset.keyboardVerticalOffsetnow defaults to the measuredheaderHeightinstead of0.
Fixed
SafeAreaViewelement now accounts for the ProgressBar. A ComposableScreenSafeAreaViewpreviously applied only the device top inset, so the bar (which sits above it) overlapped content. It now adds the bar overlap to its top padding — subtracting the device inset when it applies thetopedge itself (no double-count), or adding the full footprint when it doesn't. Only the screen's top-mostSafeAreaViewshould carry this (a nested top-edge one would double-offset).
[1.51.2] - 2026-06-22
Fixed
- ComposableScreen
RadioGroup/CheckboxGroup— container honorsflex/flexGrow/flexShrink. The group container style threaded onlywidth/height, soflex:1on a group was a no-op — groups sized to content and image-grid columns rendered unequal. The container now appliesflex/flexGrow/flexShrinkfromBaseBoxProps, so aflex:1group fills its parent and image grids get fluid, equal-width columns without fixed-percentage widths.
[1.51.1] - 2026-06-22
Fixed
- ComposableScreen
RadioGroup/CheckboxGroup— centered label/subLabel. WithitemAlignItems: "center"(or"flex-end"), the label/sub-label now actually center (or right-align) within the item card. The content wrapper was content-width with no grow, so the item row pinned it left anditemAlignItemsonly centered within that narrow block; it nowflexGrow:1+alignSelf:"stretch"to fill the card. Label/sub-label<Text>also gain a matchingtextAlignso multi-line copy aligns instead of reading left. Default (noitemAlignItems) still left-aligns.
[1.51.0] - 2026-06-19
Added
- ComposableScreen
RadioGroup/CheckboxGroup— per-item image. Each item can carry an optionalimage({ url, width?, height?, aspectRatio?, resizeMode?, borderRadius? }) rendered above the label/sub-label as a column (image → label → subLabel). SVG URLs render viareact-native-svg; rasters viaexpo-image(when installed) or RNImage. Image rendering helpers were extracted into a sharedimageSourcemodule reused byImageElementand both groups. - ComposableScreen
RadioGroup/CheckboxGroup—itemAlignItems+itemGap.itemAlignItems("flex-start" | "center" | "flex-end" | "stretch", default"center") sets the cross-axis alignment of each item's contents — including letting the tick top-align with multi-line / image content.itemGap(default12) sets the spacing between an item's inner pieces (tick ↔ content, image ↔ text), replacing the previously hardcoded12px. When both are unset, existing layouts render unchanged.
[1.50.1] - 2026-06-19
Fixed
- ComposableScreen
RadioGroup/CheckboxGroup— tick not pinned to edge withtickPosition: "end". The item row had nojustifyContent, so the label and tick clumped together on the left instead of the tick sitting at the right edge of the full-width card. Items now applyjustifyContent: "space-between"whentickPosition === "end", distributing the label to the left and the tick to the right edge.tickPosition: "start"(default) is unchanged.
[1.50.0] - 2026-06-19
Added
- ComposableScreen
RadioGroup/CheckboxGrouprenderers — tick + sub-label customization. Tick placement honorstickPosition("start"/"end"); tick color/radius/size come fromtickColor/tickSelectedColor/tickBorderRadius/tickSizeper selection state (tickSizedefault20— radio's inner dot and checkbox's ✓ glyph fontSize/lineHeight scale with it; radiotickBorderRadiusdefaults totickSize / 2). Items render an optionalsubLabelline (own font + color resolved once viauseResolvedFontStyle, state-aware viaitemSubLabel*/itemSelectedSubLabelColor). Itemlabelis optional; the tick↔text and label↔sub-label gaps collapse when a line is absent. Accessibility label falls backlabel → subLabel → value.
[1.49.1] - 2026-06-19
Fixed
- ComposableScreen Carousel active dot sizing —
activeDotWidth/activeDotHeighthad no visual effect because the renderer usedPagination.Basic, which sizes every dot fromdotStyle(clipped viaoverflow: hidden) and never appliesactiveDotStylewidth/height (active resizing is an unimplemented TODO inreact-native-reanimated-carousel). Switched toPagination.Custom, which interpolates width/height/borderRadius/backgroundColor between active and inactive dots, so active dot sizing now renders.
[1.49.0] - 2026-06-19
Added
- ComposableScreen
Carouselelement dots now support active-dot sizing and placement. The renderer appliesactiveDotWidth/activeDotHeighttoPagination.Basic's active dot, renders the dot row above or below the carousel viadotsPosition, and honorsdotsMarginBottom. Defaults preserve the prior look (active dot = inactive size, dots below, no bottom margin).
[1.48.0] - 2026-06-19
Added
- Carousel pagination dots are now customizable — the
Carouselrenderer readspayload.paginationto control dot colors, inactive/active width & height, gap, vertical placement (position: "top" | "bottom"), and top/bottom margins, and can hide the dots entirely (show: false). Defaults reproduce the previous hardcoded styling.
[1.47.0] - 2026-06-19
Changed
ProgressBarno longer importsexpo-router— its back button now uses the navigation adapter fromuseOnboardingNavigation()(canGoBack()/goBack()).expo-routeris now an optional peer dependency; existing expo-router apps keep the same behavior with no changes, and other navigation libraries work by injecting anavigationadapter intoOnboardingProvider.
[1.46.0] - 2026-06-18
Added
DrawingPadComposableScreen element renderer — a freehand drawing / signature canvas. Captures multi-stroke input viareact-native-gesture-handlerand Skia paths; on each completed stroke it serializes the drawing into the bound variable(s): an SVG path string (variableName) viapath.toSVGString()and/or a base64 image data URI (imageVariableName) rendered off an offscreen Skia surface. SupportsstrokeColor,strokeWidth,backgroundColor,clearable,imageFormat, a fully customizable clear button (clearButtonPosition(top/bottom × left/right),clearButtonOffset,clearButtonSize,clearButtonColor,clearButtonIconColor,clearButtonLabel), and allBaseBoxProps. Requires the optional peer dependency@shopify/react-native-skia(throws an explicit install error when absent). Wired intorenderElementand added toPRESS_HANDLED_TYPES(owns its own gesture).
[1.45.0] - 2026-06-18
Added
Sliderelement renderer — renders a continuous numeric slider that reads/seeds/writes its bound variable as a float. Backed by the new optional peer dep@react-native-community/slider; degrades to an empty box when the dep is absent (mirrorsGradientBox's silent fallback). Track/thumb tints default to the themeprimary/neutral.low. Wired intorenderElement(dispatch +PRESS_HANDLED_TYPES, since it owns its gesture) andcollectElementDefaults(first-render default seed).
[1.44.7] - 2026-06-18
Fixed
backgroundGradientonButton(and other elements) no longer blows the element up to fill the screen. The gradient render path nested the content inside<GradientBox style={{ flex: 1 }}>with an innerflex: 1view, while the non-gradient path was content-sized. In aZStack/flex container thatflex: 1grabbed the parent's full main-axis, so a gradientButton(orSafeAreaView/KeyboardAvoidingView/ScrollView) expanded to the whole screen. The innerflex: 1is now gated behind an explicitheight/flex/flexGrow, so a content-sized element stays content-sized with or without a gradient. Affected renderers:ButtonElement,SafeAreaViewElement,KeyboardAvoidingViewElement,ScrollViewElement.
[1.44.6] - 2026-06-18
Changed
- Version sync with
@rocapine/react-native-onboarding@1.44.6(asset prefetch/preload now works forComposableScreensteps). No UI changes.
[1.44.5] - 2026-06-16
Fixed
- Staggered autoplay
ProgressIndicatorloader bars no longer reset to empty. When severalautoplaylinear bars ran on one screen (e.g. a "curating your profile…" loader), bars that finished early painted empty while only the last-finishing bar stayed filled — even though every bar's bound variable correctly reached its max (sorenderWhen: eq maxcheckmarks stayed visible, exposing the desync). Cause: each autoplay bar wrote its bound variable on every animation step (~20×/s), and everysetVariablere-rendered all ComposableScreen variable consumers; on Fabric / Reanimated 4 that re-render storm reverted the already-settled animated fill of sibling bars. Fixes:- Autoplay bars now write the bound variable only at the sweep boundaries
(start / completion) instead of on every step, eliminating the re-render
storm. The live numeric
%is still rendered natively viashowLabel. (A consumer interpolating the variable mid-sweep with{{var}}now sees it jump min→max — useshowLabelfor a live readout.) - The linear fill is driven by a left-anchored
scaleXtransform instead of an animated percentagewidth, which commits reliably on Fabric. - Autoplay progress is seeded from the bound variable on mount, so a completed bar is restored to full if the screen subtree remounts.
- Added dependency arrays to the animated worklets to avoid mapper churn.
- Autoplay bars now write the bound variable only at the sweep boundaries
(start / completion) instead of on every step, eliminating the re-render
storm. The live numeric
[1.44.4] - 2026-06-16
Fixed
- Empty / null
fontFamilynow falls back to the theme default. A text element (Text,Button,Input,RadioGroup,CheckboxGroup,WheelPicker,AnimatedText, rich-text spans) that provided no usable font only fell back totheme.typography.defaultFontFamilywhenfontFamilywasundefinedor"inherit". The CMS emits an empty string ("") ornullfor "no font selected", which slipped throughresolveInheritedFontFamilyunchanged — a falsy family then reachedresolveFontFamily, which returnsundefined(system font) and silently ignored the configured default.resolveInheritedFontFamilynow treats any falsy value (""/null/undefined) as well as"inherit"as "use the theme default". fontStylenow resolves the italic face onButton/Input/RadioGroup/CheckboxGroup. These passed onlyfontFamily+fontWeighttouseResolvedFontStyle, so a registered italic variant (e.g.PlayfairDisplay-Italic) was never selected — text fell back to synthetic italic over the upright face.fontStyleis now threaded into resolution so the real italic face is picked when registered (matchingText/AnimatedText).
[1.44.3] - 2026-06-16
Changed
- Version sync with
@rocapine/react-native-onboarding@1.44.3(production fallback-cache fix in the headless SDK). No UI/renderer changes.
[1.44.2] - 2026-06-15
Fixed
- Italic text renders with the italic face.
TextElement(incl. rich-text spans) andAnimatedTextElementnow passfontStyleintouseResolvedFontStyle, so an italic request resolves to the registered italic font face instead of the upright one. Paired with@rocapine/react-native-onboarding1.44.2.
[1.44.1] - 2026-06-15
Changed
- Version bump only — paired with
@rocapine/react-native-onboarding1.44.1 (runtime fonts register under their PostScript / file name). No UI changes.
[1.44.0] - 2026-06-11
Added
OnboardingPagekeyboardVerticalOffset— optional number forwarded to theComposableScreenrenderer'sKeyboardAvoidingView(default0). Hosts that renderOnboardingPagebelow a fixed header (e.g. apaddingTop: HEADER_HEIGHTwrapper whendisplayProgressHeaderis true) push the view's top down, so the iOSbehavior="padding"math under-compensates by exactly that offset and the bottom CTA stays hidden behind the keyboard on steps containing anInput. Pass the header height (keyboardVerticalOffset={HEADER_HEIGHT}) to compensate. Other step renderers are unchanged.
[1.43.0] - 2026-06-11
Added
ProgressiveBlurImageblurAppear— fades the masked-blur + tint layer in over the always-visible sharp base image after an optional delay, via a reanimated opacity wrapper (withDelay+withTiming, reusing the sharedEASING_MAP).{ delay? (ms, default 0), duration? (ms, default 400), easing? (default "ease-out") }. Omitting it renders the blur statically at full strength on mount (unchanged). The degraded scrim fallback is unaffected.
[1.42.1] - 2026-06-11
Fixed
- Button
flexignored —ButtonElementnow forwardsflex/flexShrink/flexGrowfrom its resolved props in both render branches (gradient + default outerAnimated.View). Previously theseBaseBoxPropsfields were dropped, so aButtonwithflex: 1always sized to its content; equal-width / proportional buttons inside anXStacknow work without wrapping each Button in aflex: 1container. ThealignSelfdefault ("stretch"when nowidth) is unchanged, so content-sized buttons behave as before.
[1.42.0] - 2026-06-10
Added
- RadioGroup / CheckboxGroup per-item shadow — item rows now honor
itemShadowColor/itemShadowOffset/itemShadowOpacity/itemShadowRadius/itemElevationviabuildShadowStyleon eachTouchableOpacity. Items carry nooverflow: hidden, so the iOS shadow is not clipped; a loneitemShadowColordefaults opacity to1and radius to4.
[1.41.2] - 2026-06-10
Fixed
shadow*props now render onXStack/YStack/ZStackcontainers —buildShadowStylewas only wired intoButtonElementandImageElement, soshadowColor/shadowOffset/shadowOpacity/shadowRadius/elevationset on Stack containers were silently dropped.StackElementandZStackElementnow spreadbuildShadowStyle(p)into their style objects. (iOS shadows still requireoverflow≠hiddenon the shadowed view.)
[1.41.1] - 2026-06-09
Fixed
- Static
transformnow applies from frame 0 when an element also has an entering animation — reanimated'sentering/exiting/layoutbuilders take over the host view's transform for the duration of the transition, so a statictransform(or continuouseffect) placed on the sameAnimatedBoxview was suppressed until the entry finished, then snapped in.AnimatedBoxnow nests the two onto separate views when a reanimated builder is present: the outer (parent-facing) view keepsflex/alignSelf+ the builder, the inner view carries the static transform/effect — so they stack instead of fighting. No-builder elements (transform/effect only) keep the single-view fast path.
[1.41.0] - 2026-06-09
Added
autoFocusprop onInputelement — whentrue, theTextInputfocuses on mount and the keyboard opens automatically. Optional, defaults tofalse.
[1.40.0] - 2026-06-09
Changed
- Version sync only — no UI changes. Bumped in lockstep with
@rocapine/react-native-onboarding1.40.0 (headless background asset preloader that warms remote image/video/Lottie/Rive/SVG assets from the payload after fetch). UI renderers are unchanged; preloaded assets are served from cache when each screen mounts.
[1.39.0] - 2026-06-08
Added
AnimatedTextUIElement — a number that count-animatesfrom→toand renders as formatted text (decimals,thousandsSeparator,easing,loop). The animation runs entirely on the UI thread and writes straight into a nativeTextInputviauseAnimatedProps({ text })(the react-native-redashReTextpattern), so it produces zero React re-renders per frame and never writes a composable variable. It is the performant replacement for driving a count-up through anautoplayProgressIndicatorbound to a variable (which re-renders the whole ComposableScreen tree on every step). Renders the number only — compose static labels as siblingText.
Changed
ProgressIndicatorshowLabelno longer re-renders — the label was React state (useState+runOnJS(setDisplayValue)per step hop), so ashowLabelindicator re-rendered itself on every step and churned the reanimated mapper scheduler (visibly destabilizing other on-screen animations). The label is now a nativeTextInputdriven from a worklet (same technique asAnimatedText), soshowLabeladds zero re-renders. ThesetVariablewrite for a boundvariableNameis unchanged (still the documented per-step write — keepstepcoarse for large ranges, or useAnimatedTextfor pure display).
[1.38.2] - 2026-06-08
Fixed
- Entry transitions restarting on re-render —
AnimatedBoxrebuilt itsentering/exiting/layoutreanimated builders inline on every render, handingAnimated.Viewa freshenteringinstance each time and re-firing the entry transition. With an autoplayProgressIndicatoron screen (writes its bound variable each step → re-renders the whole ComposableScreen tree), every sibling's entry animation visibly reloaded. The builders are now memoized on their (stable, from the memoized parsed step) spec objects.
[1.38.1] - 2026-06-08
Fixed
- Loader
CircularProgressper-frame re-render — the percentageuseAnimatedReactionrounded inside its JS callback, firingsetPercentageevery frame (~60×/s) and re-rendering the component continuously; it also had no deps array, so Reanimated rebuilt the mapper on every render (resettingprev). Now rounds inside the reader with aprevguard and a[]deps array, so the JS callback fires only when the displayed integer changes. - Loader
StepProgresslistener thrash — theprogress.addListenereffect was keyed onbarStarted/barComplete, the very states its callback flips, so eachsetStatetore the listener down and re-attached it mid-animation. The one-time start/complete transitions now live in refs and the effect deps are[progress](attaches once).
Changed
- ComposableScreen flattens variables once per render —
renderElementrebuiltflatVarsviaObject.fromEntriesfor every element on every tree re-render; an autoplayProgressIndicatorwriting a variable each step re-renders the whole tree, making this pure churn. The flatten is now memoized once inRendererasctx.flatVariables(added toRenderContext) and reused byrenderElement,RichTextElement, andButtonElement.
[1.38.0] - 2026-06-08
Added
ProgressIndicatorarbitrary value range — the renderer decouples the fill fraction (always 0–1, derived as(value − minValue) / (maxValue − minValue)) from the displayed value (in[minValue, maxValue]).autoplayanimates tomaxValue; the label and theautoplay-written variable now carry the raw value snapped tostep, withlabelSuffix(default"%") appended. Lets aProgressIndicatordrive an animated count-up to N (read via{{var}}in aText). TheuseAnimatedReactionworklet keys on the step-snapped value (not the rounded percent) and re-keys onminValue/maxValue/step, so the JS callback fires(maxValue − minValue) / steptimes per sweep — coarsestepavoids a per-step re-render storm on large ranges.
Changed
ProgressIndicatorlabel is no longer percent-only — both label render sites show{value}{labelSuffix}instead of a hardcoded{percent}%; the internalclampis now range-aware (clamp(n, min, max)). With default props (minValue:0,maxValue:100,step:1,labelSuffix:"%") the rendered output is unchanged.
[1.37.0] - 2026-06-08
Added
- Generic
onPresson non-pressable elements —renderElementnow wraps any element declaringonPress: ButtonAction[]in a single centralPressable(mirroring the existingAnimatedBoxwrapper), dispatching the same action list asButtonvia a new sharedrunActionshelper. Makes static elements (Text, Icon, Image, Lottie, Rive, Video, ProgressIndicator, RichText, Stacks, ZStack, SafeAreaView, ScrollView, KeyboardAvoidingView, Carousel) tappable. Skipped for elements that own their own tap/focus/scroll gesture (Button,RadioGroup,CheckboxGroup,DatePicker,Input,WheelPicker). ThePressableis layout-transparent — it forwards the element'sflex/flexGrow/flexShrink(incl. theparentType === "XStack"shrink default) /alignSelf, so a tappable element still splits/flows in its parent's flex context exactly as it would un-wrapped (e.g. flex:1 cards in a row grid). arrayOpmulti-select support inrunActions— asetVariableaction witharrayOp: "append" | "remove" | "toggle"reads the target variable's JSON-encodedstring[](theCheckboxGroupencoding), applies the set operation tovalue, and re-storesJSON.stringify(values)+ comma-joined member labels.appenddedups,toggleflips,removedrops; the label list stays aligned to the value list. Makes a tappable card behave like a checkbox.
Changed
- Extracted
runActionsfromButtonElement— the press-action dispatch loop (continue / setVariable / custom) moved intoelements/runActions.tsand is now shared byButtonand the genericonPress.Button's behavior (haptic,disabledWhen,pressedStyle) is unchanged.ButtonActiontypes/schemas moved toelements/actions.ts(re-exported fromButtonElementfor back-compat).
[1.36.2] - 2026-06-08
Fixed
- Theme font now applies to all ComposableScreen text elements —
RadioGroup/CheckboxGroupitem labels,WheelPickeritems, and the AndroidDatePickertrigger label previously rendered in the system font when theirfontFamily/itemFontFamilyprop was omitted, ignoringtheme.typography.defaultFontFamily. They now resolve throughresolveInheritedFontFamily+ the font registry (matchingButton/Text/Input), so omitted font falls back to the theme default and weighted variants are matched correctly (synthetic bold suppressed viaresolvedToVariant).
[1.36.1] - 2026-06-04
Fixed
ProgressiveBlurImageelement on React Native 0.85 — replaced removedStyleSheet.absoluteFillObjectwithStyleSheet.absoluteFill(RN 0.85 dropped the former; the latter is now the equivalent frozen style object). Fixes the build under Expo SDK 56.
Changed
- Expo SDK 56 / React Native 0.85 alignment — bumped build-time dev dependencies (
react19.2.3,react-native0.85.3,expo-router~56.2.8,expo-store-review~56.0.3,react-native-gesture-handler~2.31.1,react-native-reanimated4.3.1,react-native-safe-area-context~5.7.0,react-native-svg15.15.4,@react-native-community/datetimepicker^9.1.0).react-native-svg15.15.4 fixes a native build break against RN 0.85'sImageResponseObserversignature. No runtime/API changes (peer deps stay*).
[1.36.0] - 2026-06-04
Added
- Uniform image blur — the
ImageComposableScreen renderer now forwards ablurRadiusprop to bothexpo-imageand RNImage(native blur, no extra dep).0/omitted = sharp; ignored for SVGs. ProgressiveBlurImageelement renderer — renders a full-bleed sharp image with a gradient-masked blurred copy of the same image on top (revealed where themaskis opaque) plus an optionaltintgradient, producing a progressive (variable) blur: sharp where the mask is transparent, blurred + tinted where it's opaque. Masking a blurred image copy (rather than a backdropBlurView) is what makes it composite reliably on iOS — a maskedBlurViewhas no backdrop to sample and renders transparent. Supports both linear and radial masks: linear renders viaexpo-linear-gradient, radial viareact-native-svg(a required dep — radial works even without expo-linear-gradient). The tint overlay + degraded scrim follow the same mask shape. Composes as the bottom layer of aZStackwith sharp foreground content above. A native-view probe + error boundary degrade to a sharp image + dark scrim (never throws) when the masked-view native module isn't in the running binary.
Changed
@react-native-masked-view/masked-viewadded as an optional peer dependency — needed (alongside the existingexpo-linear-gradientfor the mask/tint gradients andexpo-imagefor the blurred copy) byProgressiveBlurImage. When absent the element degrades gracefully to a sharp image + a dark gradient scrim derived from the mask (still legible for overlaid text). Themaskis linear-only; a radial source mask is approximated by a vertical fade.
[1.35.0] - 2026-06-02
Added
- Haptic feedback on clickable ComposableScreen elements —
Button,RadioGroup, andCheckboxGrouprenderers fire tactile feedback on press / select / toggle when their newhapticprop is set ("light" | "medium" | "heavy" | "soft" | "rigid";"none"or omitted = silent). Powered by a sharedtriggerHaptichelper (elements/haptics.ts) that dynamically requires the new optionalexpo-hapticspeer dependency — silently no-ops when the dep isn't installed, mirroring theexpo-store-review/expo-linear-gradientpattern.
Changed
expo-hapticsadded as an optional peer dependency — install only if you opt into thehapticprop.
[1.34.1] - 2026-06-02
Fixed
ProgressIndicatorresetting after it finishes —useAnimatedReactionwas created without a dependency array, so reanimated 4 tore down and rebuilt the mapper on every render. A loopingshowLabelindicator re-renders ~40×/s indefinitely (onesetPercentageper frame), churningstartMapper/stopMapperon the UI-thread scheduler and destabilizing other running animations on the same screen — the "autoplay once" indicator would occasionally snap back to its initial value after completing. The reaction is now keyed on[showLabel, writesVariable, variableName]so the mapper stays stable across renders (this also keepsprevalive, restoring therounded === prevover-fire guard).
[1.34.0] - 2026-06-02
Added
- WebP / AVIF image support — the
Imageelement now renders viaexpo-imagewhen installed (new optional peer dep), falling back to React Native'sImagewhen absent (same try/require pattern asGradientBox/expo-linear-gradient). RN's built-inImageis unreliable for WebP on iOS;expo-imagedecodes WebP/AVIF reliably cross-platform.resizeModemaps to expo-imagecontentFit(cover/containpass through,stretch→fill,center→none). - SVG image support — the
Imageelement auto-detects URLs whose path ends in.svg(query-string / hash tolerant) and renders them withreact-native-svg'sSvgUri(already a dependency). No schema change — existing payloads with.svgURLs just work.resizeModemaps to SVGpreserveAspectRatio(cover→xMidYMid slice,contain/center→xMidYMid meet,stretch→none). ScrollViewelementalignItems/justifyContent— renders the new optionalScrollViewprops (see headless1.34.0) on the scroll content container for cross-axis alignment + distribution along the scroll axis.
Fixed
- Horizontal
ScrollViewno longer "stuck" / unscrollable — children of a horizontalScrollViewwere rendered withparentType"XStack", which applied aflexShrink: 1default, so fixed-width cards shrank to fit the viewport instead of overflowing (the row couldn't scroll). Horizontal scroll content now renders with a dedicated"XScroll"parentType(row layout, noflexShrinkdefault) and dropsflexGrow: 1from its content container, so children keep their intrinsic width and the row scrolls. (VerticalScrollViewkeepsflexGrow: 1so a short payload still fills the viewport.) RichTexttextAlignnow aligns the wrapping row —textAlignwas published to childTextelements viaRichTextStyleContextbut had no visible effect on the row itself (each word is a shrink-wrapped flex item, sotextAlignis a no-op there); the row's horizontal distribution is governed byjustifyContent, which defaulted to"center".textAlignnow maps onto the row'sjustifyContentwhenjustifyContentisn't set explicitly (left→flex-start,center→center,right→flex-end).
[1.33.0] - 2026-06-01
Added
RichTextcontainer renderer — renders the newRichTextUIElement as a wrapping flex row (<View>/GradientBox,flexDirection:"row",flexWrapdefault"wrap"). Children (Textelements) render throughrenderElementas real flex children, so each honors its own box props (padding,borderRadius,border,backgroundColor,margin,transform) — enabling padded/rounded/rotated chip segments — plusrenderWhen/expression. Supportsgap,alignItems(incl."baseline"), andjustifyContent. Unlike inlineTextSpans,RichTextchildren may useanimation/transform(theAnimatedBoxViewwrapper is valid inside the row). The container's text-style props (fontSize,color,textAlign, …) are published via a newRichTextStyleContextand merged byTextElementComponentas inherited defaults (child props win) — so a title's base typography is declared once on the container. Plain-text children are expanded into one inlineTextper word (spaces preserved) so the row wraps word-by-word; children with box styling or motion stay atomic chips. (Because spaces become real flex items, avoidgapwhen mixing words + chips — use chipmarginHorizontal.)
[1.32.0] - 2026-06-01
Added
AnimatedBoxwrapper +buildAnimationhelper — renders the newtransform/animationsurface (see headless1.32.0) for every ComposableScreen element.renderElementwraps the dispatched node in a singleAnimated.View(AnimatedBox) only whenanimationortransformis present (zero extra view otherwise), forwardingflex/alignSelfso the wrapper stays layout-transparent.entering/exiting/layoutresolve to reanimated builders by name (Reanimated[preset]) with.duration().delay().springify().easing()modifiers; unknown presets degrade to no-op. Continuouseffect(pulse/fade/rotate/shimmer/bounce) runs imperatively viawithRepeat. No new peer deps — uses the existingreact-native-reanimatedstack.- Shared
EASING_MAPextracted tobuildAnimation.ts;ProgressIndicatorElementnow imports it (removes the duplicated easing table). - New
composable-screen-animationsexample screen (entering presets staggered bydelay, spring vs easing, looping effects, static transforms, exiting + layout toggle, Replay button).composable-screen.tsx+onboarding-example.tsdemos: hero image fades in (FadeInDown), star icon zooms in with a static tilt and a continuouspulse. RichTextSpanextended — applies the newTextSpanfields (backgroundColor,opacity,textTransform,textDecorationColor,textDecorationStyle,lineHeight) to the nested inline<Text>.
[1.31.0] - 2026-06-01
Added
- Inline rich-text rendering in
TextElement— whencontentis a span array, the renderer maps each span to a nested<Text>(new internalRichTextSpancomponent) so fragments with different weight/style/color/decoration wrap together on one baseline. Each span resolves its own font viauseResolvedFontStyleagainst the parentText's inherited family, so a span setting onlyfontWeightstill picks the correct weighted font variant. Supports per-spanfontWeight,fontStyle,fontFamily,fontSize,letterSpacing,color,textDecorationLine.
Changed
TextElementPropsSchema.contentmirror widened tostring | TextSpan[];TextSpan/TextSpanSchemaadded to the UI element. Plain stringcontentrenders identically to before. Expression mode interpolates{{variable}}inside each span'stext.
[1.30.0] - 2026-05-29
Added
ProgressIndicatorElementrenderer — renders theProgressIndicatorUIElement in both variants. Linear uses an animated track-fillView; circular uses an animatedreact-native-svgring (both driven byreact-native-reanimated— no new peer deps; same stack asCircularProgress).easingnames map to CSS cubic-bezier curves (linear,ease-in(0.42,0,1,1),ease-out(0,0,0.58,1),ease-in-out(0.42,0,0.58,1)).autoplayanimatesinitialValue → 100(optionallylooping, optionally after adelayms viawithDelay) and writes the rounded value tovariableNameon each integer-percent change (reaction keyed on the rounded value, not per-frame, to avoid a context re-render storm); withoutautoplaythe indicator animates toward the bound variable / staticvalue. OptionalshowLabelrenders the live percentage.composable-screen.tsx+onboarding-example.tsdemos exercise a linear autoplay-loop and a circular autoplay-once indicator.
[1.29.0] - 2026-05-29
Added
DatePickerElement:"now"sentinel support — renderer mirrors the headless schema and resolvesdefaultValue/minimumDate/maximumDatevia aresolveDatehelper that maps the literal"now"tonew Date()at render time (ISO strings still parse as before). Initial value,minimumDate, andmaximumDatepassed to the native picker all honor"now".composable-screen.tsx+onboarding-example.tsdemos now usemaximumDate: "now".
[1.28.0] - 2026-05-29
Added
RadioGroupElement/CheckboxGroupElement:showTicksupport — both renderers mirror the headlessshowTickfield and gate the indicator onshowTick !== false. WithshowTick: falsethe radio circle / checkbox✓box is not rendered, leaving label + selected background/border to convey state; default (true/ omitted) is unchanged.composable-screen.tsx+onboarding-example.tsdemos exercise both states (radio shows the tick, checkbox hides it).
[1.27.0] - 2026-05-29
Added
WheelPickerelement renderer — renders the newWheelPickerUIElement using the optional@react-native-picker/pickerpeer dep (native iOS wheel / Android dropdown). Seeds + writes its bound variable likeRadioGroup(full{value, label}entry), resolvesitems/rangevia the shared headlessresolveWheelPickerItemshelper, and contributes tocollectElementDefaultssodefaultValueis visible torenderWhen/{{var}}on first render. Falls back to a clear placeholder when the peer dep is absent.
[1.26.0] - 2026-05-28
Added
IconElementfilled / tinted rendering —IconElement.tsxnow mirrors headlessfill+fillOpacityschema fields and passes them through to the underlyinglucide-react-nativeSVG (extendsreact-native-svg'sSvgProps). Authors can render filled lucide icons or tinted overlays directly from CMS payload, e.g.{ "fill": "#007AFF", "fillOpacity": 0.25 }. Default behaviour unchanged — omitfilland icons render outlined as before.
[1.25.1] - 2026-05-28
Added
aspectRatioon every UIElement (viaBaseBoxProps) — wired into the Rive renderer's wrapper; other element renderers can opt-in by readingp.aspectRatio.
Changed
-
ComposableScreen page no longer wraps content in a
ScrollView— the wrapper container'sflexGrow: 1left innerflex: 1children unbounded vertically, so aCarousel(or anyflex: 1element) grew with its intrinsic content and pushed siblings off-screen. Payloads needing scroll should use theScrollViewUIElement (added in 1.25.0).KeyboardAvoidingViewstill wraps the page root.Migration: if your existing payload relied on the implicit page scroll (content taller than the viewport with no
ScrollViewUIElement), wrap your top-level container in aScrollViewelement to restore the previous behavior. Layouts where the root container isflex: 1(the common case) are unaffected — and now render correctly when the inner tree usesflexto share space. -
Rive default size — wrapper height defaults to undefined (was
200); when neitherheight/flex/aspectRatio/min-height/max-heightis set, falls back toaspectRatio: 1so the artboard doesn't fill the screen via its native intrinsic.
Fixed
Buttonhonors explicitpadding: 0— sub-axis defaults (paddingHorizontal: 24,paddingVertical: 14) used to apply even whenpaddingwas set to 0, because RN treats the shorthand and axis props independently. Axis defaults now apply only whenpaddingitself is unset.ButtonhonorstextAlign— Pressable'salignItems: "center"constrained the labelTextto its intrinsic width, neutralizingtextAlign. Removed the constraint so the label stretches andleft | center | rightapplies (default still centered).Buttonshadow visible fromshadowColoralone — iOS defaultsshadowOpacityto 0; the renderer now fills inshadowOpacity: 1andshadowRadius: 4when onlyshadowColoris set.Imageshadow renders — iOS clipped image shadows because theImagehost hadoverflow: hidden. WhenshadowColor/elevationis set, the renderer now wraps the image in a shadow-carryingView(orGradientBox) and lets the innerImageclip its own rounded corners.
[1.25.0] - 2026-05-27
Added
ScrollViewelement renderer — renders a React NativeScrollView. AppliesBaseBoxPropsto the outer container (gradient-aware), mapsbounces/ indicators /contentInset/keyboardShouldPersistTaps, and exposes acontentContainerPaddingshortcut oncontentContainerStyle(which also keepsflexGrow: 1).horizontalrenders children in row order.KeyboardAvoidingViewelement renderer — renders a React NativeKeyboardAvoidingViewwithbehaviordefaulting to iOSpadding/ Androidheight, pluskeyboardVerticalOffsetandenabled.
Changed
- ComposableScreen page wraps content in
KeyboardAvoidingView— the page Renderer now nests its scroll view inside aKeyboardAvoidingView(flex: 1, iOSpadding/ Androidheight), so text inputs avoid the keyboard. AKeyboardAvoidingViewplaced inside the page scroll view is inert by design (it cannot measure its frame); keyboard avoidance is handled at the page level.
[1.24.0] - 2026-05-27
Added
- Button per-state styling + shadow —
ButtonElementrenderer now mergespressedStyle(while held) anddisabledStyle(whiledisabledWhenis truthy) on top of base props, and appliesBaseBoxPropsshadow fields (shadowColor,shadowOffset,shadowOpacity,shadowRadius,elevation) to the outermost wrapper. Opacity transitions between rest/pressed/disabled animate overtransitionDurationMs(default150, native driver); color and shadow changes switch instantly.
Changed
ButtonElementusesPressable+Animated.Viewinstead ofTouchableOpacity, enabling explicit press-state tracking and the animated state transitions. Press feedback defaults toopacity 0.8when nopressedStyle.opacityis set, preserving prior tap feel.disabledBackgroundColor/disabledColordeprecated in favor ofdisabledStyle; kept as fallback whendisabledStyleis omitted.
[1.23.0] - 2026-05-26
Added
renderWhenruntime gating in ComposableScreen —renderElementevaluates the new optionalrenderWhenfield on every UIElement against flattenedctx.variablesand returnsnull(skipping the element and its subtree) when the condition is false. Single gating point covers all 15 element types; container subtrees are skipped naturally because the bail-out runs beforerenderChildrenis invoked.
Changed
- Element defaults overlaid into
ctx.variables—Renderer.tsxnow computes element-declared defaults (Carousel.defaultIndex,RadioGroup.defaultValue,CheckboxGroup.defaultValues,Input.defaultValue,DatePicker.defaultValue) via a tree walk and overlays them ontoRenderContext.variablessynchronously on first render.composableVariableskeeps precedence so user-driven updates aren't clobbered. MakesrenderWhenand{{var}}interpolation see defaults from the very first frame, before per-element seeding effects persist them into the variable store. CarouselElementpersists default index — whenvariableNameis set and the variable has no value yet, the carousel writes its clampeddefaultIndexintocomposableVariableson mount, matching the seeding pattern used by RadioGroup / Input / DatePicker.
Internal
- New
elements/collectDefaults.tsmodule — pure recursive walk over the UIElement tree returningRecord<variableName, ComposableVariableEntry>for defaulted variables. Consumed byRenderer.tsx.
[1.22.0] - 2026-05-11
Added
- Expression mode on
setVariablebutton action — new optionalvalueMode?: "literal" | "expression"andkind?: "int" | "float" | "string"fields onSetVariableButtonAction. In"expression"modevalueis evaluated as an arithmetic expression supporting{{var}}references, numeric literals,+ - * /, and parens. Variable values are coerced according to theirkindtag (string / int / float) or inferred from string content when no tag is present. Numeric+on any string operand becomes concat. Missing variables default to numeric 0 in arithmetic context (so{{counter}} + 1works on first click). On any parse failure the action falls back to plain{{var}}interpolation. Result kind is written back to the variable entry so subsequent expressions can re-evaluate without re-tagging.
Internal
- New
elements/expression.tsmodule — tokenizer + recursive-descent parser for the expression-mode subset. Pure function, no dependencies, deterministic.
[1.21.0] - 2026-05-11
Added
- Variable-bound
Carouselindex — Carousel renderer mirrors the newdefaultIndexandvariableNameschema fields. Initial page resolves from the variable value (whenvariableNameset and parsable as int) then falls back todefaultIndex ?? 0; index is clamped to[0, children.length - 1]and frozen at mount to avoid carousel remounts. AuseEffectwatching the variable value callsref.scrollTo()on external changes (e.g.setVariablebutton actions);onSnapToItemwrites the current index back as a string whenvariableNameis set. AlastSyncedIndexref prevents external↔swipe feedback loops.
[1.20.0] - 2026-05-11
Added
- Disabled-state support on ComposableScreen
Buttonrenderer — the renderer now readsdisabledWhen,disabledBackgroundColor, anddisabledColorfromButtonElementProps. When the condition evaluates truthy againstctx.variables(flattened to primitive values), theTouchableOpacityis disabled and the button renders with the disable color tokens (theme.colors.disable,theme.colors.text.disable) or the per-button overrides. Filled buttons with abackgroundGradientdrop the gradient in the disabled state for a clearer affordance; outlined buttons swap the border to the disable color.
[1.19.0] - 2026-05-07
Added
typography.defaultFontFamilytheme token — new optional field onTypographyTokens. Defaults to"Inter". Override viacustomTheme={{ typography: { defaultFontFamily: "Lobster" } }}to brand every ComposableScreen text element with one font without patching eachtextStyles.*.fontFamilyentry.- Font inheritance on
Text/Button/InputComposableScreen renderers — when an element omitsfontFamilyor sets it to the literal"inherit", the renderer resolves the family againsttheme.typography.defaultFontFamilybefore passing it touseResolvedFontStyle. Resolution helper exported asresolveInheritedFontFamilyfrom the ComposableScreensharedmodule. - New
resolveInheritedFontFamily(elementFontFamily, themeDefault)util atUI/Pages/ComposableScreen/elements/shared.ts.
Changed
ButtonElement,InputElement,TextElementtypings:fontFamily?: string | "inherit"(wasstring).
[1.18.0] - 2026-05-06
Added
fontStylerendering onTextElement,ButtonElement,InputElement(top-level), andRadioGroupElement/CheckboxGroupElement(itemFontStyle). Renderers pass the value through to the underlying<Text>/<TextInput>style, alongsidefontFamilyandfontWeight.setVariableButtonaction —ButtonElementhandles a new action variant{ type: "setVariable", name, value, label? }. The handler writes to the ComposableScreen variable map (and syncs the headless variable map) before any subsequent action in the chain runs, so a following"continue"sees the updated value whenresolveNextStepNumberevaluates branch conditions.
Changed
Button/Text/Inputfont weight resolution — switched fromuseResolvedFontFamilytouseResolvedFontStylefrom@rocapine/react-native-onboarding. When the registry matches a concrete weighted variant (e.g.Inter-700),fontWeightis suppressed on the rendered<Text>to avoid synthetic emboldening on top of an already-weighted font file.
Fixed
CarouselElementsizing — wrap the carousel in an innerView flex:1withonLayoutand pass measuredwidth/heighttoreact-native-reanimated-carouselinstead ofDimensions.get("window"). Render is gated until first measurement.OnboardingDataGateerror handling —useQueryerrors are now thrown so a hostErrorBoundarycatches them, instead of silently rendering thefontsFallbackforever.FontLoaderGate— resets registry to a loading sentinel before async registration and falls back to an empty registry on rejection so a fetch failure doesn't strand the gate.
[1.17.1] - 2026-05-04
Fixed
- Runtime font registration via
OnboardingProvider— fonts declared on the onboarding payload now load correctly when the backend returns the variant-array shape ({ family: [{ weight, style, url }, ...] }). Previous versions silently failed withloadSingleFontAsync expected resource of type Assetand bogusweight 8 from [object Object]warnings, leavingfontFamilystrings unmapped to weighted variants. No UI-package API change; fix lives in the headless SDK consumed byFontLoaderGate.
[1.17.0] - 2026-04-30
Changed
- ComposableScreen typography elements use the runtime font registry —
TextElement,ButtonElement, andInputElementnow calluseResolvedFontFamily(fontFamily, fontWeight)from@rocapine/react-native-onboardingto resolve afamily + weightrequest to the runtime-registered font variant. CMS authors continue to setfontFamilyto the family name declared in theOnboarding.fontsmanifest; the SDK picks the right registered variant (e.g.Inter+500→Inter-500) and falls back to the closest registered weight when an exact match is unavailable.
Element Zod schemas are unchanged. No CMS migration required for existing payloads — they keep working with system fonts.
Bumped
- Peer dependency on
@rocapine/react-native-onboardingis now^1.17.0.
[1.16.0] - 2026-04-29
Added
- Button
actionsexecution —ButtonElementnow runs the headlessButtonAction[]chain on press: sequential,awaits async handlers, warns on missing handler, aborts on thrown error,"continue"is terminal. customActionsplumbing —RenderContextexposescustomActionsto every ComposableScreen element.ComposableScreenRendererreads them from the headlessOnboardingProgressContext(set via<OnboardingProvider customActions={...}>).- Re-exports
ButtonAction,CustomButtonAction,CustomActionHandler,CustomActions,ComposableVariableEntryfrom the headless package.
Changed
ComposableVariableEntryis now sourced from the headless package (@rocapine/react-native-onboarding); the UI provider re-exports it. Existing imports fromOnboardingProgressProvidercontinue to work.
[1.15.0] - 2026-04-28
Added
SafeAreaViewUIElement renderer — newSafeAreaViewElementComponentthat delegates toSafeAreaViewfromreact-native-safe-area-context. Forwardsmodeandedges(array or per-edge object) and appliesBaseBoxPropsstyling.
Changed
OnboardingTemplateno longer applies safe-area insets. The template previously readuseSafeAreaInsets()and addedpaddingTop/paddingBottom. Renderers now own safe-area handling:Carousel,Commitment,Loader,MediaContent,Picker,Question, andRatingswrap their content with<SafeAreaView edges={["top", "bottom"]}>. TheComposableScreenrenderer intentionally does not wrap — author safe-area placement using the newSafeAreaViewUIElement so screens can render edge-to-edge backgrounds.- The progress-header offset (40px) remains in
OnboardingTemplateas plain padding, no longer combined with the top inset.
[1.14.0] - 2026-04-28
Added
ZStackUIElement renderer — newZStackElementComponentthat renders children layered on top of each other. Each child is wrapped inposition: "absolute"filling the container, enabling image-with-text-overlay and other depth-compositing patterns. Supports allBaseBoxPropsincludingbackgroundGradientviaGradientBox.
[1.13.1] - 2026-04-28
Added
ZStackUIElement renderer — newZStackElementComponentthat renders children layered on top of each other. Each child is wrapped inposition: "absolute"filling the container, enabling image-with-text-overlay and other depth-compositing patterns. Supports allBaseBoxPropsincludingbackgroundGradientviaGradientBox.
[1.13.0] - 2026-04-28
Added
-
Gradient backgrounds on all
ComposableScreenelements — every element that renders a container (YStack,XStack,Icon,Image,Text,Button,Lottie,Video,RadioGroup,CheckboxGroup,Carousel,DatePicker) now respectsbackgroundGradientfromBaseBoxProps. -
GradientBoxcomponent — internal utility that wrapsexpo-linear-gradient(LinearGradient) when the library is installed, falling back to a plainViewsilently when it is not. All element renderers delegate their outer container toGradientBox. -
expo-linear-gradientoptional peer dependency — install it to enable gradient rendering; omitting it degrades gracefully to a solid background. -
Linear gradient API —
backgroundGradient: { type: "linear", from: GradientEdge, to: GradientEdge, stops: GradientStop[] }.GradientEdgeis one of 8 named positions ("top","bottom","left","right","topLeft","topRight","bottomLeft","bottomRight"). Stops support optional explicitposition(0–1); when all stops declare a position,locationsis passed toLinearGradient.
Fixed
figmaUrltype inComposableScreenstep schema — changed from.nullable()to.nullish()to align with all other page-type schemas and the headless SDK.
[1.12.0] - 2026-04-28
Changed
ComposableScreenelement variable sync — when aComposableScreenelement with avariableName(e.g.Input,RadioGroup,DatePicker,CheckboxGroup) changes its value, the change is now written to both the UI-layercomposableVariablesstore (drives{{interpolation}}within the current screen) and the headlessvariablesstore (OnboardingProgressContext.setVariable). This makes composable element answers available toresolveNextStepNumberbranch conditions on subsequent steps.
[1.11.1] - 2026-04-27
Changed
-
All element renderers updated to apply the full expanded
BaseBoxProps:minWidth,maxWidth,minHeight,maxHeight,flexShrink,flexGrow,backgroundColor,overfloware now wired into every element's style output. -
dim()helper added (shared.ts) — castsnumber | stringwidth/height values to React Native'sDimensionValue, enabling percentage strings (e.g."100%") across all elements. -
StackElementrenderer — appliesflexGrow, all newBaseBoxPropslayout props.width/heightnow support percentage strings. -
TextElementrenderer — appliesflex,flexShrink/flexGrow,alignSelf,width/height(viadim()),minWidth/maxWidth/minHeight/maxHeight,overflow. -
InputElementrenderer — appliesfontFamily,lineHeight,letterSpacing; alsoflex,flexShrink/flexGrow,minWidth/maxWidth/minHeight/maxHeight,overflow. -
ButtonElementrenderer —alignSelfnow uses the complete enum fromBaseBoxProps. -
RiveElementrenderer — prop renamedautoplay→autoPlay(schema-level rename; the underlyingrive-react-nativelibrary still receivesautoplay). -
CarouselElementrenderer —Pagination.Basicnow driven by dot style props:dotColor,activeDotColor,dotWidth,dotHeight,dotsGap,dotsMarginTop. -
IconElement,LottieElement,VideoElementrenderers — applyflex,flexShrink/flexGrow,alignSelf,minWidth/maxWidth/minHeight/maxHeight.
[1.11.0] - 2026-04-24
Added
-
Carouselelement renderer — rendersCarouselUIElements usingreact-native-reanimated-carousel(now a required peer dependency). Each slide is aUIElementsubtree rendered by the same recursive engine asYStack/XStack, giving full layout flexibility per slide. Four modes viacarouselType:"normal"— full-width paged carousel (default)"parallax"— depth-zoom effect using librarymode="parallax""stack"— stacked cards at 75 % window width viamode="horizontal-stack""left-align"— peek effect at 82 % window width withoverflow: "visible"
Pagination uses
Pagination.Basicfrom the library: animated pill dots in themeprimary/neutral.lowcolors, tappable to jump to any slide.autoPlaydefaults tofalse;loopdefaults totrue;showDotsdefaults totrue. Width defaults touseWindowDimensions().width; height defaults to220 px. AllBaseBoxPropsapplied to the outer container.
[1.10.0] - 2026-04-23
Added
DatePickerelement renderer — rendersDatePickerUIElements using@react-native-community/datetimepicker(new optional peer dependency). On mount, initialises the variable fromdefaultValue(or today if omitted) as{ value: ISO string, label: locale-formatted string }. On change, updates the same variable; thelabelis human-readable (e.g."Apr 23, 2026"formode: "date"). SupportsminimumDate,maximumDate,mode(date/time/datetime),display(platform-specific — iOS defaults to"spinner", Android to"default"),textColor,accentColor,locale, and allBaseBoxPropsfor the wrapping container.
[1.9.0] - 2026-04-22
Added
CheckboxGroupelement renderer — rendersCheckboxGroupUIElements as a vertical (default) or horizontal list of tappable checkbox items. Each item shows a square checkbox indicator and a label; tapping toggles the item's value in/out of the selected set. On mount, setsdefaultValuesintocomposableVariables(keyed byvariableName) as{ value: JSON.stringify(string[]), label: string }. Subsequent toggles update the same entry. Supports all per-item style props (itemBackgroundColor,itemSelectedBackgroundColor,itemBorderColor,itemSelectedBorderColor,itemBorderRadius,itemBorderWidth,itemColor,itemSelectedColor,itemFontSize,itemFontWeight,itemFontFamily,itemPadding,itemPaddingHorizontal,itemPaddingVertical),gap,direction, and allBaseBoxPropsfor the group container.
[1.8.1] - 2026-04-22
Added
alignSelfon allBaseBoxPropselements —Input,RadioGroup,Image,Lottie,Rive,Icon, andVideorenderers now passalignSelffrom props to their root style. Accepts"auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline".alignSelfonStackElement—YStack/XStackrootViewnow appliesalignSelffrom props.
Fixed
InputElementflattened to bare<TextInput>— removed the wrapping<View>soalignSelf,width,height, and other layout props apply directly to the input rather than a container. All style props previously split between the wrapper and the innerTextInputare now on the singleTextInput.RadioGroupitem text collapse — replacedflex: 1withflexShrink: 1on the label<Text>inside each radio item. Prevents Yoga from collapsing the text when the item is inside anXStack.
[1.8.0] - 2026-04-21
Added
Buttonelement renderer — rendersButtonUIElements as a<TouchableOpacity>. Supports three variants:filled(solid primary background),outlined(transparent background with border), andghost(no background or border). Tapping callsonContinuewhenactionis"continue"or unset; other future action values are no-ops. Supportslabel,variant,backgroundColor,color,fontSize,fontWeight,fontFamily,textAlign,alignSelf, and allBaseBoxProps.RadioGroupelement renderer — rendersRadioGroupUIElements as a vertical (default) or horizontal list of tappable radio items, each with a circular indicator. Reads/writes the selected value viacomposableVariables(keyed byvariableName). On mount, sets thedefaultValueentry including the matching item's human-readablelabel. Supports all per-item style props (itemBackgroundColor,itemSelectedBackgroundColor,itemBorderColor,itemSelectedBorderColor,itemBorderRadius,itemBorderWidth,itemColor,itemSelectedColor,itemFontSize,itemFontWeight,itemFontFamily,itemPadding,itemPaddingHorizontal,itemPaddingVertical) and allBaseBoxPropsfor the group container.- Structured variable entries —
composableVariablesis nowRecord<string, ComposableVariableEntry>whereComposableVariableEntry = { value: string; label?: string }.RadioGroupstores{ value, label }on selection;Inputstores{ value }. Expression interpolation inTextelements resolveslabel ?? value, so{{variableName}}on a radio-backed variable displays the human-readable label (e.g."Monthly") instead of the raw value (e.g."monthly").
Note on semver: The
composableVariablestype changed fromRecord<string, string>toRecord<string, ComposableVariableEntry>. This is a technically breaking change to the context shape, but is published as a minor bump becausecomposableVariablesis an internal context value (not part of the public API contract). Existing consumers that only read the value string viavariables[key]remain unaffected — access.valuefor the same result.
Changed (internal)
ComposableScreenelement components and types split intoelements/subfolder — one file per element.Renderer.tsxreduced from 630 to 58 lines;types.tsfrom 443 to 173 lines. ARenderContextobject replaces the five individual parameters previously threaded throughrenderElement.
[1.7.0] - 2026-04-21
Added
fontFamilysupport onTextelements — theTextrenderer now passesfontFamilyfrom element props directly to the React Native<Text>style. Any font family loaded by the host app (e.g. viaexpo-font) can be applied to a text node by settingfontFamilyin its props.
[1.6.0] - 2026-04-21
Added
Inputelement renderer — rendersInputUIElements as a styled<TextInput>. Supports all text input props (placeholder,placeholderColor,defaultValue,keyboardType,returnKeyType,autoCapitalize,secureTextEntry,maxLength,multiline,numberOfLines,editable) plus typography (color,fontSize,textAlign,padding*) andBaseBoxProps(backgroundColor,borderWidth,borderRadius,borderColor,width,height,opacity,margin*).- Variable context —
OnboardingProgressContextextended withcomposableVariables: Record<string, string>andsetComposableVariable.Inputelements write their value into this shared map on every keystroke (keyed byvariableName). Values survive navigation betweenComposableScreensteps because the context lives above the router. - Expression interpolation for
Textelements — whenmode: "expression",{{variableName}}patterns incontentare replaced with values fromcomposableVariablesat render time. Defaultmode: "plain"is unchanged. OnboardingProgressProviderandOnboardingProgressContextexported from the package's public API so host apps can wrap their root layout with the provider.
Fixed
InputElementComponentno longer subscribes toOnboardingProgressContextdirectly;setComposableVariableis threaded as a stable prop throughrenderElementinstead, preventing context-driven re-renders from stealingTextInputfocus on every keystroke.ComposableScreenStepTypeSchema.parse(step)is now wrapped inuseMemoso theelementsarray reference is stable across context-driven re-renders.ScrollViewinComposableScreenRenderernow useskeyboardShouldPersistTaps="handled"so a first tap on anInputinside aScrollViewcorrectly focuses the field rather than being swallowed.
[1.5.0] - 2026-04-21
Added
Iconelement renderer — rendersIconUIElements usinglucide-react-native(bundled, no extra install needed). Supportsname,size,color,strokeWidth, and allBaseBoxProps. Unknown icon names render nothing rather than crashing.Videoelement renderer — rendersVideoUIElements viaexpo-video(optional peer dep). Supportsurl,autoPlay,loop,muted,controls, and allBaseBoxProps. Shows an install-hint placeholder ifexpo-videois absent.expo-videoadded as optional peer dependency.
[1.4.0] - 2026-04-21
Added
Lottieelement renderer — rendersLottieUIElements vialottie-react-native. The package is an optional peer dep; if absent a placeholder view with an install hint is shown instead of crashing. Supportssource,autoPlay,loop,speed, and allBaseBoxProps.Riveelement renderer — rendersRiveUIElements viarive-react-native(optional peer dep with same graceful fallback). Supportsurl,autoplay,fit,alignment,artboardName,stateMachineName, and allBaseBoxProps.
Changed
BaseBoxPropsrefactor —width,height,opacity,margin*,padding*,borderWidth,borderRadius, andborderColorare now defined once in a sharedBaseBoxPropstype andBaseBoxPropsSchema, then extended byImage,Lottie, andRiveelement schemas.
Fixed
borderWidth,borderRadius, andborderColoronLottieandRiveelements now render correctly. Both native canvas components are wrapped in aViewwithoverflow: hiddenso border styles are applied by the wrapper rather than the animation view directly.
[1.3.0] - 2026-04-17
Added
ImageUIElement renderer forComposableScreen— mapsImagenodes to React Native<Image>with full prop pass-through:url,width,height,aspectRatio,resizeMode,borderRadius,borderWidth,borderColor,opacity, and all margin / padding shorthand props.aspectRatiofallback onImage— whenheightis not provided, the renderer appliesaspectRatio(explicit value or16/9default) so the image is always visible.
Fixed
- Removed unused
useSafeAreaInsetsimport and call fromComposableScreenRenderer(safe area is handled byOnboardingTemplate).
[1.2.0]
Added
- ComposableScreen renderer (under development) — renders the new
ComposableScreenstep type by recursively walking aUIElementtree and mapping each node to a nativeVieworText. The renderer now passes through all new layout props added in this release:borderWidth,borderRadius,borderColor,overflow,opacity,margin,marginHorizontal,marginVertical,width,height,minWidth,maxWidth,minHeight,maxHeighton stack elements;margin,marginHorizontal,marginVertical,borderWidth,borderRadius,borderColor, andopacityon text elements. packages/onboarding-ui/README.md— new README documenting the UI package, theComposableScreenelement tree API, and its supported props.
Note:
ComposableScreenis under active development. The renderer and element schema may change before they are considered stable.