A standalone web runtime for React Native apps. You write react-native. It runs in the browser as a real web app.
Status: early alpha. The package is not on npm yet. The API below is what the repository builds today. See Supported APIs for the exact list.
react-native-web tracks React Native and stops there. This project starts from the browser and asks a different question: what does a React Native app need to be a good web app?
- Parity first. Every
react-nativeexport we ship behaves as it does on iOS and Android. - Web second, behind the same API. Semantic HTML, SEO, server rendering, DOM events, logical CSS properties, and every browser capability that native lacks.
- Modern only. TypeScript, ESM, React 19. No Flow, no CommonJS, no
findDOMNode, noforwardRef.
| Layer | Import | Content |
|---|---|---|
| Parity | @rootnative/web |
React Native components and APIs |
| Web | @rootnative/web/dom |
Browser features that native does not have, for example <Head> |
| Server | @rootnative/web/server |
Server rendering helpers |
Your app imports react-native. A bundler alias maps that name to @rootnative/web.
role picks the HTML element. No extra props.
<View role="main">
<View role="navigation" />
<Text role="heading" aria-level={1}>
Title
</Text>
<View role="list">
<View role="listitem">
<Text>One</Text>
</View>
</View>
<Text href="/about" role="link">
About
</Text>
</View><main>
<nav></nav>
<h1>Title</h1>
<ul>
<li><span>One</span></li>
</ul>
<a href="/about">About</a>
</main>Styles compile to atomic CSS classes at runtime. One class per declaration, shared across the app.
- Numbers get
px. Colors normalize with the same parser as React Native. flex: 1means1 1 0%, as on native.flex: 0keeps the intrinsic size.marginStart,paddingEnd,start,end, andborderTopStartRadiusmap to logical CSS properties, so RTL works through the browser.boxShadow,filter,mixBlendMode,display: 'contents',position: 'static',gap, andinsetpass through.- A longhand always wins over a shorthand, whatever the key order, as on native.
import { renderToString } from 'react-dom/server'
import { createStyleRegistry, StyleRegistryProvider, styleTagsHTML } from '@rootnative/web/server'
const registry = createStyleRegistry()
const body = renderToString(
<StyleRegistryProvider registry={registry}>
<App />
</StyleRegistryProvider>,
)
const html = `<!doctype html><html><head>${styleTagsHTML(registry)}</head><body>${body}</body></html>`One registry per request. The client adopts the rendered style elements on hydration and inserts nothing twice.
import { Head } from '@rootnative/web/dom'
;<Head
title="Page title"
description="What the page is about"
canonical="https://example.com/page"
openGraph={{ title: 'Page title', image: 'https://example.com/og.png' }}
jsonLd={{ '@context': 'https://schema.org', '@type': 'WebPage', name: 'Page title' }}
/>React 19 moves <title>, <meta>, and <link> into <head> on the server and on the client.
Until the package is on npm, link the workspace package or use a path alias.
Vite
import { defineConfig } from 'vite'
export default defineConfig({
resolve: {
alias: { 'react-native': '@rootnative/web' },
},
})TypeScript
{
"compilerOptions": {
"paths": { "react-native": ["./node_modules/@rootnative/web"] }
}
}A Vite plugin that sets the alias, resolves .web.tsx files, and inlines Platform.OS is planned. The library works without it.
Components: View, Text
APIs: StyleSheet, Platform, Dimensions, useWindowDimensions, usePressability
Web layer: Head
Server: createStyleRegistry, StyleRegistryProvider, renderStyleTags, styleTagsHTML
Notes on the web behavior of shipped props:
| Prop | Behavior |
|---|---|
role |
Picks the element. Roles with no implicit element, and the table roles, keep a role attribute. |
aria-level |
Web only. The heading level, 1 to 6. Default 2. |
href, target, rel, download |
Web only. Render an anchor. |
on* DOM handlers |
Pass through to the element. onClick, onKeyDown, onPointerDown, and the rest. |
onLayout |
A ResizeObserver. Fires after mount and after each size change. |
pointerEvents |
none, auto, box-none, and box-only. |
numberOfLines |
1 truncates with an ellipsis. More lines clamp. |
ellipsizeMode |
tail and clip. head and middle fall back to tail for now. |
selectable |
On by default on the web. false sets user-select: none. |
Text onPress |
A click. Press in, press out, long press, and keyboard activation come with Pressable. |
Platform.isDisableAnimations |
Follows prefers-reduced-motion. |
Dimensions.get('window').fontScale |
The root font size divided by 16, the browser's font-size preference. |
usePressability |
Returns pointer, keyboard, and focus handlers, not responder handlers. Spread them on a View. |
usePressability onPress |
Gets the pointerup event. A keyboard or assistive-technology activation gets the click event. preventDefault() cancels the click, so a link does not navigate. |
| Press events | nativeEvent is the DOM event, with locationX, locationY, pageX, pageY, timestamp, touches, and changedTouches added. |
- The
react-native-webinternals and file layout. - Expo and Metro. A Metro plugin is out of scope for 1.0.
- React 18, CommonJS, Flow.
- Props that React Native does not have, except the small web-only set above.
Chrome 95, Edge 95, Firefox 93, Safari 15.1, iOS Safari 15.1.
pnpm install
pnpm --filter @rootnative/playground dev # one route per component
pnpm test # Vitest in a real Chromium
pnpm lint
pnpm typecheck
pnpm buildThe monorepo uses pnpm, TypeScript 7, tsdown, Vitest 5 in browser mode with Playwright, oxlint, and Prettier.
packages/
web/ @rootnative/web
playground/ Vite app with one route per component
Each component lives in one folder with its types and its tests.