Theming and Tailwind
React Native has no CSS engine: NativeWind compiles Tailwind into style objects, attaches them
through cssInterop and provides a variant runtime because native views do not understand
className. Angular Native's cascade already supports selectors, specificity, inheritance, media
queries and custom properties. <view class="flex-1 bg-blue-500 p-4"> matches through class,
without an interop layer or className prop.
A build step converts web CSS to the native subset; a preset adds native-specific vocabulary.
The build step
@import 'tailwindcss/theme.css';
@import 'tailwindcss/utilities.css';
@import '@ng-native/tailwind/native.css';Import theme.css and utilities.css instead of tailwindcss to exclude preflight's browser
reset (html, ::before, -webkit-*). This documentation site imports preflight explicitly,
since its own chrome is a real document even though the components in it are not.
The compiler unwraps @layer and @supports, drops @property, substitutes static theme
variables, and folds calc() through lightningcss. A theme variable the app sets itself stays a
var() instead (see Tokens cross component boundaries). For
example, calc(var(--spacing) * 4) is constant but cannot resolve on a device. The compiler
converts oklch() to sRGB as in component stylesheets.
Then the utilities compile:
p-4 -> { paddingTop: 16, paddingRight: 16, paddingBottom: 16, paddingLeft: 16 }
gap-2 -> { gap: 8 }
h-16 -> { height: 64 }
rounded-lg -> { borderTopLeftRadius: 8, ... }
bg-blue-500 -> { backgroundColor: 'rgb(43, 127, 255)' }Unsupported styles produce diagnostics that name the rule's selector, which is what to search the app for:
[angular-native] .appearance-none (Tailwind): dropped 'appearance': 'appearance' has no React Native equivalent: no style prop of a native view does what it does.Diagnostics prevent unsupported styles from silently doing nothing.
The preset
@ng-native/tailwind contains three files: shared.css for platform-independent styles, plus
native.css and web.css, which import it and define four platform-dependent variants.
hover: is the pressed state
:hover is permanently unsupported by the native matcher and dropped with a build warning. The
hover: variant instead matches :active, which the engine sets on touched views and their
ancestors, letting hover classes express touch engagement.
data-hover is for a trackpad, a mouse or a pencil, through React Native's W3C pointer events,
which are off until an app turns them on. Components
must listen for these events and set the attribute themselves. Without a pointer the variant
remains a press state.
/* native.css */
@custom-variant hover (&:active, &[data-hover]);
/* web.css */
@custom-variant hover (&:hover, &:active, &[data-hover]);The browser variant includes :active for touchscreen laptops and phone browsers, where :hover
may never fire or may stick after a tap. Use press: and hovered: to distinguish the states.
focus-visible: is focus: on native
The web distinguishes keyboard focus from clicks to avoid unnecessary rings. On phones, focus
comes from keyboards, remotes or assistive technology, where rings are useful. Since
:focus-visible is also dropped with a build warning, the native variant aliases it to focus.
Both platforms also match data-focus so a wrapper can show its control's focus ring. The native
text field receives focus, while its wrapper owns the border, radius and padding; the control's
composed behavior informs the wrapper.
ios:, android:, web:, native:
mount adds platform-ios, platform-android or platform-web to the root, enabling Tailwind's
platform variants. Variants for absent classes compile but never match, so shared classes such as
ios:pt-2 are harmless on the web.
dark: follows a class
Tailwind's default dark: uses @media (prefers-color-scheme: dark), which the engine tracks.
A class also supports palettes such as .dark { --background: ... } and in-app overrides of the OS
theme.
watchConditions(app.engine) keeps the root's dark class in sync with the system. For an app
switcher, call watchConditions(app.engine, { darkClass: false }) and control the class yourself;
otherwise a system-dark root overrides the switcher's light choice.
<view [class]="theme.className()" style="flex: 1">…</view>import { computed, inject, signal, Service } from '@angular/core';
import { ColorScheme } from '@ng-native/device';
type Preference = 'light' | 'dark' | 'system';
@Service()
export class Theme {
private readonly system = inject(ColorScheme);
private readonly preference = signal<Preference>('system');
readonly className = computed(() => {
const chosen = this.preference() === 'system' ? this.system.current() : this.preference();
return chosen === 'dark' ? 'dark' : '';
});
choose(preference: Preference): void {
this.preference.set(preference);
}
}The switcher calls choose('light'), choose('dark') or choose('system').
To preserve the preference across launches, save and restore it through the app's settings storage. The example does not persist it.
A class retheme covers the app's own CSS only. To switch everything, the native chrome too (headers,
switches, sheets, the keyboard), set the scheme on the window with ColorScheme.set:
inject(ColorScheme).set('dark'); // the whole app is dark, whatever the system says
inject(ColorScheme).set(null); // back to the system'sprefers-color-scheme, light-dark() and ColorScheme.current all follow it, so nothing
else has to know.
Tokens cross component boundaries
CSS custom properties cascade down the node tree into child components. Ordinary rules match only their component's nodes because sheets attach to component classes, providing emulated encapsulation without per-element markers.
A parent can retheme child internals through --primary, but cannot select them directly.
This includes Tailwind's theme. An element that sets --color-brand or --spacing recolours or
respaces every utility inside it that reads that token, as on the web, however the token is set:
in a component stylesheet, a style attribute, a [style.--color-brand] binding or from code.
<view [style.--color-brand]="accent()">
<view class="bg-brand p-4"></view>
</view>A bound declaration reads a token too, where its whole value is the var():
[style.background-color]="'var(--color-brand)'", with a fallback if it needs one. It follows the
token as a stylesheet declaration does. A var() inside a longer value, a calc() included, is
not read from a binding: set a custom property and read that in the stylesheet.
Every theme token is resolved on device, where it is read: colours, including the color-mix()
behind shadow colours and opacity modifiers, bare channels such as --primary: 0 100% 50% read
through hsl(var(--primary)), lengths and the spacing scale's calc()s and max()s, transition
durations and curves, animate-* animations, and the blur-* and drop-shadow-* filters. Where a
utility reads one in a form native cannot resolve on device, that declaration takes the theme
value at build time instead, and Metro prints a warning naming it.
A theme token defined as another, --color-primary: var(--color-blue-500), is worked out where the
theme defines it, as on the web: setting --color-blue-500 on an element does not change
bg-primary inside it. Set --color-primary itself to do that.
Resolving tokens on device costs little. Nodes that match the same rules under the same parent share one resolved style, so a list of identical rows resolves its tokens once.
Reading a token from TypeScript
Some places take a colour as a value rather than as a style: a prop on a native view, a colour
passed to a library, a drawing that paints each series its own colour. injectToken from
@ng-native/device reads a custom property's value for them, so the stylesheet stays the one
place the palette is defined.
import { Component, signal } from '@angular/core';
import { Switch } from '@ng-native/components';
import { injectToken } from '@ng-native/device';
@Component({
selector: 'app-setting',
imports: [Switch],
template: `<switch [(checked)]="on" [trackColor]="{ true: accent() }" />`,
})
export class Setting {
protected readonly on = signal(false);
protected readonly accent = injectToken('--accent');
}It returns a signal of the value as text, rgb(255, 0, 0) or 12px, with aliases, color-mix()
and fallbacks worked out as the stylesheet works them out. On iOS and Android it updates when the
colour scheme, another media condition or a stylesheet changes the token. A token holding
platform-color() reads as that text. The signal is undefined where nothing defines the token,
and where the token holds a value with no text form here: a shadow, a gradient or other background
layers, a filter, a transform, or bare colour channels such as 13, 110, 253.
Without options it reads the token at the root, the scope of :root rules, and has a value from
the start. To read it where a class lower in the tree sets it again, pass a node:
injectToken('--accent', { at: inject(ElementRef).nativeElement }). On iOS and Android that value
arrives once the node has been committed, so the signal is undefined until then.
On the web it reads the browser's computed value, at the mount point or at the node, which keeps a
color-mix() as written. It updates when a class or inline style changes under the mount point or
a node is put in or taken out. The dark class mount keeps in step with the system is one such
class, so a prefers-color-scheme query is followed while mount keeps it. Other media queries,
and stylesheets added later, are not followed on the web.
A shape's fill or stroke written as a token, <svg-path fill="var(--series-1)">, is resolved
against the tokens in scope at the shape, as the same declaration in a stylesheet is.
Unsupported values and font fallbacks
A unitless line-height value such as calc(1.75 / 1.125) inside a custom property leaves
text-lg empty until build-time substitution resolves it. Unsupported values produce line-numbered
diagnostics.
Font stacks are a silent exception. --font-sans is a list, but native fontFamily accepts one
name: font-family: Inter, Helvetica, sans-serif becomes Inter, discarding the rest without a
warning. Native has no fallback stack. If the first font is unavailable or a CSS generic such as
ui-monospace, text silently uses the system font. Name and verify a bundled font.