Skip to content

Styling

SymbioteNative’s preferred way to style a native view is ordinary CSS — a class string resolved through a shared runtime registry, compiled to native style objects at build time. Every current example app (React, Vue SFC, Vue TSX, Angular, Svelte, Solid) styles this way. StyleSheet.create — React Native’s own JS-object style model — is still fully supported and works identically everywhere; use it for genuinely dynamic values a CSS class can’t express, or if you’d rather not reach for CSS.

Whichever path produces it — a compiled CSS class or a StyleSheet.create object — a style value supports the same things once it reaches the engine:

  • React Native-style layout and text properties.
  • Arrays and conditional style entries through the engine style flattener.
  • Platform utilities such as Platform.select().
  • Color processing through the engine’s platform color seam.

A side-effect-only import (no default export) registers every class in the file globally, from any adapter’s own source file:

import './App.css';
function Card() {
return (
<view className="card">
<text className="title">Native surface</text>
</view>
);
}

The same import './App.css' + class="card" shape works from a Vue <script> and an Angular component. Every canary app (examples/react, examples/vue-sfc, examples/vue-tsx, examples/angular) ships its static look this way — see App.css (or the SFC <style> block below) in any of them for a full stylesheet running on device.

A build-time-only compiler (@symbiote-native/css-parser) turns each rule into a plain style object; class="card" resolves it back at render time through a runtime registry (registerStyles/resolveClassName, exported from @symbiote-native/engine). This registry is shared by every adapter, not just Vue — React’s className, Vue’s class/:class, and Angular’s class/[class] all resolve through the same lookup.

A Vue SFC <style> block (including scoped) compiles into native style objects the same way — no CSS ships in the app bundle, no runtime CSS engine exists:

<template>
<view class="card">
<text class="title">Native surface</text>
</view>
</template>
<style scoped>
.card {
padding: 16px;
border-radius: 12px;
background-color: #111827;
}
.title {
color: #ffffff;
font-size: 18px;
font-weight: 600;
}
</style>

:style/:class composition and cascade precedence (explicit :style always wins over class-derived style) work the same as any other Vue app. scoped works the same as it does for real Vue: opt in with the attribute and every class in that block is suffixed to a per-component scope, so the same class name in two components never collides. :global(...) is the escape hatch back out — for a utility class meant to apply anywhere, exactly like real Vue’s scoped-CSS semantics, minus the DOM underneath. See the .row/.flex1 utility classes in examples/vue-sfc/App.vue for a real scoped stylesheet with a :global() escape running on device.

box-shadow, transform, filter, transform-origin, and background-image (gradients) all map onto Fabric’s own native style props and compile like any other property:

