Variants

@ng-native/tailwind ships three stylesheets. shared.css holds the parts that do not depend on the platform; native.css and web.css each import it and add their own answers for the variants where a touchscreen and a browser genuinely disagree. Import native.css in an Expo app and web.css in a browser build.

Platform, disabled and dark variants

Tailwind cannot infer what platform it is running on, so the variants match a class on the root: platform-ios, platform-android or platform-web. mount puts it there itself - on a phone from nativePlatform(), and platform-web from @ng-native/web's mount - so ios:, android:, web: and native: (either native platform) work with nothing to set up. A variant whose class is never present simply never matches rather than erroring - ios:pt-2 in a shared class string is not a mistake on a web page, it is just not an iPhone.

Variants stack: android:dark:bg-zinc-950 applies on Android in dark mode, whether the two classes sit on one ancestor, as they do on the root, or on two.

disabled: matches [data-disabled] as well as :disabled, because a control's disabled input is consumed by the behavior composed onto it and never left as a prop :disabled could read; data-disabled is what that behavior publishes instead. A <text> publishes it too.

data-disabled, data-focus and data-hover count as set unless their value is "false". An attribute binding to a boolean, [attr.data-disabled]="off()", writes the string "false" when it is off, and some component libraries publish their state the same way, so a state written as off is read as off.

aria-busy:, aria-checked:, aria-disabled:, aria-expanded:, aria-hidden: and aria-selected: match as on the web. Each of those attributes is an input that sets the accessibility state, and the component puts it back on the node as the attribute it came in as, so aria-disabled="true" matches aria-disabled: and aria-disabled="false" does not. The attribute stays on the node for selectors and never reaches native.

dark: matches a view with the dark class and everything inside it, as Tailwind's own class-based dark mode does. watchConditions(app.engine) - which the template's src/main.ts already calls - keeps dark on the root in step with the system scheme, so dark: follows the device with nothing to set up. An app with its own theme switch passes { darkClass: false } and puts dark on its own root view instead:

watchConditions(app.engine, { darkClass: false });
<view [class]="theme()" style="flex: 1"></view>

The classes are the same with a Tailwind prefix: platform-ios, platform-android, platform-web and dark, never tw-dark. Tailwind 4's prefix() leaves a variant's selector as the preset wrote it. Tailwind 3 prefixes every class in one, so on native the build takes the prefix back off these four, and the web preset matches them as attributes ([class~="dark"]), which the prefix leaves alone. Either way ios:tw-pt-2 and dark:tw-bg-black match the classes mount and watchConditions set. An app that sets its own dark class sets dark.

Tailwind's own dark: is @media (prefers-color-scheme: dark), which the CSS engine already answers and keeps in sync with the system. A class is one step further: it lets a theme switcher disagree with the OS, which prefers-color-scheme on its own cannot do.

This is also why a palette defined as custom properties keeps working. The build step inlines a var() wherever it can, since there is no CSS parser on the device to resolve one later. A custom property declared more than once with different values - --primary under :root and again under .dark, which is exactly how a theme is written - is the exception: which declaration wins is a question only the runtime cascade can answer, so those survive the build untouched and are resolved per node. Define your tokens that way and the switch reaches them.

hover: and press:

On native, hover: means the pressed state: :hover is dropped with a build warning there and always will be - there is no pointer cascade to answer it - but a hover style and a press style ask the same design question. The native preset points the variant at :active (set by the engine while a touch is down) and also data-hover, for a real pointer - a trackpad, a mouse or a pencil, which React Native's pointer events report once an app turns them on. press: and hovered: name one half each, for a component that wants to be precise rather than portable.

On the web, hover: keeps the real :hover as well as :active and data-hover, so a class string written once behaves the same on a desktop browser, a touchscreen laptop, and a phone browser without the component knowing which it is on.

/* native.css */
@custom-variant hover (&:active, &[data-hover]:where(:not([data-hover="false"])));

/* web.css */
@custom-variant hover (&:hover, &:active, &[data-hover]:where(:not([data-hover="false"])));

focus-visible: and focus:

On native, focus-visible: is aliased straight to focus:, because the distinction it exists for on the web - keeping a ring off a control someone clicked - has no case on a phone: focus only ever arrives from a keyboard, a remote or an assistive technology, exactly the situations a ring is for. :focus-visible is dropped with a build warning on native for the same reason :hover is. Both platforms also match [data-focus], because focus lands on the control itself while the border and padding that form the ring usually sit on a wrapper around it, which can only know it is focused because the behavior composed onto the control told it.

*: and **:

*: styles the children of the element it is on, and **: everything beneath it, as on the web: *:rounded-full on a row rounds each item in it. Either takes a variant of its own after it, as in *:data-[slot=icon]:size-4.

peer-* and group-*

group-* variants match an ancestor with the group class, and peer-* variants match an earlier sibling with the peer class, exactly as on the web. The state a variant reads has to be one the engine can see on the peer: peer-focus: (focus, or data-focus), peer-active: and peer-hover: (a press), peer-disabled: (a disabled prop, or the data-disabled a control publishes), peer-aria-disabled: and the other aria states above, peer-data-[...]:, peer-aria-[...]: on an attribute the peer really carries, and the arbitrary peer-[.is-on]:. A class or a state arriving on the peer restyles the siblings after it.

<text-input class="peer border-b-hairline" placeholder="Email" />
<text class="text-gray-500 peer-focus:text-green-600">We never share it</text>

peer-checked: and group-checked: are dropped at build time with a warning that says why: a switch keeps whether it is checked in its component's checked input, where no selector can read it. Publish it as an attribute and use the data variant instead:

<switch class="peer" [(checked)]="on" [attr.data-checked]="on() ? '' : null" />
<text class="peer-data-checked:text-green-600">On</text>

font-mono

Tailwind's --font-mono is a browser font stack starting ui-monospace, and native's fontFamily takes a single name, not a fallback list - the compiler keeps only the first family from any stack, silently. ui-monospace exists on neither iOS nor Android, so font-mono would silently fall back to the system font everywhere without help. native.css gives font-mono Courier New (a real font on both, so it means something even with no platform class on the root) and then upgrades it per platform - Menlo on iOS, the monospace alias (Roboto Mono) on Android - once .platform-ios/.platform-android is present. web.css does not need this: it does not import it, and a browser resolves ui-monospace on its own.

An app's own monospace font replaces all of that, on every platform: @theme { --font-mono: 'JetBrains Mono'; } after the preset's import on Tailwind 4, or fontFamily.mono in the config or a preset on Tailwind 3. The Tailwind 3 preset upgrades font-mono only while it is still Tailwind's own ui-monospace stack.

The same silent first-family-only rule applies to any font stack you write yourself, Tailwind's or not: give native a single, real family, the way native.css does for font-mono.

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.