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, @symbiote-native/angular, @symbiote-native/svelte,
and @symbiote-native/solid 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”view, text, pressable,
image, image-background, scroll-view, text-input, switch,
activity-indicator, safe-area-view, modal, refresh-control,
input-accessory-view, button, touchable-opacity,
touchable-native-feedback, touchable-without-feedback,
touchable-highlight, and sticky-header are lowercase tags an app writes
directly — React, Vue, Svelte, and Solid resolve them with no import; Angular
resolves them once its imports array carries SYMBIOTE_ELEMENTS. There is
no per-adapter wrapper component left for these, so the framework differences
that used to live in each adapter’s own render bridge (descriptorToReact,
descriptorToVue, a hand-authored Svelte host tag, Solid’s
descriptorToSolid) mostly don’t apply to them anymore — a pressable on
React and a pressable on Solid commit the identical Fabric tree, built by
the same shared host behavior in @symbiote-native/components/@symbiote-native/engine.
What still differs by framework:
- Event syntax —
onPress(React/Svelte/Solid),@press(Vue),(press)(Angular). See Event naming examples below. pressable’s pressed state no longer arrives as a render-prop child, a Vue scoped slot, or a Svelte snippet — those relied on a wrapper component body to call a child function, and a plain tag has no such body. WireonPressIn/onPressOutinto local state instead (each framework’s own API page shows the idiom); Angular already used real(pressIn)/(pressOut)outputs for this and is unaffected.- Lists (
FlatList,SectionList,VirtualizedList,VirtualizedSectionList) andKeyboardAvoidingVieware the one family still a genuine per-framework component:renderItem/the cell content returns a framework element (ReactNode, a Vue slot, a Svelte snippet, a Solid render function), which is exactly the shape a tag cannot express — see each framework’s own API page for the render-item signature. Animated.*wrapping stays per-adapter — see the Animations guide for how each framework hands an animated value into a tag’sstyleprop and whatcreateAnimatedComponentstill does where a genuine component (the lists) is being animated.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”| Tag | React | Vue | Angular | Svelte | Solid | Payload |
|---|---|---|---|---|---|---|
pressable |
onPress |
@press |
(press) |
onPress |
onPress |
ISymbioteEvent |
pressable |
onLongPress |
@long-press |
(longPress) |
onLongPress |
onLongPress |
ISymbioteEvent |
switch |
onValueChange |
@value-changeor v-model |
(valueChange) |
onValueChange |
onValueChange |
boolean |
text-input |
onValueChange |
@value-changeor v-model |
(valueChange) |
onValueChange |
onValueChange |
string |
view |
onLayout |
@layout |
(layout) |
onLayout |
onLayout |
ISymbioteEvent |
text-input’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. Solid’s
onValueChange fires the same (text, event) shape too, as an ordinary
callback prop: Solid JSX props reach the component as plain getters, with no
compiler-level event translation.
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 scroll-view 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. Solid has no @Output() equivalent either, for the
same reason.
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). text-input/switch
(and Slider, from the separate @symbiote-native/slider package) declare
value this way, so both styles work: <text-input bind:value={x} /> and the
explicit <text-input 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. Solid has no
bind:value/v-model sugar of its own on these components: value and
onValueChange are always explicit, the same pair React uses without the
sugar.
Framework event idioms side by side
Section titled “Framework event idioms side by side”// React — callback props, no import needed for the tags<pressable onPress={event => console.log(event)} /><switch value={enabled} onValueChange={setEnabled} /><!-- Vue — attrs listeners, or v-model on controlled tags --><pressable @press="event => console.log(event)" /><switch v-model="enabled" />// Angular — @Output() everywhere, except the scroll-family callback @Input()s@Component({ imports: [SYMBIOTE_ELEMENTS], 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"> let enabled = $state(false);</script>
<pressable onPress={event => console.log(event)} /><switch value={enabled} onValueChange={next => (enabled = next)} />// Solid — callback props, same names as React; no emits, no @Output(), no bind: sugarimport { createSignal } from 'solid-js';
const [enabled, setEnabled] = createSignal(false);
<pressable onPress={event => console.log(event)} /><switch value={enabled()} onValueChange={setEnabled} />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. For a primitive tag (view, switch, modal, …), the engine’s
own IHostBehavior.buildStructure consumes this directly at attach time — no
adapter is involved. The descriptorToReact/descriptorToVue/descriptorToSolid
bridges (and Svelte’s equivalent host-tag wiring) still exist for the one
family that remains a genuine per-framework component — the lists
(FlatList, SectionList, VirtualizedList, VirtualizedSectionList) and
KeyboardAvoidingView — since a list’s cell content is a real framework
element, not a Descriptor.
| 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 primitive tag name |
txt |
txt(props?, children?): IDescriptor |
Shorthand for a 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 intrinsic 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 'view', 'text', 'image', … |
Every intrinsic tag an app may write. 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 |
|---|---|---|
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 image |
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 |
These are called by the primitive’s own IHostBehavior.buildStructure in the
engine now, not by an adapter — that’s what lets a bare <modal>/<switch>/…
tag commit the same tree a wrapper used to. ActivityIndicator’s and
ImageBackground’s painting logic moved into their behaviors directly
(behaviors/activity-indicator/, behaviors/image-background.ts) with no
separate render* function left. The list family still builds its cell
content from the framework’s own elements, so the shared half there 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, Solid createSignal/createEffect) 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, Svelte
and Solid all 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; Solid’s is the plain IClassNameValue
the engine’s style registry resolves, matching Vue’s). 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.