Skip to content

Where I split web and React Native code in our design system

Of our 74 components, 38 have a React Native twin, 27 of them primitives and none a pattern. Each twin exists for a different element, event or window.

7 min read9 sections

Our cross-platform design system has 74 components, and 38 of them have a native twin: a .native.tsx file next to the web file, which the React Native bundler picks instead because of its filename suffix.

Read without context, that sounds like half the system has diverged. It hasn't. Each twin covers only the small part that differs between the platforms, and that rule is the most useful thing this system taught me about sharing code between web and React Native.

01

How web and mobile code drift apart

Web and mobile versions of a product start out identical and share nothing but a name two years later. I've seen it happen on real projects. On a meeting platform, five people building broadly for eight months ended up with three UI frameworks and four dropdown libraries in one codebase, and no shared component layer ever emerged.

The usual tools make divergence free, so discipline alone doesn't stop it. Platform.select lets you put a branch wherever it's convenient:

snippet.tsx
TypeScript · 13 lines
export function Card({ title, children }) {  return (    <View      style={Platform.select({        web: { padding: 24, cursor: 'default' },        default: { padding: 16 },      })}    >      {Platform.OS === 'web' ? <h3>{title}</h3> : <Text>{title}</Text>}      {children}    </View>  )}

Every line of that is fine on its own. The trouble is that a branch like this leaves no trace: there's no file you can count, no layer it is confined to, and nothing fails when the tenth one appears. Conditional imports have the same problem one level up. Any file can diverge, and nobody finds out until someone tries to change both platforms at once.

02

What I wanted: screens and patterns written once

The platform split has to exist somewhere, because a div isn't a View. What I didn't want was a split you could see from the call site.

That gives a precise target. Every screen should be written once, and so should every pattern, such as a form, a dialog or a menu. Only the things that touch a platform element may differ.

03

Where the twins are: 27 in primitives, none in patterns

The 38 twins by layer:

LayerTwins
primitives/27
overlay/8
form/2
navigation/1
patterns/0 of 26

Another number says more than the table: 33 of the 38 twins import no other component. They are leaf components, and each one translates a single platform element, such as div to View or input to TextInput, and does nothing else.

A pair looks like this:

primitives/Box.tsx
TypeScript · 5 lines
// primitives/Box.tsximport styled from 'styled-components'import { boxRecipe, type BoxProps } from './box.recipe' export const Box = styled.div<BoxProps>(boxRecipe)
primitives/Box.native.tsx
TypeScript · 6 lines
// primitives/Box.native.tsximport styled from 'styled-components/native'import { View } from 'react-native'import { boxRecipe, type BoxProps } from './box.recipe' export const Box = styled(View)<BoxProps>(boxRecipe)

Both files use the same props and the same recipe, and only the element differs. Everything above it imports Box and never learns which one it got.

So the number doesn't mean half the system is duplicated. Half the components are a single file shared by both platforms, and in the other half the difference comes down to one primitive.

04

Three reasons a twin may exist: element, event, window

A twin may exist for one of three reasons, and two of them have nothing to do with elements.

The first is a different element, which covers 33 of the 38 twins. These are mechanical and self-contained, and nothing above them can tell which version it got.

The second is a different event. Touch screens have no hover, so Tooltip.native opens on a long press, with no delay. On web, delayShow prevents flicker when a cursor crosses a row of icons. A long press already shows intent, so a delay would only make it feel broken.

overlay/Tooltip.native.tsx
TypeScript · 16 lines
// overlay/Tooltip.native.tsxexport function Tooltip({ content, children }: TooltipProps) {  const [open, setOpen] = useState(false)   return (    <>      <Pressable        onLongPress={() => setOpen(true)}        onPressOut={() => setOpen(false)}      >        {children}      </Pressable>      {open ? <TooltipBubble>{content}</TooltipBubble> : null}    </>  )}

The third is a different window. NativeSelect.native is needed because a system picker has to sit above the keyboard, and the only route to a separate native window is a React Native Modal. The shared Portal hands the tree to the host, which would leave the list underneath the keyboard. That failure shows up only on a device, and only inside a form.

The other three twins each import exactly one component and don't change the picture.

Those three reasons are the complete list, so a proposed twin that fits none of them is a pattern trying to diverge.

05

When a pattern needed a native difference, I extracted a primitive

It did come up. A pattern needed a different element in one spot on native, and the easy move was a Dialog.native.tsx with a copy of the whole dialog and one line changed.

