The promise of "write once, run everywhere" has been a lie for thirty years. The desire behind it is real — nobody wants three codebases drifting — but the actual delivery has been: write once, debug everywhere, ship three slightly different apps.
I've watched teams ship a beautiful web product, then spend six months rebuilding it for iOS, then three more months for Android, then quietly accept that notifications behave differently per platform forever. The fragmentation isn't a bug. It's the default outcome of trying to make fundamentally different rendering systems agree on every pixel. The hardest part isn't the rendering itself — it's the long tail of edge cases where the platforms diverge. A button that wraps to two lines on Android but one on iOS. A modal that scrolls the wrong element on web. A date picker that picks a different timezone offset by platform. Multiply by 200 components and the team is now maintaining three divergent visual languages inside one product.
The three failure modes of cross-platform
Every cross-platform tool fails in roughly the same three ways. Naming them is the first step to escaping them.
Visual drift. The component "looks close" but not identical. A border that renders 1px on web and 0.5pt on iOS. A shadow implemented with box-shadow on web and a native elevation on Android — and they're never quite the same depth. The team accepts "good enough" and ships three slightly different visual languages under one brand.
Behavior drift. The component has the same name but different behavior. A <Select> that opens on mousedown on web and on tap on mobile — fine — but a <Dialog> that traps focus correctly on web and skips focus restoration on Android. The team writes platform-conditional code inside every component. "Write once" is now write-three-times-disguised-as-one.
Capability drift. Platform X has a feature the others don't. iOS gets haptics. Android gets the back button. Web gets URL state. The "unified" component decides: omit the feature, fake it on the unsupported platforms, or maintain three implementations. All three cost more than writing the component twice with shared types.
The contract that actually works
The lie of "write once" comes from conflating one source file with one outcome. The real goal is the latter. A single component definition that produces a visually and behaviorally identical result on web, iOS, and Android requires four things held in tension:
-
Same component name.
<Button>is<Button>is<Button>. Not<Button>,<ButtonNative>,<ButtonWeb>. -
Same props.
variant,size,disabled,onPressmean the same thing and accept the same values on every platform. -
Same design tokens.
colors.primary.500resolves to the exact same hex on every platform, even when the underlying color format differs (hex strings on web, native color objects on iOS and Android). - Same render contract. Tap on mobile equals click on web. Focus trap works the same way. Keyboard navigation is identical. The accessibility tree is identical.
Miss any one of these and the platforms drift.
What this looks like in code
The same component file ships to all three platforms. Web imports from @otfdashkit/ui; iOS and Android from @otfdashkit/ui-native — same JSX, same component names, same props.
import { Button, Card, Input, Stack } from '@otfdashkit/ui'
export function SignupForm() {
return (
<Card padding="lg">
<Stack gap="md">
<Input
label="Email"
placeholder="you@example.com"
keyboardType="email"
/>
<Input
label="Password"
type="password"
secureTextEntry
/>
<Button
variant="primary"
size="lg"
onPress={handleSubmit}
>
Create account
</Button>
</Stack>
</Card>
)
}
The web bundle ships this as real DOM with platform-native accessibility. The iOS and Android bundles ship the same JSX through a native renderer that maps <Button> to the native button on each platform — same variant, size, disabled, and onPress semantics, the same tap-target sizes, the same focus behavior. Props that look platform-specific (keyboardType, secureTextEntry) are passed through as no-ops on platforms that don't have them. The component contract stays clean, and the screen code never branches on platform.
Design tokens flip one theme across every platform
Visual identity comes from tokens, not from the component code. One token file defines the entire theme:
export const tokens = {
colors: {
primary: {
50: '#F0F7FF',
500: '#0066FF',
900: '#003D99',
},
surface: {
base: '#FFFFFF',
raised: '#F7F8FA',
sunken: '#EEF0F4',
},
text: {
primary: '#0B1220',
secondary: '#5A6478',
inverse: '#FFFFFF',
},
},
radius: { sm: '6px', md: '10px', lg: '16px', pill: '999px' },
space: { xs: '4px', sm: '8px', md: '16px', lg: '24px', xl: '32px' },
type: {
body: { size: 16, lineHeight: 1.5 },
caption: { size: 13, lineHeight: 1.4 },
},
}
A theme flip is one swap:
import { applyTheme } from '@otfdashkit/tokens'
import { darkTheme } from './dark-theme'
applyTheme(darkTheme)
Web swaps CSS custom properties. iOS swaps native color references. Android swaps color resources. The component code is untouched. Every <Button variant="primary"> becomes the new primary color on every platform in the same paint frame. No stale iOS button lingering with the old brand color three weeks after a redesign.
[[DIAGRAM: design tokens flow from a single source file to three renderers — web, iOS, Android — each translating to its native color and unit format, while every component reads the same token name]]
Where AI coding agents make this worse (and how to fix it)
AI coding tools accelerate cross-platform work — but they accelerate the drift too. An agent extending a codebase without strict component conventions will happily invent <Button2> or sprinkle style={{ background: '#0066FF' }} directly into JSX. The codebase now has 200 components in ui/, 47 ad-hoc button copies in screens/, and three different blues for the primary brand color.
The fix isn't to disable the agent. It's to ship the conventions alongside the components.
Every full-stack kit ships with:
-
CLAUDE.mdand.cursorrulesdescribing the component contract — use<Button>, never raw platform primitives; usecolors.primary.500, never inline hex. - 20+ tested prompts in
ai/prompts/— "add a new screen", "swap the brand color", "wire auth to a new backend" — that produce code conforming to the kit's conventions. - A 24-item design checklist enforced by a script before any kit ships, so the conventions are enforced in CI rather than tribal knowledge.
The agent extends the kit. It doesn't regenerate it. That's the difference between an AI-native codebase and an AI-fragmented one.
What this enables
When the component contract actually holds, several things change at once.
A new platform is one renderer, not one rebuild. A hypothetical desktop or wearable target adds a fourth @otfdashkit/ui-* package. The component code, the tokens, the props, the screen code — none of it changes.
A redesign is one token swap. Change primary.500 from #0066FF to #0052CC once. Every component, every platform, every screen updates in the same paint frame. Marketing can ship a brand refresh in an afternoon instead of a quarter.
AI agents become multipliers, not entropy sources. With the conventions and prompts shipped, an agent adding a new screen produces code that conforms. The codebase stays a kit, not a museum.
Quality is measurable. The 24-item checklist runs in CI. A new component that drifts on color contrast, focus order, or tap-target size fails the script before it ships.
The lie, restated
"Write once, run everywhere" was never the goal. The goal is ship-one-product, render-everywhere-correctly. The mechanism that gets you there isn't a magic compiler. It's a strict contract — same name, same props, same tokens, same behavior — held consistently across renderers.
OTF ships that contract as ~200 components on three platforms, design tokens that flip themes identically, AI-tool configs that keep agents honest, and full-stack kits that demonstrate it end-to-end. The components are MIT-licensed on npm (@otfdashkit/ui, @otfdashkit/ui-native, @otfdashkit/tokens). The kits own the code outright, ship to production with a single script, and start at $99 (Everything Bundle $149). Live at saas.otf-kit.dev and fitness-preview.otf-kit.dev.
The myth isn't worth chasing. The contract is.
Top comments (0)