OTFotf
All posts

Same component, web + mobile — the architecture behind @otfdashkit/ui

D
DaveAuthor
11 min read
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's createTamagui

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:

ComponentWebNative
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:

  1. Two implementations to maintain when adding a new component. A new Banner ships 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. Marquee is web-only, OnboardingCarousel is native-only).

  2. Per-platform mental load when reading the kit. A developer who's only on web reads @otfdashkit/ui and ignores @otfdashkit/ui-native. A developer who's on both reads both. The kits make this clearer by structuring kits/<name>/ consistently — same hooks shape, same Hono routes shape, same CLAUDE.md template — 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.

architecturedesign-systemreact-nativetamagui

On this page