Writing a component once and shipping it to web, iOS, and Android — same name, same props, same pixels — is the boring dream every cross-platform framework has been chasing for a decade. Most of them get 80% of the way there and then quietly leak the last 20% as platform-specific escape hatches. We tried most of them. Here's the part that actually works.
The parity tax, line by line
Every cross-platform project ships a balance sheet nobody wants to read. Three button components that drift apart over six months. Three inputs where the Android one loses its focus ring. Three loading spinners that look related but aren't. None of it is dramatic. All of it costs.
The line items:
-
Build drift. You write
<Button>on Tuesday, ship a new prop on Wednesday on iOS, push the same prop to web on Friday, forget Android until QA pings you three weeks later. The bug isn't the prop — it's the calendar. - Design drift. A designer tweaks the primary colour in Figma on a Tuesday. The web theme updates in a PR the same day. Native ships the old shade for a quarter because the token export from Figma never reached the native repos.
- Refactor drag. Changing one component means auditing three codebases, three type files, three test suites, three sets of snapshots, and three different review queues. The PR is 200 lines. The deployment dance is 2,000.
Three platforms. Three of everything. That's the tax.
One component, three platforms, one API
The same import path, the same props, the same JSX. Web:
import { Button } from '@otfdashkit/ui'
export function CTA() {
return (
<Button variant="primary" size="md" onPress={startCheckout}>
Get started
</Button>
)
}
Native (iOS + Android, same file):
import { Button } from '@otfdashkit/ui-native'
export function CTA() {
return (
<Button variant="primary" size="md" onPress={startCheckout}>
Get started
</Button>
)
}
Same variant. Same size. Same onPress shape. The ~200 components in the library cover the ground most apps actually need — buttons, inputs, sheets, lists, dialogs, tabs, toasts, navigation primitives — and every one of them ships with the same surface area on every platform.
That isn't a marketing claim. It's the API. You write <Button> once, you ship it three times.
Tokens are the source of truth
Components are half the problem. The other half is colour, spacing, type, radius — the visual grammar. If those drift, the components drift with them, and no amount of component-library discipline will save you.
Install the token package alongside the components:
npm i @otfdashkit/ui @otfdashkit/ui-native @otfdashkit/tokens
Tokens look like this:
import { tokens } from '@otfdashkit/tokens'
export const theme = {
color: {
bg: { primary: tokens.color.blue[600], surface: tokens.color.gray[50] },
text: { primary: tokens.color.gray[900], muted: tokens.color.gray[600] },
},
space: { 1: tokens.space[1], 2: tokens.space[2], 4: tokens.space[4] },
radius: { sm: tokens.radius.sm, md: tokens.radius.md },
}
Flip bg.primary from blue to teal once, on one platform. All three update. The same <Button> on iOS, Android, and web renders with the same hex, the same padding, the same border-radius. Visual parity is a property of the token graph, not a hope held by the team.
Theme switching is the same primitive. A dark theme is a second token export. Components don't know it exists — they consume whatever the current theme hands them, and the theme is the only thing that knows which mode is active.
The 24-item gate before anything ships
Most cross-platform libraries ship whatever compiles. The cross-platform kits ship after a script gates them against a 24-item design checklist. Touch targets meet platform minimums. Contrast passes WCAG AA. Focus rings are visible against every background token. Safe-area insets are honoured on every sheet and modal.
The checklist is enforced, not aspirational. A kit doesn't reach npm publish until the script returns zero. That's the difference between "looks right on my laptop" and "passes review on a real device, in dark mode, with the user's preferred text size".
A few items from the actual list:
| Check | Why it matters |
|---|---|
| Tap target ≥ 44pt iOS / 48dp Android | App Store and Play Store floor |
| Contrast ratio ≥ 4.5:1 for body text | WCAG AA, also just legible |
| Dark-mode parity per token | No "almost dark" surfaces |
| Safe-area inset handling | Notch, dynamic island, gesture bar |
| Reduced-motion fallback | System setting respected, not ignored |
If a kit fails any one of those, it doesn't ship. The gate is the contract.
AI agents extend, they don't regenerate
This is the part most cross-platform stacks get wrong. Hand an AI coding agent a fresh empty repo and it invents its own conventions. Hand it a kit with no documentation and it does the same thing, but louder.
Every full-stack kit ships the conventions the agent needs to extend the kit instead of replacing it:
my-app/
├── CLAUDE.md # how the kit is structured
├── .cursorrules # what the agent must / must not change
├── ai/
│ └── prompts/ # 20+ tested prompts for common tasks
└── src/
└── ... # your code, on top of the kit
CLAUDE.md says: this is a cross-platform kit, components live here, tokens flow through here, do not regenerate <Button>. .cursorrules says the same thing in Cursor's dialect. The 20+ prompts in ai/prompts/ are the ones actually used to extend real kits — add a screen, wire auth, add a Stripe product, scaffold a new page, hook up a server action.
The agent extends. It doesn't replace. Convention beats configuration, and the convention is written down.
[[DIAGRAM: one component source feeding web, iOS, and Android through a shared token graph; AI agent reads CLAUDE.md and .cursorrules to extend the kit, not regenerate it]]
From dev to production in one script
Cross-platform parity doesn't end at "it renders correctly in dev". You still need a domain, DNS, TLS, and a mobile build that survives App Store review. The SaaS Dashboard kit and the Fitness kit both ship with a deploy script that wires all of it:
./scripts/production.sh my-app.example.com
What it does, in order: validates env, builds web, builds the iOS archive, builds the Android AAB, points DNS at the deployment, issues and pins the TLS cert, runs the post-deploy smoke checks. One command. The script is the same one used internally — the same one that keeps saas.otf-kit.dev and fitness-preview.otf-kit.dev alive.
That's the boring half of cross-platform nobody talks about at a hackathon and everybody pays for the week before launch.
What this enables
A team of two shipping an iOS app, an Android app, and a web app from one component tree. A designer updating one token and seeing it on three platforms the same day. An AI agent that asks "where do I add a new screen?" and gets a real answer from CLAUDE.md instead of inventing a fourth codebase. A release that doesn't need a "wait, does this work on Android?" channel in Slack on Friday afternoon.
You can start in two ways:
# Option A — copy-paste CLI, scaffolds into the current directory
npx otf-kit init
# Option B — straight npm install, bring your own scaffold
npm i @otfdashkit/ui @otfdashkit/ui-native @otfdashkit/tokens
Free MIT SDK on npm. ~200 components, three platforms, one API. Paid full-stack kits at $99 each (Everything Bundle $149) ship auth, billing, DB, Stripe, and the production script wired — you own the code. 15 single-page landing templates at $9 each when all you need is a marketing surface.
Closing
Cross-platform parity is one of those problems everyone agrees is real and almost nobody fixes at the level it deserves. The fix isn't a cleverer framework. It's the same component name on every platform, the same token graph feeding it, and a checklist that stops bad builds before they ship. The model layer underneath will keep churning — context windows will grow, prices will drop, agents will get smarter. The render target underneath the model layer won't.
Write <Button> once. Ship it three times. Sleep on launch night.
Top comments (0)