Web
The package focuses on native iOS and Android widgets. On web you can connect
your application's own controls with PlatformComponentsProvider, using the
same imports and public props throughout shared screens. You can register one
component or a complete set. Components without a registration retain the
existing fallbacks described below.
The web build must resolve this package's browser entry and alias
react-native to react-native-web.
Expo web handles this setup. For a custom bundler, follow
React Native Web's setup instructions
and enable the browser export condition. Do not import the package's native
source files or generated native components directly in a browser bundle.
Jump to connect your web components for the setup.
Built-in fallbacks
The web entry point takes the same props as iOS and Android and uses the same types, so shared screens need no Platform.OS checks. Where the browser has a control of its own, the component renders that control, which is the web's own platform widget:
| Component | Web rendering |
|---|---|
TextField | react-native-web's TextInput (an <input> or <textarea>), with the label, prefix, suffix, clear button, password toggle, supporting and error text, and counter around it |
DatePicker | <input type="date">, "time", "datetime-local" or "month". embedded renders it in place; modal opens it in a <dialog> with Cancel and Done |
DateRangePicker | A <dialog> with start and end date inputs; Done confirms a complete range within the configured bounds |
SelectionMenu | embedded renders a <select>; modal opens the options in a <dialog> |
Button | A <button> in the Material 3 variants and sizes. A toggle (selected) sets aria-pressed. There is no web menu: a button with a menu calls onPress and logs a warning once |
FloatingActionButton | A <button> styled after the Material 3 FAB, with the label while extended. scrollViewNativeID is ignored |
ButtonGroup | A row of <button>s, with toggle semantics (aria-pressed, or radios for single selection) when selection is set |
SplitButton | The main button alone: there is no web menu (a warning is logged once) |
SegmentedControl | A radio group of <button>s in a track, like the iOS control |
TabBar | A tablist of <button> tabs, icon over label, the selected icon in a pill |
NavigationRail | A vertical tablist of <button> destinations, icon over label (beside it when expanded), with the header above |
FloatingToolbar | A pill-shaped surface that lays out its children |
LiquidGlass | A plain view. isLiquidGlassSupported is false, as on Android |
ContextMenu | Its children, without a menu (see below) |
Behavior
Events and controlled props work the way they do on native:
DatePicker:embeddedreports every change withconfirmed: true.modalworks like the iOS popover: changes reportconfirmed: false, Done reportsconfirmed: true, and Cancel, Escape or a click outside the dialog callonClosed. Close the dialog by settingvisibletofalse. Changingdatealso updates an already open dialog; recreating the same date value preserves the user's pending edits.minDateandmaxDateconstrain both typed dates and confirmed selections. Bounds compare the fields shown by the input: days for date inputs, months for month inputs, and times of day for time inputs.ios.minuteIntervalsets the step for time and date-time inputs only. Dates are in the browser's local time zone;timeZoneNameis ignored.DateRangePicker: Done stays disabled until both dates are set, ordered, and withinminDate/maxDate. Confirmation reports the start of each day in the browser’s local time zone, then callsonClosed.timeZoneNameand Android dialog options are ignored.SelectionMenu:modalstays headless. Whilevisible, picking an option callsonSelect, and Escape or a click outside callsonRequestClose.accessibilityLabelnames the actual select or dialog; it defaults to the placeholder, or “Select an option”.SegmentedControl: as on iOS, clicking the selected segment keeps it selected.labelVisibility: 'auto'shows both the icon and the label.TabBar: clicking the selected tab callsonReselect.labelVisibility: 'auto'labels every tab.
Keyboard support
The browser handles keyboard input for embedded date fields and selection menus. Custom button controls support Tab to focus and Enter or Space to activate. TabBar, NavigationRail, SegmentedControl, single-selection ButtonGroup, and modal SelectionMenu currently do not implement arrow-key navigation within their groups. The modal selection menu also lacks type-ahead search. Use your own web component when your application requires those keyboard interactions.
Theme and icons
useNativeTheme and setNativeTheme set the brand color the web components use for filled buttons, focus and selection, and the browser's own controls through accent-color. Without a theme they use the Material 3 baseline purple. PlatformColor and DynamicColorIOS values don't exist on the web and fall back to the defaults.
Only image icons ({ type: 'image', source }) render on the web. SF Symbols and Android drawables have no web equivalent and are left out, so pass an image in a per-platform pair when a control needs an icon on every platform. See Icons.
ContextMenu
Browsers have no native menu to attach to a view, so on the web ContextMenu renders its children without a menu and logs a warning once in development.
Connect your web components
1. Write a typed adapter
An adapter is a React component accepting the corresponding package props. Translate those props into your web library's API, or use native HTML controls. This small example implements labeled buttons, disabled state and controlled toggles with the browser's own button:
// AppWebButton.web.tsx
import { View } from 'react-native';
import type { ButtonProps } from 'react-native-platform-components';
export function AppWebButton({
label,
disabled,
selected,
onSelectedChange,
onPress,
accessibilityLabel,
style,
testID,
}: ButtonProps) {
return (
<View style={style}>
<button
type="button"
data-testid={testID}
aria-label={accessibilityLabel ?? label}
aria-pressed={selected}
disabled={disabled}
onClick={() => {
if (selected !== undefined) onSelectedChange?.(!selected);
onPress?.();
}}
>
{label}
</button>
</View>
);
}
This example deliberately has a small support surface: it does not implement
icons, menus, haptics or visual variant props. Extend the mapping for the features
your screens use and document any unsupported props. The provider routes props;
it does not implement missing behavior inside a supplied component. React Native
styles belong on a View or need translation to CSS, rather than being spread
directly onto a DOM element.
2. Register once at the web root
Keep the registry and component definitions outside render functions so controls retain their state and focus across parent renders:
// PlatformComponentsRoot.web.tsx
import type { ReactNode } from 'react';
import {
PlatformComponentsProvider,
type WebComponents,
} from 'react-native-platform-components';
import { AppWebButton } from './AppWebButton.web';
const webComponents = {
Button: AppWebButton,
// TextField: AppWebTextField,
// SelectionMenu: AppWebSelect,
// ContextMenu: AppWebContextMenu,
} satisfies Partial<WebComponents>;
export function PlatformComponentsRoot({ children }: { children: ReactNode }) {
return (
<PlatformComponentsProvider web={webComponents}>
{children}
</PlatformComponentsProvider>
);
}
For a shared Expo/Metro app, add the native counterpart:
// PlatformComponentsRoot.tsx
export { PlatformComponentsProvider as PlatformComponentsRoot } from 'react-native-platform-components';
Metro's platform resolution chooses .web.tsx for web and the plain file for
native. A custom bundler must configure the equivalent resolution, or mount the
provider directly in its separate web entry point. Keep imports of your web-only
design system in that web module so they never enter native bundles. The native
provider simply renders its children and ignores web registrations.
3. Keep shared screens unchanged
Wrap the shared application once:
import { PlatformComponentsRoot } from './PlatformComponentsRoot';
import { SharedScreens } from './SharedScreens';
export default function App() {
return (
<PlatformComponentsRoot>
<SharedScreens />
</PlatformComponentsRoot>
);
}
Screens continue importing from this package:
import { Button } from 'react-native-platform-components';
export function SaveAction({ save }: { save: () => void }) {
return <Button label="Save" onPress={save} />;
}
That Button uses AppWebButton inside the web provider, and the native widget
on iOS and Android. There is no per-screen platform branch or custom import.
Adapter contract
WebComponents accepts these keys: Button, ButtonGroup, SplitButton,
ContextMenu, DatePicker, DateRangePicker, FloatingActionButton,
FloatingToolbar, LiquidGlass, LiquidGlassContainer, NavigationRail,
SegmentedControl, SelectionMenu, TabBar and TextField.
Each entry is checked against that component's public props. WebComponentProps
also exposes the mapping, for example WebComponentProps['TextField']. Import
these as types so they add no native runtime imports to web bundles.
- Controlled state: render from the supplied value. Report a proposed change through the existing callback; do not permanently change the selection when the parent leaves its prop unchanged.
- Callbacks: translate your web library's events into the documented package
arguments. For example,
SelectionMenu.onSelectreceives(data, label, index), whereindexrefers to the original options array, including disabled entries. Date picker confirmation and dismissal remain separate events. - Disabled choices and accessibility: preserve disabled state, accessible
names, keyboard operation and focus behavior in your adapter. Translate test
identifiers to
data-testidif your browser tests use them. - Platform props: document how unsupported
ios/androidoptions, icons, styles and native effects map or are ignored. Do not spread these onto DOM nodes. - Composition: register
SplitButtonto supply its complete web menu. Its built-in main-button-only fallback also respects a registeredButton. Other compound controls are replaced as a whole, not by automatically replacing every HTML button inside them. - Avoid recursion: an adapter must render your web implementation, not the
same package component it replaces. Rendering the package's
Buttonfrom its own registeredButtonadapter would recursively invoke the adapter.
TextField refs
A TextField adapter must forward the public TextFieldRef, not a raw DOM node.
Use React's forwardRef<TextFieldRef, TextFieldProps> and useImperativeHandle
(or React 19's ref prop) to expose:
| Method | Required behavior |
|---|---|
focus() | Focus the underlying input. |
blur() | Blur it. |
clear() | Clear it and call onChangeText(''), maintaining controlled-value semantics. |
isFocused() | Report whether the underlying input currently has focus. |
setSelection(start, end?) | Set a clamped UTF-16 selection range; default end to start. |
Read DOM state inside methods or effects, not at module initialization or during server rendering. See the TextField API for event and selection details. The provider forwards the ref through the exported component.
Scope, fallbacks and server rendering
Registrations belong to a React subtree. Nested providers replace the entries
they supply and inherit omitted or undefined entries. Separate application
roots and separate server render requests do not share a mutable registry.
Removing a registration restores the nearest inherited component or the built-in
fallback. Changing the implementation component itself remounts that control.
No registration is required to use the existing fallbacks. Their limitations in
the table above still apply wherever you do not provide a replacement. Capability
constants such as isLiquidGlassSupported describe the built-in platform support;
they are not changed by registrations.
For server-rendered web apps, render the same registry and initial props on the
server and client. In Next.js App Router, put the provider and its registry inside
a client component ('use client' at the top of your web root module); do not pass
component functions from a Server Component across that boundary. The adapter
must itself support server rendering and hydration. There is no process-global
register() call.