React Native Web previews
The react-native-web adapter renders a React Native component in the
browser through React Native Web,
with the preview’s inputs as its props. Use it to review shared components and
screens at real device widths, next to their designs, without building the app.
It shows the web rendering, which can differ from iOS and Android. To review the native app itself, stream it from the iOS Simulator or an app window, such as an Android emulator.
The adapter works like the React adapter, with two differences:
imports of react-native load react-native-web, and .web.* files are
preferred. Inputs, callbacks, providers, styles, aliases, and dedupe work as
described in that guide. The definition keys, states, controls, and hooks are
described in TypeScript Workbench previews.
Requirements
react-native-web,react, andreact-dom18 or later, installed in your project. Thereact-nativepackage isn’t used by previews and doesn’t need to be installed for them. Workbench finds these packages from the definition’s folder and never installs or replaces them.workbench.yamlat the project root.name: Acmeis enough.- A trusted workspace, since previews run project code.
Write a first preview
acme-app/
├── workbench.yaml
├── package.json react, react-dom, react-native-web
└── src/
├── assets/
│ ├── avatar.png
│ └── acme-sans.woff2
├── components/
│ ├── ProfileCard.tsx
│ └── ProfileCard.workbench.ts
├── haptics.ts the native implementation
├── haptics.web.ts the web implementation, used by previews
└── preview/
├── canvas.css
└── environment.tsx
src/components/ProfileCard.tsx:
import { Image, Platform, Pressable, StyleSheet, Text, View } from 'react-native';
import { tap } from '../haptics';
import avatar from '../assets/avatar.png';
export interface ProfileCardProps {
name: string;
role: string;
online?: boolean;
onPress?: () => void;
}
export function ProfileCard({ name, role, online = false, onPress }: ProfileCardProps) {
return (
<Pressable
accessibilityRole="button"
onPress={() => { tap(); onPress?.(); }}
style={styles.card}
>
<Image source={{ uri: avatar }} style={styles.avatar} />
<View>
<Text style={styles.name}>{name}</Text>
<Text style={styles.role}>{role} · {Platform.OS}</Text>
</View>
{online && <View style={styles.dot} />}
</Pressable>
);
}
const styles = StyleSheet.create({
card: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 16, borderRadius: 12, backgroundColor: '#ffffff' },
avatar: { width: 48, height: 48, borderRadius: 24 },
name: { fontFamily: 'Acme Sans', fontSize: 17, fontWeight: '600', color: '#111827' },
role: { fontSize: 14, color: '#6b7280' },
dot: { width: 10, height: 10, borderRadius: 5, backgroundColor: '#16a34a' },
});
src/haptics.ts uses a native module, and src/haptics.web.ts replaces it in
the browser:
// src/haptics.ts
import Haptics from 'react-native-haptic-feedback';
export function tap() {
Haptics.trigger('impactLight');
}
// src/haptics.web.ts
export function tap() {
// Browsers have no haptic feedback.
}
src/components/ProfileCard.workbench.ts:
import { definePreview } from '@canonic2/workbench';
export default definePreview({
id: 'components/profile-card',
title: 'Components/Profile Card',
adapter: 'react-native-web',
source: { entry: './ProfileCard.tsx', export: 'ProfileCard' },
environment: '../preview/environment.tsx',
styles: ['../preview/canvas.css'],
sizes: ['mobile', 'fit'],
inputs: { name: 'Avery Example', role: 'Designer', online: false },
controls: {
name: { type: 'text' },
role: { type: 'text' },
online: { type: 'boolean' },
},
states: {
offline: {},
online: { inputs: { online: true } },
},
});
src/preview/environment.tsx fills the frame and reports presses under
Actions in Preview controls:
import { cloneElement, type ReactElement } from 'react';
import { View } from 'react-native';
import type { PreviewContext } from '@canonic2/workbench';
export function wrap(element: ReactElement<Record<string, unknown>>, context: PreviewContext) {
return (
<View style={{ flex: 1, padding: 16, backgroundColor: '#f3f4f6' }}>
{cloneElement(element, { onPress: () => context.action('press') })}
</View>
);
}
src/preview/canvas.css declares the font. A stylesheet is only needed when
the preview uses custom fonts or other project CSS:
@font-face {
font-family: 'Acme Sans';
src: url(../assets/acme-sans.woff2) format('woff2');
}
Run Workbench: Refresh Pages. Profile Card appears in a
Components collection with the states Offline and Online, and
Platform.OS is web.
Keep the definition free of component imports: Workbench runs it in Node to list previews. Import only types from component files.
How imports resolve
- An import of exactly
react-nativeloadsreact-native-web. Deep imports such asreact-native/Libraries/...aren’t rewritten and fail withCould not resolve. Give each one an alias, or move the code into a.web.*file. - For an import without an extension, Workbench tries
.web.tsx,.web.ts,.web.jsx, and.web.jsfirst, then.tsx,.ts,.jsx,.js,.mjs,.cjs,.json,.vue, and.html. Files ending in.ios.*,.android.*, or.native.*are never chosen. - Setting
resolveExtensionsinworkbench.config.tsreplaces the whole list, including the.web.*entries. Include them if you set it. - An alias for
react-nativeinworkbench.config.tsreplaces the built-in one, if you need your own shim.
Data and providers
Providers, inputs, and data work as they do for React.
A component that loads its data over the network keeps doing so: answer its
fetch and XMLHttpRequest calls per state with
requests, or wrap it in a provider
holding fixture data from an environment.
Preview a screen inside its app layout
A preview mounts the component named in source. Pointing it at a screen
doesn’t mount the app’s parent layouts. In an Expo Router app, a route such as
app/(tabs)/index.tsx therefore renders without the tabs from
app/(tabs)/_layout.tsx, the root navigation theme, or the app’s other
providers. A full-height preview supplies space; the project supplies the
screen’s surroundings.
Use an environment to wrap screen previews in the same providers, background, header, and tab bar as the app. Keep isolated component previews in a separate environment so a button or card doesn’t get the app’s navigation around it.
Prefer sharing the app’s shell components and navigation options. If the production layout depends on file-based routing, extract the reusable shell or create a preview wrapper with an in-memory navigator. Mount the previewed screen as the active route’s content. This retains the navigator’s scene sizing and tab-bar space instead of drawing a tab bar over the screen. Keep any preview-specific composition in step with the production layout.
For example, suppose your project has AppProviders for its theme and session,
and a ScreenPreviewShell that renders children inside the app’s tab layout.
These are project components, not Workbench APIs. The shell accepts activeTab
and calls onTabPress with 'home' or 'explore':
// src/preview/screen-environment.tsx
import type { ReactElement } from 'react';
import type { PreviewContext } from '@canonic2/workbench';
import { AppProviders } from '../app/AppProviders';
import { ScreenPreviewShell } from './ScreenPreviewShell';
export function wrap(element: ReactElement, context: PreviewContext) {
const activeTab = context.globals.activeTab === 'explore' ? 'explore' : 'home';
return (
<AppProviders>
<ScreenPreviewShell
activeTab={activeTab}
onTabPress={tab => context.navigate('mobile-app/' + tab)}
>
{element}
</ScreenPreviewShell>
</AppProviders>
);
}
Wire that environment into each tab screen’s definition and map its tab addresses to previews:
// src/preview/home.workbench.ts
import { definePreview } from '@canonic2/workbench';
export default definePreview({
id: 'mobile-app/home',
title: 'Mobile App/Home',
adapter: 'react-native-web',
source: { entry: '../screens/HomeScreen.tsx' },
environment: './screen-environment.tsx',
sizes: ['mobile', 'resizable'],
globals: { activeTab: 'home' },
links: { '/home': 'mobile-app/home', '/explore': 'mobile-app/explore' },
});
Create the Explore definition with its own source, id: 'mobile-app/explore',
and globals: { activeTab: 'explore' }. Reuse the environment and link map.
The callback navigates by preview ID; links handles anchors using those
addresses. Navigation runs while Actions is on. See
Links and navigation. Alternatively, keep
the other tabs decorative and report presses with context.action('tab', tab)
so reviewing a state doesn’t leave that screen.
Use a separate shell for modal or stack screens: their header, background, and available content area may differ from a tab screen. Providers shared by all React Native Web previews can live in an adapter-scoped project environment; each preview’s environment then supplies its own shell. The project environment wraps the preview environment on the outside. See Environments.
Keep .workbench.ts files and preview wrappers outside Expo Router’s route
directory. Files in that directory are interpreted as routes. A router mock
that implements links can support isolated screens, but doesn’t supply layouts
or navigation context; screens that use navigation hooks need a compatible
navigator or a mock that implements those hooks.
Safe areas and device dimensions
Choose the same logical width and height as the device you are comparing with,
using the Resizable artboard when needed. A Simulator image’s
pixel dimensions can be larger than the device’s logical dimensions. The
mobile size is a preset, not a simulation of every phone model.
If the app uses react-native-safe-area-context, supply its contexts in the
preview environment. A desktop browser usually reports zero phone insets.
SafeAreaProvider initialMetrics seeds the first render; its web implementation
then measures browser insets, so initial values alone don’t hold simulated
phone insets throughout the preview.
For deterministic browser previews, a project wrapper can provide the frame
and inset contexts directly. This example requires
react-native-safe-area-context in the project. The inset values are illustrative;
choose values for the device and orientation being reviewed:
// src/preview/PhoneMetrics.tsx
import type { PropsWithChildren } from 'react';
import { useWindowDimensions } from 'react-native';
import { SafeAreaFrameContext, SafeAreaInsetsContext } from 'react-native-safe-area-context';
const insets = { top: 59, right: 0, bottom: 34, left: 0 };
export function PhoneMetrics({ children }: PropsWithChildren) {
const { width, height } = useWindowDimensions();
return (
<SafeAreaFrameContext.Provider value={{ x: 0, y: 0, width, height }}>
<SafeAreaInsetsContext.Provider value={insets}>
{children}
</SafeAreaInsetsContext.Provider>
</SafeAreaFrameContext.Provider>
);
}
Place PhoneMetrics outside the shell that consumes it. Avoid nesting another
SafeAreaProvider that replaces those simulated values. Supplying insets
doesn’t add padding: the app’s SafeAreaView, navigator, or layout must consume
them. Preserve the app’s ownership of that spacing so the preview doesn’t
apply the same inset twice. This wrapper doesn’t draw a status bar, notch, or
home indicator.
A standalone preview’s window is its iframe, so useWindowDimensions measures
that frame as it resizes. A docs example lives in a panel inside a larger
document; a phone surface embedded there needs its own measurements rather
than assuming the document’s window is the panel size.
Compare browser and native rendering
Even with the app shell, React Native Web renders the web platform:
Platform.OSis'web', soPlatform.selectcan choose different copy or behavior. An iOS branch isn’t selected by choosing a phone-sized frame.- Fonts, text wrapping, scrolling, and native controls can differ. Load the project’s web fonts and compare at the same logical size and theme.
- Native menus, keyboards, system bars, and gestures need the native runtime.
Use the iOS Simulator lens or an Android emulator window to review those differences alongside the browser preview. Check the native screen itself for missing safe-area handling before adding preview-only padding to make it look correct.
Replace native-only modules
A module that needs native code can’t run in the browser. Replace it in one of two ways:
-
A
.web.*file next to your own module, ashaptics.web.tsabove. This also serves a web build of the app, if you have one. -
An alias in
workbench.config.tsthat points the package at a mock. The mock affects previews only:import { defineConfig } from '@canonic2/workbench'; export default defineConfig({ aliases: { 'react-native-haptic-feedback': './src/preview/haptics-mock.ts' }, });// src/preview/haptics-mock.ts export default { trigger(_type: string) {} };
Aliases match whole import specifiers, and relative alias paths start from the project root. See Imports, JSX, and environment variables.
Layout and full-height screens
The component renders inside an element with the ID workbench-preview, which
is at least as tall as the frame and uses a column flex layout. A root view with
flex: 1 fills the frame without a stylesheet, including when the frame is
resized. Project CSS can override the host’s default layout.
Choose the artboard sizes with sizes; mobile suits most app screens. See
Sizes.
Images and fonts
- Importing an image, with
import avatar from './avatar.png'orrequire('./avatar.png'), gives its URL.Imageaccepts it assource={{ uri: avatar }}orsource={avatar}. - Workbench loads the file you name. It doesn’t pick
@2xor@3xvariants. - Declare custom fonts with
@font-facein a stylesheet listed instyles, then use the family name infontFamily. Font files can be.woff,.woff2, or.ttf.
The other file types, and what needs a compiler plugin, are listed in Styles, fonts, and images.
Packages that need extra settings
Some React Native packages assume Metro, React Native’s bundler. Settings in
workbench.config.ts cover the common cases:
__DEV__orglobalfail with__DEV__ is not definedorglobal is not defined. Define them.- JSX in
.jsfiles fails withThe JSX syntax extension is not currently enabled. Add a compiler plugin that loads that package’s.jsfiles as JSX. - Flow type annotations fail with a syntax error, such as
Expected ")" but found ":". They need a compiler plugin that strips Flow types, or an alias to a compiled build of the package.
import fs from 'node:fs';
import { defineConfig } from '@canonic2/workbench';
// acme-native-lib publishes JSX in .js files.
const jsxInJs = {
name: 'jsx-in-js',
setup(build: any) {
build.onLoad({ filter: /node_modules[\\/]acme-native-lib[\\/].*\.js$/ }, async (args: { path: string }) => ({
contents: await fs.promises.readFile(args.path, 'utf8'),
loader: 'jsx',
}));
},
};
export default defineConfig({
define: { __DEV__: 'true', global: 'globalThis' },
plugins: [jsxInJs],
});
Compiler plugins use esbuild’s plugin interface. See the configuration file.
Document components with a docs page
A page’s docs can show React Native components as live
examples. Give the page a docs lens with adapter: react-native-web. Each
example exports a component: the default export of each file in a folder, or
each named export of one file. Imports resolve as they do for previews, with
react-native loading react-native-web and .web.* files preferred. Example
file names must be kebab-case, so online.web.tsx isn’t an example; put the
.web.* file next to the module the example imports instead.
When the component also has a React DOM implementation for the web, give the page a lens for each. Both show the same Markdown, and the lens switcher in the top bar changes what renders the examples:
implementations:
web:
kind: docs
label: Web
adapter: react
native:
kind: docs
label: React Native Web
adapter: react-native-web
environment: src/preview/environment.tsx
styles:
- src/preview/canvas.css
collections:
- name: Components
items:
- label: Profile Card
src: docs/profile-card.md
lens: native
implementations:
web: web/src/profile-card/examples/
native: src/components/profile-card-examples/
src/components/profile-card-examples/online.tsx:
import { ProfileCard } from '../ProfileCard';
export default function Online() {
return <ProfileCard name="Avery Example" role="Designer" online />;
}
The web lens’s folder has its own online.tsx that renders the web
component. An example one lens has and the other doesn’t shows
Not available in and the lens’s label.
Examples get no inputs or controls, so set the props in each example. The
lens’s environment wraps each example as it wraps a preview, and its
styles load with the examples, here for the @font-face rule. The
adapter supplies a column flex host in each panel, so a root view with
flex: 1 fills the panel’s available content area. Panels keep their own
padding and content-based height; they don’t become full-screen frames.
Errors and fixes
| Error | Cause and fix |
|---|---|
Could not resolve "react-native-web", "react", or "react-dom/client" |
A required package isn’t installed where the definition can find it. Install it in the project. |
Could not resolve "<native package>" |
A native-only module. Replace it with a .web.* file or an alias. |
Could not resolve "react-native/…" |
A deep import into react-native. Alias it or move it into a .web.* file. |
__DEV__ is not defined, global is not defined |
Add them to define. See Packages that need extra settings. |
The JSX syntax extension is not currently enabled |
A package publishes JSX in .js files. Add a plugin. |
| A full-screen view is only as tall as its content | Check that the root view and any environment wrappers grow with flex: 1. See Layout and full-height screens. |
| Tabs, a header, or the app background are missing | The preview mounts the screen without its parent layout. Add a screen environment. |
| Phone safe-area spacing is missing or changes after mount | Supply and consume deterministic metrics in the preview wrapper. See Safe areas and device dimensions. |
The errors shared with the React adapter, such as duplicate React copies, missing exports, and environment variables, are listed in React previews.