Skip to content

Svelte API

Primitives (view, text, pressable, text-input, scroll-view, …) are plain lowercase intrinsic tags — write them directly in markup, no import needed. Import only what’s still a real component or module from @symbiote-native/svelte:

import { StyleSheet } from '@symbiote-native/svelte';

Svelte drives the same engine as React/Vue/Angular/Solid, but through a different seam: Svelte has no official custom-renderer API yet (createRenderer from svelte/renderer is still an unmerged proposal), so this adapter patches a handful of globalThis DOM classes instead — stock compiled Svelte output believes it’s talking to the real DOM, while every call actually routes into @symbiote-native/engine’s mutation API. Components are ordinary .svelte files; nothing about authoring one is adapter-specific beyond the prop/event shape below.

Terminal window
pnpm add @symbiote-native/svelte react-native svelte

react-native and svelte stay your app’s own top-level dependencies — react-native is the Metro version anchor, so it has to be pinned at the app root. @symbiote-native/engine is a peer dependency and installs alongside. Metro needs the package’s own transformer for .svelte files (@symbiote-native/svelte/metro-svelte-transformer) and two settings that are easy to miss: resolver.unstable_conditionNames: ['browser'] (without it Metro resolves Svelte’s SSR build and mount() throws lifecycle_function_unavailable), and compilerOptions.fragments: 'tree' in svelte.config.js (so the compiler emits DOM-shim-friendly document.createElement calls instead of an innerHTML template). examples/svelte is the reference for the full config.

  • Events: real camelCase props, not on:x directives — onPress, onLongPress, onValueChange, onLayout. Svelte templates already write props in camelCase, so there’s no kebab-case normalization step like Vue’s.
  • Children: a children prop typed Snippet, rendered internally via {@render children?.()} — not Svelte 4’s <slot />.
  • Render children: list components only (FlatList, SectionList, …) still pass state back via {#snippet} (Svelte’s twin of a scoped slot); a primitive tag like pressable has no component body to render one from — wire onPressIn/onPressOut into a rune instead, see Children below.
  • Refs: every primitive is a plain tag now, so bind:this reaches the committed host node directly. See refs and attachments for the full picture, including hostInstance()/findNodeHandle().
  • Styles: style/class props, plus a component’s own scoped <style> block — see Scoped styles below.
  • Two-way binding: value = $bindable() on TextInput/Switch/Slider — see Two-way binding below.
<pressable {onPress} {onLongPress} />
<switch bind:value={enabled} />
<text-input bind:value={text} onValueChange={(text, event) => onChange(event)} />
<view {onLayout} />

TextInput’s onValueChange fires (text, event) — the same merged shape React uses, unlike Angular, which needs a second output for the raw event because an EventEmitter only carries one value.

pressable is a plain tag, so pressed state no longer arrives through a {#snippet} — wire the two press events into a rune instead:

<script lang="ts">
let pressed = $state(false);
</script>
<pressable
{onPress}
onPressIn={() => (pressed = true)}
onPressOut={() => (pressed = false)}
>
<text>{pressed ? 'Release' : 'Press me'}</text>
</pressable>

List renderers still pass state back through a {#snippet}, since the rendered cell is a real Svelte snippet rather than a React node or a Vue slot.

<script lang="ts">
let node: unknown;
</script>
<view testID="target" bind:this={node} />

Every primitive is an ordinary tag, so bind:this reaches the committed native node directly — no {@attach} indirection needed for a plain ref. {@attach fn} still works as an ordinary Svelte attachment when you want a mount/unmount pair rather than a single assignment. Imperative methods like scroll-view’s scrollTo/scrollToEnd/flashScrollIndicators live on the host node itself — reach them through hostInstance(node). Full mechanism, including findNodeHandle(): How to: refs and attachments in Svelte.

Switch, TextInput, and the Slider wrapper each declare value = $bindable():

<switch bind:value={enabled} />
<text-input bind:value={text} />
<Slider bind:value={volume} minimumValue={0} maximumValue={1} />

Supplying your own onValueChange on the same instance silently disables the bind: echo — a component can’t detect that it’s bound from the inside, so pick one form per instance. Full pattern, including $bindable() for a wrapper component of your own: Two-way binding.

A component’s own <style> block is scoped by default, the same as real Svelte — every class it declares gets a per-file scope suffix, resolved through the same style registry every adapter’s class/className/:class path shares:

<view class="card">
<text>Scoped — no other component's .card collides</text>
</view>
<style>
.card {
padding: 16px;
background-color: #13243a;
}
</style>

:global(.name) escapes scoping the same way it does in real Svelte. Svelte’s own scoping deliberately doesn’t reach into a child component’s own markup — scope a class where it’s declared, not where it’s used. @media/@keyframes/ pseudo-class selectors are dropped with a diagnostic; React Native has no such concept. See the Styling guide for the full compiler pipeline shared by every adapter.

@symbiote-native/svelte re-exports the same runtime utilities as the other adapters, so app code keeps one import root: Platform, StyleSheet, PlatformColor, DynamicColorIOS, PixelRatio, Alert, Share, Linking, Keyboard, Vibration, ActionSheetIOS, BackHandler, ToastAndroid, PermissionsAndroid, AccessibilityInfo, I18nManager, Settings, LayoutAnimation, InteractionManager, StatusBar, AppState, findNodeHandle, and dlog/isDebug for diagnostic logging.

Two reactive reads are runes instead of hooks/composables — Svelte’s own term, and this adapter’s lifecycle-helper bucket is named runes/ to match:

<script lang="ts">
import { useWindowDimensions, useColorScheme } from '@symbiote-native/svelte';
const dimensions = useWindowDimensions();
const scheme = useColorScheme();
</script>
<text
>{dimensions.current.width}×{dimensions.current.height} · {scheme.current}</text
>

Each returns a boxed getter ({ get current() { ... } }), not a bare $state value — Svelte 5 reactivity is lexically scoped to the module that declares it, so a raw $state returned from a function loses its reactivity for the caller; read .current the same way you’d unwrap a Vue Ref.value.

On Android, Keyboard and Settings do nothing until you also install @symbiote-native/android: keyboardDidShow/keyboardDidHide never fire, because Android’s only stock keyboard-event source is ReactRootView’s layout listener and the bridgeless surface SymbioteNative mounts never triggers it, and Settings reads back null, because RN’s Settings has no stock Android implementation at all.

An animated value dropped straight into a primitive’s prop resolves in the engine — <view style={{ opacity: someAnimatedValue }} /> just works, no wrapper component needed. Animated (both the JS and native driver) and PanResponder are re-exported from @symbiote-native/svelte — see the Animations guide for the full surface. Animated.FlatList/Animated.SectionList have no Svelte wrapper yet.

<script lang="ts">
import { createTunnel, TunnelIn, TunnelOut } from '@symbiote-native/svelte';
const overlayTunnel = createTunnel(); // module-level singleton, importable from both surfaces
let toastVisible = $state(false);
</script>
<!-- inside the surface that should paint the content -->
<TunnelOut tunnel={overlayTunnel} />
<!-- inside any other component, in any surface -->
{#if toastVisible}
<TunnelIn tunnel={overlayTunnel}>
<text>Toast</text>
</TunnelIn>
{/if}

React’s createPortal (and Vue’s Teleport) have no Svelte twin at all — createPortal is react-reconciler’s own Fiber-level HostPortal primitive with no equivalent in a framework with no reconciler, and Svelte has no built-in Teleport-shaped relocation either — so createTunnel is the only cross-surface primitive here, for two independently mount()-ed surfaces that share no Fabric tree at all. TunnelIn/TunnelOut take an explicit tunnel prop rather than tunnel.In/tunnel.Out member access like React/Vue: a .svelte file compiles to one fixed, top-level component, so there’s no runtime factory to construct a fresh pair per createTunnel() call the way Vue’s defineComponent can.

index.js
import { createApp } from '@symbiote-native/svelte/bootstrap';
import App from './App.svelte';
createApp(App).mount('MyApp');

Mirrors React/Vue’s createApp(App).mount(appName) two-step idiom. The lower-level AppRegistry/setHostRegistrar entry point described in the Core API is also re-exported directly, for app code that wants it. AppRegistry.setWrapperComponentProvider is re-exported but currently ignored on this adapter: Vue composes a wrapper via h(wrapper, ...) at call time, but a compiled .svelte file has no equivalent runtime composition of two independently-authored components — there’s no working wrapper-component example to show here yet.

Svelte 5’s own <svelte:boundary> is currently Svelte-exclusive among the five adapters — React’s componentDidCatch boundary and Vue’s onErrorCaptured aren’t documented patterns here yet:

<svelte:boundary onerror={(error, reset) => console.error(error)}>
<Child />
{#snippet failed(error, reset)}
<text>{error.message}</text>
{/snippet}
</svelte:boundary>

Full behavior, including the {@const}-doesn’t-capture-reset() gotcha: How to: catch render errors with <svelte:boundary>.

Do not pass React component packages to the Svelte adapter. A third-party React Native package that ships a JavaScript React component still uses the React dispatcher internally. Non-React adapters need native-view wrappers instead — see the Slider package for the reference implementation, shipping on React, Vue, Angular, Svelte, and Solid alike.