DEV Community

Cover image for Same component, web + mobile — the architecture behind @otfdashkit/ui
Dave Kurian
Dave Kurian

Posted on • Originally published at otf-kit.dev

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)
Enter fullscreen mode Exit fullscreen mode

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:

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 */
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

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%)',
    // ...
  },
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Native: swap a Tamagui theme prop:

<Theme name="dark">
  <App />
</Theme>
Enter fullscreen mode Exit fullscreen mode

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     │
    └─────────────────┘         └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)