I left the pattern alone. I extracted the primitive whose only difference was the element, gave that primitive a twin, and kept the pattern shared. That happened four times, and PositionedLayer, DialogActionsRow and MenuSeparator are three of the primitives it produced.

The principle is to put the twin on the small part that causes the difference and keep the component around it shared. The reason for a platform difference is almost always small: a single element, event or window. The component that shows the difference is usually large. Splitting the component copies everything that wasn't different along with the one thing that was, and from that day the two copies drift.

Because the same fix came up four times under pressure, I made it a rule.

06

A CI check rejects any native file under patterns

I didn't want the rule to depend on anyone remembering it, so it's enforced: a pattern must not have a .native variant. In our verification layer the check is called pattern-native, and it fails CI across 11 subjects.

A stripped-down version of the idea fits in a few lines:

snippet.ts
TypeScript · 10 lines
import { globSync } from 'glob' const offenders = globSync('packages/ui/src/patterns/**/*.native.{ts,tsx}') if (offenders.length > 0) {  console.error('A pattern must not have a native variant.')  console.error('Extract the primitive that differs, and twin that instead:')  for (const file of offenders) console.error(`  ${file}`)  process.exit(1)}

The real check is part of a larger conventions script, next to a handful of related checks. CI runs that whole chain instead of reimplementing it: the verification scripts and three real builds, one each with Next, Vite and Metro. The core of the check is as small as the version above. Its error message matters as much as the exit code, because it tells the next person what to do instead.

07

The login screen: one component call, byte-identical in three apps

A real screen shows the result better than the table does. LoginForm.tsx is one file of 135 lines with no native twin, built from Box, Button, Card, Input and Text. The platform split happens in those primitives.

The component call on the login screen is byte-identical across all three apps:

snippet.txt
Text · 3 lines
b768aa14a346  apps/web/app/LoginScreen.tsx        Next.js 16, RSCb768aa14a346  apps/spa/src/routes/LoginRoute.tsx  Vite SPAb768aa14a346  apps/mobile/App.tsx                 React Native 0.87

The identical part is the call. The wrappers around it differ: on web the form sits in a centering Box, on the phone in a SafeAreaView, and the API URL is different. Those are platform and product differences that sit outside the design system, and the identical call shows exactly where that boundary is.

Modal shows the same thing for a harder component. Its portal, focus management and measurement all stayed shared, because the parts that differ were pushed down into primitives.

08

Shared tokens and recipes let the split sit that low

Two things below the components make this work.

The first is that tokens resolve per platform. createTheme() turns a token into a string that resolves differently on each platform: on web it is a CSS variable, and on native it is a sentinel value that the native resolver then replaces. So ${theme.color.fg.default} behaves the same everywhere, and each color has one definition. There is nowhere to write a second one, so colors can't diverge.

The second is that recipes are shared. Style recipes sit above the elements and both platforms apply them. The only difference is the trigger: :hover on web and pressed on native. A twin that shares its recipe can't look different from its web counterpart; only the element underneath changes.

Shared recipes are also where the low split carries its biggest risk. When we ported a real page to a phone, the first build crashed on a scroll view. The shared stack recipe always emits alignment properties, and React Native requires those on the content container instead of the scroll view itself. Each rule was correct on its own, and together they broke the screen. Neither the cause nor the fix was in the page: we split the recipe into a version with alignment and one without. That story is in the post about porting a real page. A split at the bottom puts the risk in the same few places as the difference. I'd make that trade every time, because when something breaks there is one place to look instead of thirty.

09

What I'd do differently

I'd look at the distribution sooner. The table above is the most useful artifact in the system, and I produced it late. The count of twins on its own looks alarming. The count by layer, together with how many twins import another component, tells you whether the split is contained. Both are cheap to compute at any size, and they answer the question before it turns into an argument.

I'd also review earlier in general. The three root causes I found when I reviewed all the components for a different class of problem had been there long before. Running that review when the system had 30 components, instead of the 75 it had by then, would have been an order of magnitude cheaper. The same goes for twins: one that shouldn't exist is cheapest to catch while it's still one file.

And I'd make the three reasons the written rule for new twins. When someone proposes one, the first question is which of the three it is: a different element, event or window. How it should look on native comes after that. If it is none of them, the fix is to extract a primitive.

Where this came fromBuilding our cross-platform design system, which covers Next.js, React Native and a Vite SPA.Read the case study →Related serviceDesign system and component libraryView service →Next postSpeeding up a Redshift report when the query and schema are fixedRead →

Writing

If you’re working on something similar, send us the details.

The first call takes 30 minutes, and you’re under no obligation.

Tell us about your projectcontact@microtech.consulting