Web
@ng-native/web is a second host for the exact same components your app ships on a phone -
running in a browser instead of on Fabric. Reach for it to preview work without a
simulator, to build documentation and marketing pages that want the real thing rather than a
screenshot, or to share one component codebase between a native app and a web presence. This
documentation site is itself built on it: every interactive example on every page here is a real
component, mounted through mount into the page.
It is not a way to ship your native app to the browser as a second platform target. Some things - covered in What does not carry over - simply have no equivalent outside Fabric, and an app built around them will not run here unchanged.
Setting up a browser app
A browser app on @ng-native/web builds with Vite, and ngNativeWeb() from
@ng-native/web/vite sets it up: it compiles the app's components, links the @ng-native/*
packages as it links any Angular library, and keeps React Native and Expo out of the bundle.
In an empty directory:
npm init -y
npm pkg set type=module
npm install @angular/core rxjs @ng-native/components @ng-native/web
npm install --save-dev vite typescriptvite.config.ts:
import { ngNativeWeb } from '@ng-native/web/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [ngNativeWeb()],
});ngNativeWeb() is three things: @oxc-angular/vite's Angular compiler for the app's own
components, the same compiler for the decorated @ng-native/* source under node_modules, which
@oxc-angular/vite leaves alone, and the resolution a browser build needs. react-native and
expo, which the packages only require on a device, resolve to an empty module, in the bundle and
in Vite's dependency pre-bundling alike, including in a workspace that has React Native installed
for its native app. An Expo module that a package requires, such as expo-battery behind
@ng-native/expo/battery, resolves to a module that throws when it is loaded, so the service is
inert, as it is anywhere the module is missing, whether or not Expo is installed. An import of one
in the app's own code still fails the build. It sets no build.rolldownOptions.external, so it
combines with a tool that sets its own, such as Storybook. It takes
@oxc-angular/vite's options and passes them on, over zoneless: true and emitClassMetadata: false.
index.html, which Vite serves as the page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>src/main.ts and src/app.ts:
import { mount } from '@ng-native/web';
import { App } from './app.ts';
mount(document.getElementById('root')!, App);import { Component, signal } from '@angular/core';
import { Pressable, Text, View } from '@ng-native/components';
@Component({
selector: 'app-root',
imports: [Pressable, Text, View],
styles: `
.card {
margin: 24px;
padding: 16px;
gap: 12px;
border-radius: 12px;
background-color: rgb(238, 242, 255);
}
`,
template: `
<view class="card">
<text>Hello from Angular Native</text>
<pressable accessibilityRole="button" (press)="increment()">
<text>Pressed {{ count() }} times</text>
</pressable>
</view>
`,
})
export class App {
protected readonly count = signal(0);
protected increment(): void {
this.count.update((count) => count + 1);
}
}npx vite serves it with live reload, npx vite build writes the production build to dist, and
npx vite preview serves that build.
Vite does not type-check. For tsc and an editor, a tsconfig.json like this one checks the app
and the packages' source together:
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src"]
}allowImportingTsExtensions is required: the packages import their own files as ./x.ts.
Beside a native app
A browser build can also sit in a native app's own directory, such as one created from
@ng-native/template, and render the same components. It has a web entry of its own next to the
app's src/main.ts, which mounts through @ng-native/platform and stays the native entry.
npm install @ng-native/web
npm install --save-dev vite
npm pkg set scripts.web=viteThe config is the one above, named vite.config.mts, since the app's package.json is not
"type": "module". index.html is the one above with the web entry as its module script:
<script type="module" src="/src/main.web.ts"></script>src/main.web.ts:
import { mount } from '@ng-native/web';
import { App } from './app/app.ts';
mount(document.getElementById('root')!, App);npm run web then serves the app in a browser, and npm start still runs it on a device. The
app's own tsconfig.json checks the web entry too.
With Tailwind
@ng-native/tailwind's web preset gives a browser the same variants and utilities a phone gets,
so a class string written for native means the same thing here.
The setup is the same for an app of its own and for one
beside a native app, except where the native build has a file of the same
name. In an Nx workspace,
nx g @ng-native/nx:tailwind <project> writes it for either: it takes a project whose Vite config
runs ngNativeWeb() to build for a browser, and sets up the native build too when the project has
one. The Nx page covers it. By hand:
npm install --save-dev tailwindcss @tailwindcss/vite @ng-native/tailwindAdd tailwindcss() after ngNativeWeb() in vite.config.ts:
import tailwindcss from '@tailwindcss/vite';
import { ngNativeWeb } from '@ng-native/web/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [ngNativeWeb(), tailwindcss()],
});Then a stylesheet: src/styles.css in an app of its own, and src/styles.web.css beside a native
app, since the native build's src/styles.css imports native.css. A library's theme.css is
imported in both. index.html links it, and Vite builds it with the page:
<link rel="stylesheet" href="/src/styles.css" />Beside a native app, the href is /src/styles.web.css.
An import './styles.css'; in the web entry works too where TypeScript knows .css modules, as
vite/client's types declare them. Beside a native app it does not: TypeScript 6 refuses the
import of a file it has no types for, so the app's typecheck fails on it.
@import 'tailwindcss/theme.css';
@import 'tailwindcss/utilities.css';
@import '@ng-native/tailwind/web.css';A class such as rounded-lg bg-blue-600 px-4 py-2 on a <pressable> then styles it. mount puts
platform-web on the root, which is what the preset's web: variant matches, and keeps dark on
it while ColorScheme is dark, which is what dark: and a theme's .dark block match: the
system's prefers-color-scheme, or the scheme inject(ColorScheme).set() chose over it. An app
that puts dark on a view of its own passes darkClass: false.
Variants covers the rest.
With Tailwind 3
A Tailwind 3 app builds its web stylesheet with web-preset.cjs, the Tailwind 3 counterpart of
web.css: hover: and focus-visible: are the browser's own, the safe area and the hairline come
from the browser, and font-mono keeps Tailwind's stack. nx g @ng-native/nx:tailwind sets it up
as it does for Tailwind 4, for an app of its own and beside a native app alike. By hand:
npm install --save-dev tailwindcss@3 @ng-native/tailwindAn app of its own lists the web preset in its tailwind.config.js:
// tailwind.config.js
module.exports = {
presets: [require('@ng-native/tailwind/web-preset.cjs')],
content: ['./src/**/*.{ts,html}'],
};Beside a native app, the device build keeps preset.cjs in tailwind.config.js, so the web build
gets a config of its own that takes everything else from the app's:
// tailwind.web.config.js
module.exports = {
...require('./tailwind.config.js'),
presets: [require('@ng-native/tailwind/web-preset.cjs')],
};An app that lists a preset of its own beside preset.cjs lists it here too, in the same order.
Vite runs Tailwind 3 as a PostCSS plugin, from a PostCSS config beside vite.config.ts, which
needs nothing more than ngNativeWeb(). It names the web build's config, ./tailwind.config.js in
an app of its own:
// postcss.config.js
module.exports = {
plugins: { tailwindcss: { config: './tailwind.web.config.js' } },
};In a package with "type": "module", as the setup above makes, both configs end in .cjs, since
module.exports in a .js file is an error there.
src/styles.css, linked from index.html as above, holds the directives. Beside a native app it
is the one the device build reads too:
@tailwind base;
@tailwind components;
@tailwind utilities;With a prefix, the platform and dark variants still match the platform-web and dark classes
mount keeps on the root, so web:tw-p-2 and dark:tw-bg-black work with darkClass as they do
on a device.
The Angular CLI
The Angular CLI's builders do not build @ng-native/*. Their compiler checks every host binding
against the browser's DOM schema, and the components bind React Native's props, such as
accessibilityState and trackColorForTrue, on elements that are not HTML, so the build fails
inside the packages. An Angular web app that hosts islands builds with Vite
and ngNativeWeb() instead, which compiles an ordinary Angular app, bootstrapApplication from
@angular/platform-browser and all.
Mounting
import { provideRouter } from '@angular/router';
import { mount } from '@ng-native/web';
import { routes } from './app/app.routes.ts';
import { App } from './app/app.ts';
const root = document.getElementById('app-root')!;
mount(root, App, { providers: [provideRouter(routes)] });mount is the browser counterpart to @ng-native/platform's mount(), over the same
HostEngine seam - @ng-native/components makes exactly these calls of a renderer and nothing
else, so BrowserEngine satisfies the same interface Fabric's Engine does, with no cast. The
differences are the whole of what a browser page does not need: a real Element instead of a
numeric root tag, the real document, and no Fabric UI manager at all.
mount also wires Screen, ColorScheme and Direction from @ng-native/device to real browser
sources - window resize events, matchMedia('(prefers-color-scheme: dark)'), document.dir - so a
component that reads inject(Screen).compact() or reacts to dark mode behaves the same whether it
is running on a phone or in this browser tab. It also injects reset.css into document.head on
first use, in Tailwind's base layer and first in the head, so that layer comes before any layer
the page declares and every utility wins over it, which closes the gap between Yoga's flex defaults
and a browser's: an unstyled native view starts at display: flex; flex-direction: column; align-items: stretch; flex-shrink: 0, and an unstyled DOM element does not, so a shared class
string would otherwise disagree between the two hosts.
Inside an app you already have
An existing Angular web app can host Angular Native components in any of its templates with
<ng-native-island>, or from code with mount(element, component, { injector }). The island joins
the app: it uses the app's services and is checked by its change detection, while rendering
<view>, <text> and <pressable> through this package. The app builds with Vite and
ngNativeWeb(), as above. Islands covers both, what an island shares with
the app and what it keeps, and an Analog app, which
builds with ngNativeWebLink() and can keep hydration on.
The other way round
This package also has the web half of the opposite arrangement: an Angular component rendered by a
browser, in a web view inside a native screen. mountInWebView (from @ng-native/web/web-view)
mounts a component in the page a native web view loaded, and <dom-component> from
@ng-native/expo shows it. DOM components covers both halves.
From here, Islands covers placing components in an existing app and mounting more than one app into one page, Storybook covers a catalogue of your components, and What does not carry over covers the native-only pieces: the router, which a web build has to route around, and worklet animation and gesture handling, which are inert there.