Unify Your UI/UX Across Platforms with OTF's Component System
The button shipped on iOS at 9:47pm. By 9:51pm, three engineers had filed issues. Web had it grey. Android had it floating with the wrong radius. iOS — the one that looked right at first — had a press state that stuck after release.
Three teams, three button components, three regressions.
This is what platform drift actually costs. Not the abstract "inconsistent UX" line from a design system pitch deck — actual man-hours: the same button redrawn three times, themed three times, accessibility-checked three times, and still wrong in three different ways on the same Tuesday night.
The naive fix is "one codebase to rule them all" — write once, run anywhere. Most attempts at that die on contact with platform reality. iOS wants one gesture model. Web wants another. Native wants a third. Each platform has its own accessibility tree, its own focus model, its own idiomatic motion. A component that pretends those don't exist looks fine in a demo and breaks the first time a screen reader user encounters it.
So this isn't a "unified codebase" pitch. It's a tighter claim: the same component name, the same props, the same visual output — on every platform — without papering over the platform reality that makes each one usable. When the abstraction respects the platform, you stop redrawing. When it doesn't, you get the Tuesday night.
The drift problem, named precisely
"Cross-platform" usually splits into two:
- One runtime, two skins. A web app shipped as a wrapped WebView. Same DOM, same CSS, same bugs in two app stores.
- Two runtimes, two skins. A native iOS app and a native Android app, plus a React web frontend. This is the common case, and the cost here is the original sin: every component is rebuilt three times.
The first case is cheap on day one and punishing on day ninety — WebView keyboards, WebView accessibility, WebView pull-to-refresh that never feels right. The second is the one most teams actually live in, and the one that quietly bleeds the most hours.
Three primitives are usually duplicated:
| Component | What's rebuilt | Cost |
|---|---|---|
| Atoms (Button, Input, Card) | Visual + interaction + a11y | Multiplied across every screen |
| Forms (validation, errors) | Submission flows per platform | Tests aren't portable |
| Navigation (stack, tab) | Idioms differ per platform | The hardest one to fake |
The days lost aren't glamorous. They're the Tuesday-night pixel-fix days, repeated across every release.
What "the same component" actually has to mean
The cheap version of "unified" is "it renders something." A button-shaped rectangle that submits the form. That's the floor, not the goal.
The version that survives production has three properties, and all three have to hold at the same import:
- Same name, same props. A
Buttonacceptsvariant,size,onPress(oronClickon web),disabled. Same names, same types, same semantic values across runtimes. - Same pixel output, given the same tokens. Tap the design tool, copy the hex. The button on web, iOS, and Android renders that exact colour, at that exact radius, at that exact type scale.
- Same a11y contract. A button is a button to VoiceOver, NVDA, and TalkBack. Role, label, focus ring, and disabled semantics — identical, not "close enough".
Most cross-platform libraries clear one of those. Almost none clear all three at the same component, same import.
One codebase. iOS, Android, and web.
The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.
Why "write once, run anywhere" usually means broken everywhere
The pattern that fails is the one that hides the platform. A View that "just works" on web and mobile has to make choices about touch vs mouse, flexbox vs Yoga, focus-visible vs focus-on-press. The moment the abstraction makes those choices for you, your designer files a ticket: "the web button has the wrong hover state." Your QA engineer files another: "Android focus order is reversed." Your accessibility lead files a third.
The right rule is respect the platform at the boundary, then collapse everything above it. The boundary is where the buttons actually live — a real DOM <button> on web, a real native view on mobile. Above the boundary, your team writes once.

