Same component, web + mobile — the architecture behind @otfdashkit/ui
The dream of "write once, run on web + mobile" has burned a lot of people. Cordova / Ionic / Native Script / React Native Web / Flutter for web — each generation tries to make the platforms agree, and each generation pays a tax somewhere.
OTF doesn't try to dissolve the difference. It does something narrower: share the design system across web and mobile, but keep the implementation native on each side.
Same Card component name, same prop intent. Different file, different render path. One
mental model.
This post is about how that's structured, and where it bends.
The two packages
@otfdashkit/tokens — design tokens (CSS variables + JS object)
@otfdashkit/ui — web implementation (Radix + Tailwind)
@otfdashkit/ui-native — native implementation (Tamagui)tokens is the seam. It exports the same values in two formats:
web.css— CSS custom properties (--background,--primary, …)native.ts— JS object consumed by Tamagui'screateTamagui
When you change --primary in theme-warm, both web and native pick it up. There's
exactly one source of truth for color, spacing, radius, motion.
Why two implementation packages, not one
We tried unified. It went badly.
We tried React Native Web — running RN components in a browser via react-native-web. It
works, but the bundle is huge (you're shipping RN's entire layout engine to the browser),
the styling story is awkward (StyleSheet.create is a worse fit than Tailwind for web), and
the animation primitives don't map cleanly to web idioms.
We tried Tamagui-everywhere — Tamagui generates its own web output from the same source as its native output. Closer to the dream, but Tamagui's compile-step adds friction to web projects that don't need it, and the web-side ergonomics aren't as good as Radix + Tailwind for our use case (heavy keyboard accessibility, nested overlays, etc.).
So we split. @otfdashkit/ui uses Radix + Tailwind, optimised for the web. @otfdashkit/ui-native
uses Tamagui, optimised for native. They share names (where the abstraction is honest)
and tokens (always).
The shared name policy
Where the abstraction is honest, the names match:
| Component | Web | Native |
|---|---|---|
Card | <Card> from @otfdashkit/ui | <Card> from @otfdashkit/ui-native |
Avatar | <Avatar> + parts | <Avatar> + parts |
Input | <Input> | <Input> |
Button | <Button> (variant prop) | <Button> (variant prop) |
Tooltip | <Tooltip> + parts | <Tooltip> (long-press on native) |
Tabs | <Tabs> + parts | <OtfTabs> (prefixed to avoid Tamagui conflict) |
Where the abstraction is not honest, the names diverge:
- Web
<Sheet>(right-slide modal) → Native<BottomSheet>(drag-up sheet, snap points, different physics) - Web
<Drawer>→ Native<ActionSheet>(iOS-style action picker) or<BottomSheet> - Web
<Toaster>(DOM-mounted) → Native<OtfToastProvider>(RN context-mounted)
This is deliberate. A named abstraction that papers over a real platform difference will break. Better to keep names different where the implementations are different.
Tokens that work on both sides
The tokens package emits two outputs from one source.
Web (CSS custom properties):
/* @otfdashkit/tokens/web.css */
:root {
--background: 0 0% 100%; /* HSL components — multiplied through hsl() at use site */
--foreground: 220 8% 12%;
--primary: 212 100% 47%;
/* ... */
}
.dark {
--background: 220 12% 7%;
--foreground: 0 0% 96%;
/* ... */
}
.theme-warm {
--primary: 28 100% 56%; /* Override single tokens to retheme */
/* ... */
}Native (JS):
// @otfdashkit/tokens/native.ts
export const tokens = {
color: {
background: 'hsl(0, 0%, 100%)',
foreground: 'hsl(220, 8%, 12%)',
primary: 'hsl(212, 100%, 47%)',
// ...
},
// ...
}
export const themes = {
light: {
background: tokens.color.background,
foreground: tokens.color.foreground,
primary: tokens.color.primary,
// ...
},
dark: {
background: 'hsl(220, 12%, 7%)',
// ...
},
}Both come from the same themes.ts source object that's transformed into CSS or JS during
the package build.
Charts: the recharts problem
Charts are the place where we deliberately broke "same name, same place". @otfdashkit/ui
exports BarChart, LineChart, AreaChart, etc. — wrappers around recharts. There is no
equivalent in @otfdashkit/ui-native, because recharts doesn't run on RN.
Native charts in our kits use react-native-svg directly with custom tokens. They look
similar to the web charts but their API doesn't match. We considered shipping a thin abstract
layer to make them match, but the SVG-direct approach is honest and the abstraction would
have leaked the moment we needed dual-axis or interactivity.
Theming with one switch
Both web and native read from the tokens package. Switching themes is one mechanism on each:
Web: swap a class on <html>:
document.documentElement.className = 'theme-warm dark'Native: swap a Tamagui theme prop:
<Theme name="dark">
<App />
</Theme>The kits both wrap this in a useThemeColor() hook + <FloatingThemePicker> component so
the user-facing UX is the same: tap the palette icon, pick a theme, both web and native
re-render to match.

What we gave up
This split costs you two things:
-
Two implementations to maintain when adding a new component. A new
Bannerships on web first. The native side gets it later (or never, if it's not needed natively). ~80% of components are on both sides. ~20% are platform-specific (e.g.Marqueeis web-only,OnboardingCarouselis native-only). -
Per-platform mental load when reading the kit. A developer who's only on web reads
@otfdashkit/uiand ignores@otfdashkit/ui-native. A developer who's on both reads both. The kits make this clearer by structuringkits/<name>/consistently — same hooks shape, same Hono routes shape, sameCLAUDE.mdtemplate — so cross-platform fluency comes from the kit's structural conventions, not from a unified component layer.
What we got back
In return:
-
Web bundle stays small. No RN engine in the browser. Vite tree-shakes the unused primitives. The SaaS Dashboard kit's prod bundle is ~180 KB gzipped including all of
@otfdashkit/ui. -
Native feels native. Tamagui's per-platform optimizations (Skia for animations on iOS, Reanimated worklets, native gesture handler) all ship through. No layout-engine emulation tax.
-
Theming is a class swap. No re-renders, no provider changes, no animation glitches. Both web and native pick up token changes immediately.
-
Each side stays idiomatic. Web devs see Radix + Tailwind. Native devs see Tamagui. Neither has to learn the other to be productive on their side.
The architecture in one diagram
┌───────────────────────────┐
│ @otfdashkit/tokens │
│ ─ themes.ts (source) │
│ ─ web.css (output) │
│ ─ native.ts (output) │
└─────────┬─────────────────┘
│ imports
┌─────────────┴─────────────┐
│ │
┌───────▼─────────┐ ┌───────▼─────────┐
│ @otfdashkit/ui │ │@otfdashkit/ │
│ Radix + Tailwind│ │ ui-native │
│ Web only │ │ Tamagui + RN │
└───────┬─────────┘ └───────┬─────────┘
│ used by │ used by
┌───────▼─────────┐ ┌───────▼─────────┐
│ SaaS Dashboard │ │ Fitness Kit │
│ Vite + Hono │ │ Expo + Hono │
└─────────────────┘ └─────────────────┘Same theme. Same names. Different files. One mental model.
Try it
The conventions live in the Tokens overview, the Components overview, and the Native components overview. The kits at saas.otf-kit.dev and fitness-preview.otf-kit.dev are the canonical implementations.
If you're going to ship cross-platform, you have to decide what you're sharing and what you're not. We share design — colors, spacing, names, vibes. We don't share implementation. That single decision dropped most of our cross-platform pain.