Skip to content

0003 — Tamagui as the sole styling solution

Status: Accepted Date: 2026-04 Deciders: TwoMore mobile team

Context

TwoMore targets iOS, Android, and web from a single codebase. The styling layer must satisfy four hard requirements simultaneously:

  1. Universal rendering — the same component tree renders on native (Metro/Hermes) and web (Next.js SSR).
  2. Design-token enforcement — spacing, color, and typography values come from a typed token system; arbitrary #hex colors and pixel values are not legal at the call site.
  3. Dark-mode support — the theme system must be theme-aware without per-component useColorScheme calls.
  4. Performance — press feedback and entrance animations must run on the UI thread (Reanimated worklet / CSS compositor), not the JS thread.

During v1, StyleSheet.create was the baseline. Dark-mode support was hand-wired; the spacing system was ignored in practice; every session introduced new ad-hoc pixel values. The v1 codebase had no mechanical way to enforce token usage.

Options considered

Option A: RN StyleSheet.create

  • Pros: zero dependency; every RN engineer knows it
  • Cons: no token system (arbitrary values are legal); no compile-time extraction for web; dark mode requires per-component useColorScheme; typed tokens not expressible without a wrapper that nobody uses consistently
  • Effort: 0
  • Reversibility: high

Option B: NativeWind v4 (Tailwind for RN)

  • Pros: large ecosystem; Tailwind muscle memory from web engineers
  • Cons: token contract is weakly enforced — arbitrary values like [#ff0000] bypass the system silently; no compile-time CSS extraction for web (runtime resolution); our v1 experience with a similar approach showed that arbitrary spacing values accumulate every session because the system doesn't stop you
  • Effort: ~1 week setup + migration
  • Reversibility: medium

Option C: Unistyles 3

  • Pros: excellent native performance; strongly typed themes; better Reanimated integration than Tamagui
  • Cons: web support was experimental in 2026; no SSR path; our Next.js web app would need a completely separate styling approach — one system for native, another for web
  • Effort: ~1 week setup
  • Reversibility: medium

Option D: Tamagui v1.144.3

  • Pros: the only mature library with full web equivalence (native + SSR) from one component tree in 2026; compile-time CSS extraction for web; createTokens() makes violations compile errors; strong primitive library (Button, Card, Sheet, Dialog); styled() produces UI-thread animations via the configured driver
  • Cons: v1.144.3 is the last pre-v2 stable — v2 will require a migration; styled() must only be called inside packages/ui/src/ for the compiler to extract it; learning curve on compiler rules; testing story is weaker (fireEvent.press(), not userEvent)
  • Effort: ~2 weeks migration from v1 StyleSheet baseline
  • Reversibility: low — Tamagui tokens and styled() are pervasive; migration away would touch every file

Decision

Option D — Tamagui v1.144.3 as the sole styling solution; all other styling approaches are mechanically forbidden.

Primary reason: the universal rendering requirement eliminates NativeWind (no SSR) and Unistyles (no stable web). StyleSheet has no enforcement story. Tamagui is the only option that satisfies all four hard requirements.

Dissenting consideration: v1.144.3 is a dead branch — v2 migration will be required. Mitigated by: v2 GA is expected mid-2026; migrating a well-typed Tamagui v1 codebase to v2 is a mechanical refactor, not an architectural one.

Consequences

Positive

  • Token violations are compile errors: a component using paddingTop={12} instead of paddingTop="$3" fails yarn typecheck
  • Five ESLint rules enforce the no-mixing policy: @twomore/no-stylesheet-create, @twomore/no-classname-prop, @twomore/no-nativewind-imports, @twomore/no-hex-in-style-props, @twomore/no-inline-style-with-vars
  • Animation driver is locked: @tamagui/animations-moti (Reanimated worklets, UI thread) on native; @tamagui/animations-css (GPU compositor) on web — enforced by @twomore/animation-driver-pinned
  • styled() is confined to packages/ui/src/ (ARCH-7 / @twomore/no-styled-outside-ui) so the compiler always finds the call sites

Negative

  • RN-native primitives that need raw StyleSheet (PulseDot, native safe-area wrappers, platform escape hatches) are ratcheted in eslint.ratchet.config.mjs; new violations fail lint
  • Gorhom bottom sheet cannot be used in shared packages — @gorhom/bottom-sheet is native-only and crashes the web bundle. Product surfaces use ModalPanel; Tamagui Sheet is dev-panel only (STYLE-3, @twomore/no-gorhom-bottom-sheet-in-shared, @twomore/no-bottom-sheet-outside-dev-panel)
  • Tamagui compiler rules require styled() to be called in the same file as the component for extraction — forwarding styled() results across files is not supported

Neutral

  • The 4px spacing grid ($1=4 $2=8 … $10=64) is enforced by @twomore/no-numeric-spacing-in-features; off-grid values (avatar dimensions, dot sizes) are expressed as named constants in packages/ui/src/box-sizes.ts

Implementation notes

  • Token definitions: packages/ui/src/tokens.ts
  • Compiler config: apps/mobile/tamagui.config.ts, apps/web/tamagui.web.config.ts
  • Box-size constants: packages/ui/src/box-sizes.ts (AVATAR_SIZE, DOT_SIZE)
  • styled() components: packages/ui/src/ only

References

Markdown remains the source of truth. Run yarn docs:check before handoff.