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.
What a style value supports
Section titled “What a style value supports”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.
Style with CSS classes
Section titled “Style with CSS classes”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.
Vue SFC <style> blocks
Section titled “Vue SFC <style> blocks”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.
Svelte’s own <style> block
Section titled “Svelte’s own <style> block”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
Section titled “CSS Modules”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).
Vue <style module>
Section titled “Vue <style module>”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 { 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:
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):
pnpm add -D @symbiote-native/css-parser{ "scripts": { "pretypecheck": "css-dts ." }}-
css-dts(thegenerate-dts-clibin) walks the given paths and writes a realCard.module.css.d.tsnext to each CSS Modules file it finds, with the actual exported keys as literal properties — no index signature, sostyles.cradis a genuineerror TS2339undertsc/vue-tsc. Wire it as apretypecheckscript 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-pluginis 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 intsconfig.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 throughcss-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 { padding: 16px;}import styles from './Card.module.css';styles.crad; // typo: should be `.card`- In your editor, with the plugin registered,
styles.cradis 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 whethercss-dtsalready generatedCard.module.css.d.tson disk (viapretypecheck) — if that step is missing,styles.cradsilently type-checks asstringand 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.
SCSS, Sass, Less, and Stylus
Section titled “SCSS, Sass, Less, and Stylus”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.
What is not CSS
Section titled “What is not CSS”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.create — the alternative
Section titled “StyleSheet.create — the alternative”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.