The CSS engine

Angular's styles and styleUrl are real CSS here, not silently dropped the way they are without platform-browser. The work splits across two very different halves, and understanding the split explains most of what is and is not possible.

Build time: compiling to a rule set

Inside the Metro transform, each component's stylesheet is parsed with lightningcss and compiled into a rule set: selectors reduced to compounds and combinators, values converted to what React Native's style API takes, specificity computed, and the whole list sorted once. The result is attached to the component class as ɵnativeStyles, which is also what scopes it - RendererFactory2.createRenderer(host, type) is handed the component's own definition, and a definition references its own class, so a renderer finds its sheet with no registry and no generated id. Anything native genuinely cannot express is dropped with a build warning naming the file, the line and the reason, not left to silently do nothing - see what CSS reaches a device and Metro for what that allowlist covers.

What styles can be

The sheet is compiled from the component's source file alone, so styles has to be something that file spells out. Each entry, or styles itself, can be:

  • a string literal
  • a template literal, including one whose substitutions are same-file constants
  • a constant declared in the same file that holds one of those
  • an array of any of the above
import { Component } from '@angular/core';

const BOX = '.box { width: 50px; }';
const TITLE = '.title { font-weight: 600; }';

@Component({
  selector: 'app-card',
  styleUrl: '../shared/cards.css', // shared between files: a stylesheet, not an import
  styles: [
    `
      ${BOX}${TITLE}
    `,
    '.x { height: 1px; }',
  ],
  template: '<view class="box"></view>',
})
export class Card {}

An imported constant, a concatenation with +, a spread, a constant that holds an array, or a function call cannot be read at build time, and the build fails naming the component and the entry. CSS shared between components belongs in a .css file, listed in styleUrl or styleUrls by every component that uses it; a component's own styles apply alongside it, after the shared sheets, as Angular orders them, so an inline rule wins over a shared one of the same specificity. A concatenation of same-file constants is written as a template literal with one substitution per constant.

Runtime: matching and merging

The resolver only ever matches and merges a pre-sorted list; nothing is parsed again. Matching goes right to left, like every CSS engine: a rule is filed under the most selective type, class or id in its rightmost compound (its key selector), so a node only ever tries the rules that could possibly match it, and the leftward compounds are checked only once the key selector already did.

A node is matched against up to three sheets merged together: the application's global stylesheet (if you passed one to mount() as globalStyles), the sheet of the component it is the host element of, and the sheet of the component that created it. A component's own rules count for one extra class of specificity over the global sheet's - the same bump Angular's own emulated encapsulation already grants a rule by rewriting it to carry an [_ngcontent-x] attribute, kept here so the precedence people already have in their fingers just works.

Inheritance is emulated, not real

React Native's views do not inherit anything - a color set on a view has no effect on a <text> nested inside it, only text-inside-text inherits - which would silently break every stylesheet written with CSS's actual inheritance in mind. So the cascade emulates it for exactly the properties where that expectation matters: color, direction, fontFamily, fontSize, fontStyle, fontWeight, fontVariant, letterSpacing, lineHeight, textAlign, textTransform, textDecorationLine and writingDirection. Nothing else cascades down past the element it is set on - padding on a wrapper never reaches its children, on this platform or in real CSS either.

A paragraph's text-align is resolved against the direction it inherits, from a stylesheet or an inline style above it: with none written it starts at the start edge, start and end follow the direction, and left and right stay on their side. See Direction.

Custom properties and tokens

A custom property (--primary: ...) cascades exactly like an inherited value, which is what makes it the one thing able to reach inside a component it does not otherwise know about: an ordinary rule cannot, because a component's compiled sheet only ever matches its own elements. var() resolves per node against whatever tokens are in scope, so a design token set from a parent - --primary retheming a child's internals - works, while writing a selector to reach inside a component's own markup does not.

A var() naming a custom property that nothing in scope defines, with no fallback, drops the declaration, as a browser does. In development the engine warns once per name, since a phone has no inspector to show the declaration struck out.

Media queries

@media conditions are evaluated against whatever mount()'s conditions option carried in: width, height, orientation, prefers-color-scheme and prefers-reduced-motion. Those are the only features a device can actually answer, so hover, pointer, the print media type and a not qualifier are all build errors rather than a query that silently never matches. Nothing re-evaluates a media query on its own - a rotation or a system theme change dirties no component and no binding - which is why @ng-native/device's watchConditions(engine) exists; see Bootstrapping.

Angular components as native iOS and Android views, built and shipped with Expo.

An alpha. MIT licensed. Sponsor its development.

An independent project, not affiliated with or endorsed by Google, the Angular team or Expo. Angular is a trademark of Google LLC.