// web — same import path a screen already uses
import { Button, Card, Stack } from '@otfdashkit/ui'
export function PricingRow({ plan }: { plan: Plan }) {
return (
<Card>
<Stack gap="md">
<h3>{plan.name}</h3>
<Button variant="primary" size="lg" onClick={() => subscribe(plan)}>
Choose {plan.name}
</Button>
</Stack>
</Card>
)
}// native — same component name, same props, same visual output
import { Button, Card, Stack } from '@otfdashkit/ui-native'
export function PricingRow({ plan }: { plan: Plan }) {
return (
<Card>
<Stack gap="md">
<Text style={h3}>{plan.name}</Text>
<Button variant="primary" size="lg" onPress={() => subscribe(plan)}>
Choose {plan.name}
</Button>
</Stack>
</Card>
)
}No translation layer. No prop-mapping utility. No "but on mobile it has to be..." comment in the PR. The screen file is identical except for the runtime-appropriate import and the platform-native event name. Convention beats configuration.
The token layer that holds it together
Pixel parity across platforms dies the moment each runtime defines its own colours, spacing, and type scale in three different files. The fix is a single token source, fan-out per runtime, and a guard script that fails the build when a runtime drifts.
// tokens — runtime-agnostic, the only source of truth
export const tokens = {
color: {
primary: { 500: '#5B6CFF', 600: '#4854E0' },
surface: { 0: '#FFFFFF', 50: '#F7F8FA' },
},
radius: { sm: 6, md: 10, lg: 16 },
space: { xs: 4, sm: 8, md: 16, lg: 24 },
} as const# one script re-emits the token files per runtime
$ pnpm tokens:sync
→ apps/web/tokens.css (CSS variables for the web runtime)
→ apps/mobile/tokens.ts (typed consts for the native runtimes)
→ apps/landing/tokens.json (design-tool export)The cold practical detail: when a designer bumps the primary to a new brand colour, you change one file and the three runtimes pick it up in the same commit. When someone adds a new colour in one runtime and forgets the others, the guard script catches it before it ships. Configuration beats three drift-prone duplications.
The accessibility contract that doesn't ship as a footnote
The single biggest reason "unified" usually means "broken for screen reader users" is that the abstraction hides the accessibility tree. Web exposes it. iOS exposes it. Android exposes it. An abstraction that pretends to unify all three usually maps to one and ignores the other two.
The contract that holds in production:
- Role maps correctly per runtime. A
Buttonisrole="button"on web, a real native button on iOS, aclickableview with proper accessibility state on Android — but in all three cases, assistive tech reads it as a button. - Focus order is the source order. No
tabIndexgames on web, noaccessibilityElementsreshuffling on iOS that differ from the visual order. - Disabled disables the right things. Inert pointer events on web,
accessibilityState={{ disabled: true }}on native. Not just a faded look.
This is enforceable, not aspirational. A 24-item checklist runs before any kit ships:
$ pnpm kit:check
✓ every Button has an accessible name
✓ focus order matches source order on web
✓ disabled state propagates to a11y tree on all three runtimes
✗ Tab order drift in PricingTable.tsx (web) — fix before shippingThe last line is the point. The check runs in CI. A screen ships only when the contract holds across all three runtimes.
Why AI coding agents port screens instead of rewriting them
Most of the Tuesday-night regressions don't start in code — they start in a copy-paste from a snippet an AI coding agent wrote six months ago. The agent didn't know the company had a Button component. So it wrote a styled <div> with an onClick. Now there are three of those, in three files, across two repos.
The fix isn't "tell the AI to use the design system." That's a paragraph of guidance nobody reads. The fix is that the kit ships a recognised set of conventions the agent can extend without guessing:
<!-- CLAUDE.md / .cursorrules — the same hint, two agents -->
The codebase exposes a cross-platform component kit. Components have
identical names and props across web (`@otfdashkit/ui`) and native
(`@otfdashkit/ui-native`). When porting a screen, change the runtime-
appropriate import and the event handler name (`onClick` → `onPress`).
Do not regenerate an existing component as a styled primitive —
extend the existing one. Design tokens are the single source of truth
in `/tokens`; never hard-code colours or spacing in a screen.This is the part that doesn't burn on a Tuesday night. A junior dev, a contractor, or a coding agent picks up the kit and inherits the conventions. The "use this, not that" decision is encoded once, in a file the agent reads, instead of being repeated in code review forever.
What this gets you on a Tuesday night
The version of this story you actually want:
It's 9:47pm. The button ships on all three runtimes. Web has it. iOS has it. Android has it. Press state releases on touch-up. The web focus ring matches the design. The accessibility tree is intact on every platform because the contract is checked in CI, not in QA.
Total drift: zero. Tuesday night: free.
The way to get there isn't a "unified runtime" pitch. It's a component kit where the name, the props, the pixels, and the a11y contract all hold across the three runtimes — and the convention is loud enough that everyone who touches the codebase (human or agent) extends instead of regenerates. That's the durable layer. The platforms underneath keep changing — that's their job. The interface your team writes against doesn't.
Stop wiring. Start shipping.
- Login, database, and backend already connected — nothing to set up
- iOS + Android + web from one codebase
- AI configs pre-tuned + 40+ tested prompts included