Components API
SymbioteNative’s component goal is structural parity: reusable state, render, and native plumbing live in shared layers; adapters provide lifecycle and framework syntax.
Installation
Section titled “Installation”pnpm add @symbiote-native/componentsNearly every consumer gets this transitively: @symbiote-native/react,
@symbiote-native/vue, and @symbiote-native/angular each depend on it and
re-export the parts an app touches. Install it directly only when building a new
adapter or a native-view wrapper. @symbiote-native/engine and react-native
(>=0.86) are peer dependencies and stay top-level dependencies of the app.
Status matrix
Section titled “Status matrix”Every component below ships on React, Vue, Angular, and Svelte. What differs is the framework-shaped surface:
View— React useschildren; Vue uses the default slot; Svelte takes achildren: Snippetprop rendered with{@render children?.()}— closer to React’s callback-prop shape than to Vue’s slot. Refs are framework-shaped: Svelte’sViewforwards nobind:thisescape hatch of its own, so imperative access goes through thehostInstance()rune over a rawsymbiote-viewhost tag (or a component’s own exported functions, e.g.TextInput/ScrollView’sfocus/scrollTo).Text— Text children differ by framework; press events follow callback vs emit shape. Svelte’sTexttakes the same object-bag props asView(noIResponderProps, matching every other adapter’sITextProps).Image— Source resolution and statics are shared. Svelte’sImage.sveltere-derives the same prop-assembly logicrenderImage()encapsulates (buildImageBag, mirroring the source/width-height/resizeMode/tintColor folds field-for-field) rather than calling the shared function directly — documented duplication, not a behavior difference; statics (getSize/prefetch/queryCache/…) attach from the siblingindex.ts.ScrollView— Imperative handle, refresh, and sticky behavior need framework-specific examples. Svelte exposesscrollTo/scrollToEnd/flashScrollIndicators/getScrollNodeas plain exported functions read offbind:this, the Svelte-5 twin ofuseImperativeHandle/expose(). Sticky headers ride the same native driver as React/Vue onceisNativeAnimatedAvailable()is true. Honest gap:stickyHeaderIndices/invertStickyHeadersauto-wrap is not implemented — Svelte hands a component only an opaqueSnippet, with noChildren.toArray/slots.default()equivalent to pull “child at index N” out of it, so composeScrollViewStickyHeadermanually instead (it auto-wires to the parent’s scroll offset via Svelte context).Pressable— React uses a render child; Vue uses a scoped slot; Svelte uses a named snippet prop,{#snippet children({ pressed })}...{/snippet}. Events are plain callback props (onPress,onLongPress, …) exactly like React — Svelte never translates to an emit/Output shape.TextInput—onValueChangemaps to@value-changeon Vue and(valueChange)on Angular; Svelte keeps the RN name unchanged as a plain callback prop,onValueChange={(text, event) => …}— the same shape as React, no translation layer.valueis also$bindable(), sobind:value={x}works as sugar on top (Vue’sv-modelequivalent). The controlled write handshake (event-count-gatedsetTextAndSelectioncommand) is shared logic across all four.Switch—onValueChangemaps to@value-changeon Vue and(valueChange)on Angular; Svelte again keepsonValueChangeas a plain callback prop, React-shaped, withvaluealso$bindable()forbind:value={x}. Controlled snap-back (dispatchViewCommandwhen native reports a value the parent rejects) is shared logic.ActivityIndicator— Mostly shared render-only props. Svelte callsrenderActivityIndicator()directly and syncs its one child through the genericdescriptorToSveltebridge (mountDescriptorChildren) rather than hand-authoring the spinner tag.- Lists — Render item/slot shape differs; windowing math is shared. Svelte’s
cell renderer is a required named snippet prop,
item:{#snippet item({ item, index, separators })}...{/snippet}— not arenderItemcallback prop, and not#itemscoped-slot syntax. Honest gaps:SectionList/VirtualizedSectionListand multi-columnFlatListare compile-verified only (viasvelte/compilerdirectly), not execution-verified by a running smoke test the way single-columnFlatList/VirtualizedListare; andIVirtualizedListProps/IFlatListProps/IVirtualizedSectionListPropsdo not yet extendIAccessibilityProps/IAriaPropson Svelte, sotestIDandaccessibilityLabelhave no effect on a Svelte list today, unlike React. Animated— The value graph is shared; component wrapping differs by adapter (see the Animations guide). Svelte has no genericAnimated.createAnimatedComponent(Component)— a compiled Svelte component isn’t a runtime value you can wrap with one call the way React’screateElement/Vue’sh()allow — soAnimated.View/.Text/.Image/.ScrollVieware each their own hand-authored.sveltefile sharing only the non-visual reconcile logic. Honest gaps:Animated.FlatListandAnimated.SectionListhave no Svelte wrapper yet (follow-up, not silently dropped); theTouchable*family’s press-fade is a self-containedsetTimeout-driven tween reproducingAnimated.timing’s curve, not a real native-threaduseNativeDriveranimation.Modal,KeyboardAvoidingView,SafeAreaView,RefreshControl,ImageBackground,InputAccessoryView,StatusBar,Button— shared render/behavior logic, framework-shaped props and events same as above. Svelte’sModalhand-authors its two fixed host tags (symbiote-modal>symbiote-view) and readsrenderModal()’s output straight off the knownprops/children[0].propspositions, the Svelte twin of React’s/Vue’s element-chain.StatusBaris the one runtime module with a real declarative half on every adapter: Svelte renders nothing and re-applies props via$effect(the Svelte twin of Vue’swatchEffect-based version).- The
Touchable*family (TouchableOpacity,TouchableHighlight,TouchableWithoutFeedback,TouchableNativeFeedback) — built on the same shared press state machine asPressable. SeeAnimated’s honest-gaps note above for the one Svelte-specific shortfall (the tween stand-in for a real native-driven fade). PanResponder— an engine-level gesture-recognition module, not a component; shared across every adapter unchanged.
Shared vs framework-specific
Section titled “Shared vs framework-specific”Shared when the prop is plain native data:
- booleans, strings, numbers;
- native style objects;
- accessibility and ARIA aliases;
- platform constants and native payload shapes.
Framework-specific when the prop contains framework values:
- children or slots;
- render callbacks returning framework elements;
- refs and imperative handles;
- framework-level event conventions.
Event naming examples
Section titled “Event naming examples”| Component | React | Vue | Angular | Svelte | Payload |
|---|---|---|---|---|---|
Pressable |
onPress |
@press |
(press) |
onPress |
ISymbioteEvent |
Pressable |
onLongPress |
@long-press |
(longPress) |
onLongPress |
ISymbioteEvent |
Switch |
onValueChange |
@value-changeor v-model |
(valueChange) |
onValueChange |
boolean |
TextInput |
onValueChange |
@value-changeor v-model |
(valueChange) |
onValueChange |
string |
View |
onLayout |
@layout |
(layout) |
onLayout |
ISymbioteEvent |
TextInput’s React/Vue callback fires as (text, event) — one value merged
from what used to be two separate callbacks. Angular can’t do that in one
EventEmitter, so it keeps a second, separate (change) output for the raw
event alongside text-only (valueChange). Svelte’s onValueChange fires the
identical (text, event) shape React’s does — the adapter is a flat
object-bag adapter (every prop, handlers included, rides one object handed to
the shim element’s p setter), so there is no per-event translation layer to
begin with: the callback prop name is RN’s own name, unchanged.
Angular’s names ((press), (longPress), (valueChange), (change),
(layout)) are all real @Output() EventEmitters, bound like
(press)="handler($event)". The one permanent exception is the scroll-family
events (onScroll, onScrollBeginDrag, onScrollEndDrag,
onMomentumScrollBegin, onMomentumScrollEnd) on ScrollView and the list
components, which stay plain callbacks bound as an @Input() —
[onScroll]="handler" — because they can carry an Animated.event(...)
marker for native-driven scroll, and @Output() only binds a template
listener expression, never an arbitrary value. Svelte has no @Output()
equivalent at all — every event on every component, scroll-family included,
is a plain callback prop, so this Angular-only exception has no Svelte
counterpart to carve out.
Svelte’s own two-way-binding primitive, the nearest equivalent to Vue’s
v-model, is $bindable() (let { value = $bindable() } = $props(), wired
with bind:value={x} at the call site). The shipped TextInput/Switch
(and Slider, from the separate @symbiote-native/slider package) declare
value this way, so both styles work: <TextInput bind:value={x} /> and the
explicit <TextInput value={x} onValueChange={setX} /> that Svelte shares
with React. Supplying onValueChange on the same instance as bind:value
takes over the echo — the component can’t detect binding from the inside, so
pass one or the other, not both, on one control. See How to: two-way bind a
value for the full picture.
Framework event idioms side by side
Section titled “Framework event idioms side by side”// React — callback props<Pressable onPress={event => console.log(event)} /><Switch value={enabled} onValueChange={setEnabled} /><!-- Vue — emits, or v-model on controlled components --><Pressable @press="event => console.log(event)" /><Switch v-model="enabled" />// Angular — @Output() everywhere, except the scroll-family callback @Input()s@Component({ template: ` <Pressable (press)="onPress($event)" /> <Switch [value]="enabled" (valueChange)="setEnabled($event)" /> `,})<!-- Svelte — callback props, same names as React; no emits, no @Output() --><script lang="ts"> import { Pressable, Switch } from '@symbiote-native/svelte';
let enabled = $state(false);</script>
<Pressable onPress={event => console.log(event)} /><Switch value={enabled} onValueChange={next => (enabled = next)} />App code reaches these through an adapter. The exports below are the seam an adapter (or a native-view wrapper package) drives directly.
Descriptor model
Section titled “Descriptor model”A render function returns a Descriptor tree — a tiny framework-agnostic node
description each adapter maps onto its own element (descriptorToReact →
React.createElement, descriptorToVue → h()). Svelte has no such generic
walker: since Svelte compiles templates statically, each component
hand-authors the fixed-shape host tag(s) its render*() counterpart always
produces and reads the returned props/children off known positions
(descriptor.props, descriptor.children[0]); mountDescriptorChildren
(descriptor-to-svelte.ts) exists only to sync an already-fixed-shape
Descriptor’s children onto a literal host tag without recreating nodes on
every update — narrower in scope than descriptorToReact/descriptorToVue,
not a like-for-like replacement.
| Export | Signature | Description |
|---|---|---|
el |
el(type, props?, children?, key?): IDescriptor |
Build a host-element descriptor of any type; type is an open string, so a component can paint a raw Fabric view name as well as a symbiote-* primitive |
txt |
txt(props?, children?): IDescriptor |
Shorthand for a symbiote-text descriptor |
IDescriptor |
{ type, props, children, key? } |
The node every render function returns |
IDescriptorType |
string |
The host component to paint — kept open, since components register their own host element names with the engine |
IDescriptorProps |
Record<string, unknown> |
Open prop bag (style, events, accessibility, native props), forwarded onto the framework element verbatim |
IDescriptorChild |
IDescriptor | string |
A nested descriptor or a raw text child |
Native component names
Section titled “Native component names”| Export | Signature | Description |
|---|---|---|
descriptorFor |
descriptorFor(type: string): IComponentDescriptor |
Resolve an intrinsic to its Fabric component name for the current platform. An unknown symbiote-* type throws (a typo in our own code); any other string passes through as a raw Fabric view name from a library’s codegen component |
COMPONENT_DESCRIPTORS |
Readonly<Record<string, IComponentDescriptor>> |
The platform-selected intrinsic → Fabric name map. The tables are Metro-split (.ios/.android filename selects, no Platform.OS read) |
buildDescriptors |
buildDescriptors(names): Readonly<Record<string, IComponentDescriptor>> |
Assemble the descriptor map a platform name table exports, pairing each name with its platform-invariant isText flag |
makeDescriptorFor |
makeDescriptorFor(descriptors): (type: string) => IComponentDescriptor |
Bind the resolver above to one descriptor map; each platform file uses it to produce its own descriptorFor |
ISymbioteIntrinsic |
union of 'symbiote-view', 'symbiote-text', 'symbiote-image', … |
Every intrinsic an adapter may emit. A name table must cover exactly these keys, so a missing primitive is a compile error rather than a runtime gap |
IComponentDescriptor |
{ component: string; isText: boolean } |
The resolved Fabric name plus whether it lays text (drives the RCTText/RCTVirtualText nesting choice) |
Render functions
Section titled “Render functions”Pure viewProps → Descriptor. Visual state enters only through arguments; no
framework, no lifecycle, no events.
| Function | Signature | Description |
|---|---|---|
renderActivityIndicator |
(view: IActivityIndicatorViewProps, platform: IActivityIndicatorPlatform) => IDescriptor |
Paint the spinner; platform carries the iOS/Android size-prop split |
renderSwitch |
(view: ISwitchViewProps, platform: ISwitchPlatform) => IDescriptor |
Paint the switch, mapping the track/thumb color props onto the platform’s native names |
renderImage |
(view: IImageViewProps) => IDescriptor |
Resolve the source, fold the width/height aliases into style, paint symbiote-image |
renderImageBackground |
(view: IImageBackgroundViewProps) => IDescriptor |
Compose an absolute-fill image behind projected children |
renderInputAccessoryView |
(view: IInputAccessoryViewViewProps) => IDescriptor |
Assemble the nativeID/backgroundColor host element |
renderModal |
(view: IModalViewProps) => IDescriptor |
Paint the modal host, applying the transparent/backdrop overrides on top of the generic style prop |
renderTextInput |
(view: ITextInputViewProps) => IDescriptor |
Pick the single-line or multiline intrinsic and map the resolved native props |
ScrollView, Pressable, the Touchable* family, Button, and the lists have
no render* function: their cell content or children are the framework’s own
elements, so the shared half is the state and math below, not a descriptor.
State machines
Section titled “State machines”Pure reducers and folds. The adapter supplies the lifecycle cell (React
useReducer, Vue ref/watch, Angular signals, Svelte $state/$effect
runes) and executes the effects.
| Export | Signature | Description |
|---|---|---|
createInitialSwitchState |
() => ISwitchState |
Initial Switch state — no native report seen yet |
switchReducer |
(state: ISwitchState, action: ISwitchAction) => ISwitchState |
Fold a native change report into the last-reported value; always returns a fresh object so the snap-back effect re-fires on every report |
shouldSnapBack |
(state: ISwitchState, fabricValue: boolean) => boolean |
Whether native reported a value the JS-held value rejects, so the switch must be commanded back |
valueFromChange |
(event: ISymbioteEvent) => boolean | undefined |
Read the boolean out of a native Switch change payload |
createInitialModalState |
(isVisible: boolean) => IModalState |
Initial Modal state, seeded from the first visible prop |
modalReducer |
(state: IModalState, action: IModalAction) => IModalState |
Gate the iOS keep-alive frame across show/hide |
shouldRenderModal |
(isVisible: boolean, state: IModalState) => boolean |
Whether the modal host should be in the tree this render |
createPressRuntime |
() => IPressRuntime |
Mutable per-instance press bookkeeping (timers, origin, suppression flags) the handlers write through |
createPressHandlers |
(config: IPressMachineConfig, runtime: IPressRuntime, host: IPressHost) => IPressHandlers |
The whole press lifecycle — delay, long-press timer, drift test against the responder region, termination |
buildPressableListeners |
(handlers: IPressHandlers, options: { disabled?, cancelable? }) => Record<string, unknown> |
Turn those handlers into the responder listener bag a host element takes; returns {} when disabled |
createTouchableFeedbackRuntime |
() => ITouchableFeedbackRuntime |
Per-instance timing state for the Touchable* feedback animation |
createTouchableFeedbackHandlers |
(config, runtime, callbacks) => ITouchableFeedbackHandlers |
Press handlers for the Touchable* family; the adapter supplies the Animated animation through callbacks |
computePressOutWait |
(heldFor: number, minPressDuration: number, delayPressOut: number) => number |
The deactivation floor — how long to keep the pressed visual after release |
resolveTextInputProps |
(input: ITextInputFoldInput) => IFoldedTextInputProps |
Fold inputMode/autoComplete/submitBehavior and friends into the native prop set |
foldText |
(value?: string, defaultValue?: string) => string | undefined |
Resolve the controlled value against defaultValue |
textFromChange |
(event: ISymbioteEvent) => string | undefined |
Read the text out of a native TextInput change payload |
eventCountFromChange |
(event: ISymbioteEvent) => number | undefined |
Read the native event count, the ordering token behind the controlled-write handshake |
shouldCommandText |
(lastNativeText: string | undefined, value: string | undefined) => value is string |
Whether JS must command text back onto the native input because the two have diverged |
createInitialListState |
<ItemT>() => IListState<ItemT> |
Initial VirtualizedList state — no offsets measured, empty committed window |
reduceList |
<ItemT>(state, action: IListAction<ItemT>, inputs: IListReducerInputs<ItemT>) => IListReduceResult<ItemT> |
The list orchestration reducer: window recompute → edge reached → viewability → initial scroll → maintainVisibleContentPosition, returned as state plus an effect list for the adapter to run |
listEffectSignature |
<ItemT>(state: IListState<ItemT>) => string |
Stable key over the state fields an effect depends on, so an adapter can skip re-running unchanged effects |
createInitialStickyState |
() => IStickyHeaderState |
Initial sticky-header state — unmeasured, untranslated |
reduceSticky |
(state, action: IStickyAction, inputs: IStickyReducerInputs) => IStickyReduceResult |
The per-header sticky machine: the zero-swallow gate, the debounce-delay pick, and the rebuild-on-input-change decision |
stickyEffectSignature |
(state: IStickyHeaderState) => string |
The same skip-unchanged key for the sticky interpolation effect |
ScrollView, lists, and layout helpers
Section titled “ScrollView, lists, and layout helpers”Platform-invariant math and plumbing with no state machine of their own.
| Export | Signature | Description |
|---|---|---|
resolveDecelerationRate |
(rate: 'normal' | 'fast' | number) => number |
Map RN’s named deceleration rates to the numeric native value |
selectScrollIntrinsics |
(isHorizontal: boolean, contentContainerStyle) => IScrollIntrinsics |
Pick the scroll host/content intrinsics — horizontal scroll is a separate native ViewManager on Android |
resolveScrollForwarding |
(inputs: IScrollForwardingInputs) => IScrollForwarding |
The scroll-forwarding decisions: which onScroll path to build (IScrollForwardMode — 'plain', 'sticky-native', 'sticky-js'), the resolved scrollEventThrottle, whether onLayout must capture the viewport height, and whether content cells stay un-flattened for maintainVisibleContentPosition/snap on Android. It returns decisions, not built handlers — those must stay framework-owned for identity reasons |
buildScrollViewHandle |
(getNode: () => ISymbioteNode | null) => IScrollViewHandle |
The imperative handle (scrollTo, scrollToEnd, flashScrollIndicators, …) over a lazily-read host node |
splitLayoutProps |
(style) => { outer, inner } |
RN’s key partition: layout keys (margin*, flex, …) go to the outer box, visual keys (background*, padding*, border*, …) stay on the inner view, for cases like the Android RefreshControl wrap |
forwardScrollEvent |
(handler, args: readonly unknown[]) => void |
Forward a native scroll event to a user handler, dropping non-event arguments |
isSymbioteEvent |
(value: unknown) => boolean |
Runtime guard for a normalized engine event |
attachStickyScroll |
(node: ISymbioteNode, value: AnimatedValue) => () => void |
Attach a native-driven onScroll → Animated.Value binding; returns the detach function |
computeStickyInterpolation |
(params: IStickyInterpolationParams) => { inputRange, outputRange } |
The sticky header’s interpolation ranges, including the inverted-list case |
nextStickyHeaderY |
(stickyHeaderIndices, indexOfIndex, headerLayoutYs) => number | undefined |
The following sticky header’s Y, which bounds how far the current one travels |
buildOffsets |
(count, measured, fixedLayout, averageLength) => … |
Cumulative cell offsets from measured cells, a fixed-layout function, or the running average |
computeWindow |
(count, offsets, lengths, scrollOffset, …) => … |
The render window for the current scroll position |
buildListPlan |
(params: IListPlanParams) => IListPlan |
The full cell plan for a pass — which cells render, plus the leading/trailing spacer extents |
computeViewableSet |
<ItemT>(params: IViewableSetParams<ItemT>) => { tokens, map } |
The currently viewable items, as tokens plus a key-indexed map |
diffViewable |
<ItemT>(previous, current, currentTokens) => { changed, hasChanged } |
Diff two viewable sets into the payload onViewableItemsChanged expects |
resolveItemKey |
<ItemT>(item, index, keyExtractor?) => string |
The cell key, falling back to the index when no keyExtractor is given |
chunkIntoRows |
<ItemT>(data: readonly ItemT[], columns: number) => IRow<ItemT>[] |
Group flat data into numColumns rows for FlatList |
rowKeyExtractor |
<ItemT>(row: IRow<ItemT>) => string |
Stable key for such a row |
flattenSections |
<ItemT>(sections, withSeparators) => { entries, headerIndices } |
Flatten SectionList sections into one entry list plus the sticky header indices |
unwrapEntryItem |
<ItemT>(entry?: ISectionEntry<ItemT>) => ItemT | undefined |
Read the item out of a flattened entry, or undefined for a header/separator entry |
sectionEntryKey |
<ItemT>(entry, index, keyExtractor?) => string |
Key for a flattened section entry |
scrollLocationToFlatIndex |
(headerIndices, sectionIndex, itemIndex) => number |
Translate a scrollToLocation section/item pair into the flat index the windowing math uses |
resolveKeyboardAvoidingLayout |
(params: IResolveKeyboardAvoidingLayoutParams) => IKeyboardAvoidingLayout |
The resolved style/height for KeyboardAvoidingView’s current behavior |
computeInset |
(frame, keyboard, verticalOffset) => number |
The overlap between the measured frame and the keyboard frame |
readKeyboardFrame |
(payload: unknown) => IKeyboardFrame | undefined |
Guard-read a native keyboard event payload |
readLayoutFrame |
(layout: unknown) => IMeasuredFrame | undefined |
Guard-read an onLayout payload |
buttonTextStyle |
ITextStyle |
The base Button label style |
resolveButtonTextStyle |
(color?: string, disabled?: boolean) => ITextStyle |
Fold color/disabled into that base style |
BUTTON_ACCESSIBILITY_ROLE |
'button' |
The role every adapter’s Button sets |
Constants with the same shared-math role are exported alongside them —
DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_PRESS_RECT_OFFSETS,
DEFAULT_ACTIVE_OPACITY, DEFAULT_VERTICAL_OFFSET, DEFAULT_WINDOW_SIZE,
DEFAULT_INITIAL_NUM_TO_RENDER, SCROLL_VIEW_BASE_VERTICAL,
STICKY_HEADER_Z_INDEX, INITIAL_EVENT_COUNT, among others.
Accessibility
Section titled “Accessibility”| Export | Signature | Description |
|---|---|---|
resolveAccessibilityProps |
<T extends IAccessibilityProps & IAriaProps>(props: T) => T |
Fold the web aliases (role, aria-*) into the canonical accessibility* props. Returns props untouched when no alias is present, so every adapter folds identically |
IAccessibilityProps |
type | The canonical accessibility* prop set |
IAriaProps |
type | The role/aria-* aliases that fold into it |
IAccessibilityRole, IRole |
type | The native role union and its web-alias twin |
IAccessibilityStateValue, IAccessibilityValue, IAccessibilityActionInfo |
type | State, value, and custom-action payload shapes |
Shared prop types
Section titled “Shared prop types”IResponderProps, IActivityIndicatorProps, ISwitchProps, and IButtonProps
are fully framework-agnostic at their core, so every adapter’s own prop type
layers only its own class-styling field on top of the shared base rather than
redeclaring the whole shape — React adds className?: string, Vue and
Svelte both add class?: (Svelte’s is ISvelteClassValue, a wider type that
additionally accepts a clsx-style object/array, resolved by the adapter
itself before it reaches the engine). IImageProps, ITextInputProps,
ITextInputHandle, IScrollViewHandle, IVirtualizedListHandle, and
IVirtualizedSectionListHandle ship here too; a prop type carrying framework
children, refs, or render callbacks stays per-adapter by design.
The *ViewProps types (IActivityIndicatorViewProps, ISwitchViewProps,
IImageViewProps, IModalViewProps, ITextInputViewProps,
IImageBackgroundViewProps, IInputAccessoryViewViewProps) are the inputs of
the matching render* function — already resolved, adapter-facing, not app-facing.
Re-exported from the engine
Section titled “Re-exported from the engine”imageStatics and setImageSourceResolver (plus IImageStatics, IImageSize,
IImageCacheStatus) originate in @symbiote-native/engine — they touch the native
bridge, so they stay out of the pure view layer — and are re-exported here so the
public Image.* surface an adapter assembles is unbroken.