.gradient-card {
height: 64px;
border-radius: 12px;
background-image: linear-gradient(to right, #2b6cb0, #f6ad55);
}

A property with genuinely no RN equivalent (animation, pseudo-classes, media queries) is dropped with a build warning instead — see “What is not CSS” below.

A Svelte component’s inline <style> block compiles into native style objects the same way, through the same build-time compiler — with one difference from Vue: scoping is on by default, no scoped attribute to opt into:

<view class="card">
<text class="title">Native surface</text>
</view>
<style>
.card {
padding: 16px;
border-radius: 12px;
background-color: #111827;
}
.title {
color: #ffffff;
font-size: 18px;
font-weight: 600;
}
</style>

Every class in the block is suffixed to a per-component scope, exactly like real Svelte’s own scoped-CSS semantics minus the DOM underneath — the same class name in two components never collides. :global(...) is the escape hatch back out, for a utility class meant to apply anywhere. See examples/svelte/screens/StyleShowcaseScreen.svelte for a real component using both a scoped block and a :global() mark running on device. Svelte has no inline <style module> equivalent — a CSS Modules name→scopedName map is always a standalone .module.css import (below), the same as every other adapter.

CSS Modules — scoped classes resolved through a name→scopedName map instead of a bare class string — work two ways, both suffixing classes with the same scheme (a per-file scope id).

Inline, in the same SFC, exactly like real Vue:

<template>
<view :class="$style.card">
<text :class="$style.title">Native surface</text>
</view>
</template>
<style module>
.card {
padding: 16px;
border-radius: 12px;
background-color: #111827;
}
.title {
color: #ffffff;
font-size: 18px;
font-weight: 600;
}
</style>

$style is the default binding name (or whatever module="name" sets); it’s a closed-over const holding the compiled name→scopedName map, usable from both the template and <script setup> code.

Standalone .module.css files — any adapter

Section titled “Standalone .module.css files — any adapter”

import styles from './Card.module.css' works the same from a React .tsx, a Vue <script>, or an Angular .ts — the class+style merge lives once in the engine, not per adapter.

Card.module.css
.card {
padding: 16px;
border-radius: 12px;
background-color: #111827;
}
.title {
color: #ffffff;
font-size: 18px;
font-weight: 600;
}
import styles from './Card.module.css';
function Card() {
return (
<view className={styles.card}>
<text className={styles.title}>Native surface</text>
</view>
);
}
<script setup lang="ts">
import styles from './Card.module.css';
</script>
<template>
<view :class="styles.card">
<text :class="styles.title">Native surface</text>
</view>
</template>
import { Component } from '@angular/core';
import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';
import styles from './card.module.css';
@Component({
selector: 'app-card',
standalone: true,
imports: [SYMBIOTE_ELEMENTS],
template: `
<view [class]="styles.card">
<text [class]="styles.title">Native surface</text>
</view>
`,
})
export class CardComponent {
readonly styles = styles;
}

A plain .css import has no ambient type declaration needed (it’s side-effect only), but .module.css needs one so TypeScript resolves the import at all — a loose fallback works with no extra setup:

css.d.ts
declare module '*.module.css' {
const classes: Record<string, string>;
export default classes;
}

That types styles.card as string, not a literal key — a typo (styles.crad) still type-checks and silently resolves to undefined at runtime instead of failing the build.

Real key narrowing — css-dts and the TypeScript plugin

Section titled “Real key narrowing — css-dts and the TypeScript plugin”

@symbiote-native/css-parser ships two more pieces that close that gap for a standalone .module.css/.module.scss/.module.less/.module.styl file — add it as a direct devDependency of your app, alongside the ambient fallback above (it still covers any file the generator hasn’t reached yet):

Terminal window
pnpm add -D @symbiote-native/css-parser
package.json
{
"scripts": {
"pretypecheck": "css-dts ."
}
}
  • css-dts (the generate-dts-cli bin) walks the given paths and writes a real Card.module.css.d.ts next to each CSS Modules file it finds, with the actual exported keys as literal properties — no index signature, so styles.crad is a genuine error TS2339 under tsc/vue-tsc. Wire it as a pretypecheck script so it runs before every typecheck, local or CI, with no dependency on Metro or a dev server; css-dts --watch <dir> regenerates on save for a long-running local loop instead.

  • @symbiote-native/css-parser/typescript-plugin is a TypeScript language service plugin for live in-editor autocomplete on .module.css (plain CSS Modules only — SCSS/Less/Stylus fall back to the loose ambient type in the editor, since a language-service plugin must resolve synchronously and those preprocessors don’t offer a sync compile). Register it in tsconfig.json:

    {
    "compilerOptions": {
    "plugins": [{ "name": "@symbiote-native/css-parser/typescript-plugin" }]
    }
    }

    It recomputes on every keystroke inside the editor’s own tsserver, so there’s no watch process to keep running just for autocomplete. It only extracts simple .foo { } selectors — a compound (.btn.primary) or descendant (.card .title) selector still resolves correctly at build time through css-dts/the runtime registry, just without a matching in-editor suggestion.

css-dts is the CI/tsc-time correctness guarantee, the plugin is the in-editor convenience — use both together. Vue’s inline <style module> block gets its own, looser autocomplete for free from Vue’s own language tools (an index signature, so it still won’t catch a typo); a standalone .module.css import is the only form css-dts can narrow today.

Why both — they aren’t two implementations of the same fix. compilerOptions.plugins is a language-service-only extension point: only a running tsserver (the process behind your editor’s live diagnostics) loads it. The standalone tsc/vue-tsc binary — what pretypecheck and any CI typecheck job actually run — reads the rest of tsconfig.json but silently ignores plugins entirely; it never starts a language service. So given

Card.module.css
.card {
padding: 16px;
}
import styles from './Card.module.css';
styles.crad; // typo: should be `.card`
  • In your editor, with the plugin registered, styles.crad is flagged the moment you type it — no build step involved.
  • In pnpm run typecheck / CI, the editor and its plugin aren’t running at all. The only thing standing between this typo and a green build is whether css-dts already generated Card.module.css.d.ts on disk (via pretypecheck) — if that step is missing, styles.crad silently type-checks as string and passes.

Skipping either one leaves a real gap: no plugin means no live feedback while typing; no css-dts/pretypecheck means CI can’t catch the same typo at all.

Optional preprocessing, resolved by file extension (.scss/.sass, .less, .styl/.stylus) for a standalone file, or by <style lang="..."> for an inline Vue block. Each source reduces to plain CSS before scoped/module/ :global() handling runs, so every mechanism above works identically regardless of source language:

<style lang="scss" scoped>
$accent: #42b883;
.card {
padding: 16px;
&:hover {
// dropped: RN has no hover — see "What is not CSS" below
}
.title {
color: $accent;
}
}
</style>
import styles from './Card.module.scss';

sass, less, and stylus are lazy, optional dependencies — install whichever one you actually use (npm i -D sass, npm i -D less, or npm i -D stylus); a project that never authors .scss/.less/.styl never needs any of the three.

There is no DOM, so there is nothing for a browser selector to match — pseudo-classes (:hover, :focus, :nth-child), media queries, and animation have no RN target and are dropped at build time, not silently misapplied. :active is the single exception, and it is not a general opening of CSS state: a press is the one state the engine itself tracks, so .btn:active compiles to a token beside .btn and wins the cascade exactly as its specificity says. Nothing else in that family follows — there is no hover or focus for it to mirror. Prefer onPressIn/onPressOut in the examples you write; reach for :active when the pressed look is purely visual and you want it resolved without a render. Tailwind CSS is not supported. Everything else — plain CSS (including box-shadow, transform, filter, transform-origin, and background-image), CSS Modules, and the SCSS/Less/Stylus preprocessors above — is a stable, build-time-only compile step: no CSS engine, no selector matching, and no extra runtime cost ships in the app bundle. It works the same way across React, Vue, Angular, Svelte, and Solid, including Svelte’s own default-scoped <style> block.

StyleSheet is React Native’s own JS-object style model, re-exported from @symbiote-native/engine by every adapter. It’s still fully supported — reach for it for a value that’s genuinely computed at runtime, or if you’d rather not introduce a CSS file at all:

import { StyleSheet } from '@symbiote-native/react';
function Card() {
return (
<view style={styles.card}>
<text style={styles.title}>Native surface</text>
</view>
);
}
const styles = StyleSheet.create({
card: { padding: 16, borderRadius: 12, backgroundColor: '#111827' },
title: { color: '#ffffff', fontSize: 18, fontWeight: '600' },
});
<script setup lang="ts">
import { StyleSheet } from '@symbiote-native/vue';
const styles = StyleSheet.create({
card: { padding: 16, borderRadius: 12, backgroundColor: '#111827' },
title: { color: '#ffffff', fontSize: 18, fontWeight: '600' },
});
</script>
<template>
<view :style="styles.card">
<text :style="styles.title">Native surface</text>
</view>
</template>
import { Component } from '@angular/core';
import { StyleSheet, SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';
@Component({
selector: 'app-card',
standalone: true,
imports: [SYMBIOTE_ELEMENTS],
template: `
<view [style]="styles.card">
<text [style]="styles.title">Native surface</text>
</view>
`,
})
export class CardComponent {
readonly styles = StyleSheet.create({
card: { padding: 16, borderRadius: 12, backgroundColor: '#111827' },
title: { color: '#ffffff', fontSize: 18, fontWeight: '600' },
});
}

StyleSheet.create() is intentionally lightweight — it’s identity at runtime, the engine flattens a raw object literal exactly the same way. Its value is preserving literal types and giving a file one predictable style block, not a different rendering path from CSS.