Cross-platform
Motif targets two runtimes from one source tree. Both branches export the same component names, accept the same props, and read from the same theme. The branch is invisible at the call site — the bundler picks one at install time, and your code never has to choose.
One package, two builds
usemotif is the only package an application installs. It ships two compiled entry points:
dist/index.js— the web build. Wires@usemotif/reactunderneath.dist/index.native.js— the native build. Wires@usemotif/react-nativeunderneath.
The package's exports field declares a react-native condition. Metro and Expo see that
condition and resolve the import to the native build; Vite, webpack, esbuild, and Rollup ignore it
and pick up the default. There is no flag to set, no platform check to write — the bundler picks
the right binary.
Both builds share the type declarations, so editor tooling does not split between platforms either.
The surface is identical
Open the import line on a web file and on a native file. They are the same:
The component names, the prop shapes, the theme model, the responsive rules — all of it lines up.
A <Box bg="$colors.surface.base" p="$space.4" _hover={...} /> on the web is the same component
call as <Box bg="$colors.surface.base" p="$space.4" _hover={...} /> on native. You write the
prop; the platform reads it.
That is the part that took the most work. A naive port would expose a different API on each side
and let consumers switch with import { Box } from '@usemotif/react' on web and
import { Box } from '@usemotif/react-native' on native. Motif chooses the harder path: the
platform decides at the bundler level, and the application code never participates.
Two runtimes, one model
Underneath the shared surface, the platforms work differently.
On web, motif compiles tokens into a <style> element of CSS custom properties, scoped per
[data-theme="<name>"]. Token references on style props become var(--…) strings; responsive
objects become media queries injected into a stylesheet and shared across the page; pseudo-state
bags become real :hover / :focus-visible / :active rules hashed into a class. Theme switches
are attribute changes on <html>. The browser is doing the work.
On native, motif keeps the tokens in a context node. There is no cascade and no stylesheet, so
token references resolve to literal values at render time, responsive values pick the right slot
against the active dimensions, and pseudo-state bags drive Pressable state callbacks. Theme
switches re-read the active branch of the tree.
The model — atoms, named compositions, an explicit merge order — is the same on either side. The output is what differs.
Where the platforms diverge
A few things only make sense on one runtime, and motif does not pretend otherwise.
- SSR.
SSRStyleCollectorandCollectorContextonly exist on the web build. They flush generated rules into the HTML stream so the document arrives styled. Native has no equivalent because there is no document to stream. - Pseudo-states.
_hoveris a no-op on touch surfaces;_focusand_activecarry through. Motif does not warn on_hoverin a native file because the same component might run on the web through React Native Web. - Form primitives.
Input,Label,Field,TextArea, and friends ship in the web build. React Native has its own input primitives with different ergonomics; motif's recommendation on native is to wrap them withstyled()directly. - Overlays.
Portal,FocusScope,Overlay,Show,Hide, andVisuallyHiddenship on the web. The native build relies on React Native's modal stack and accessibility APIs instead.
These seams are the price of a single import path. Motif's job is to keep the seams small; the exports condition above is the main mechanism for hiding them.
Write the same code
The shared surface is the point. A team building an app on both platforms does not write
Card.web.tsx and Card.native.tsx for the styling — both files are Card.tsx. The places
where the platforms genuinely diverge (navigation, gesture handling, network) are the places they
diverge. Styling is not one of them.