Skip to content

Tamagui & Styling Conventions

Status: Active Last reviewed: 2026-07-19

How TwoMore styles every surface — the Tamagui-only foundation, the 4px-grid spacing system, typography roles, icon discipline, the ModalPanel overlay contract, visual-consistency single-source choices, and the React-Compiler-first render-performance model. These are the canonical, single-source rules; feature code follows them and never reaches for StyleSheet.create, NativeWind, raw hex colors, or numeric spacing literals. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md (STYLE-1..4, COMP-1/2).

Tamagui-only rules

  • Never mix StyleSheet.create with Tamagui — tokens don't resolve
  • Never use inline style={{}} with variables — breaks compiler flattening. Use variants.
  • Never define styled() outside packages/ui — compiler won't extract it
  • Never use className or NativeWind — pure Tamagui
  • All tokens via $ prefix: <Stack padding="$4" backgroundColor="$brand500" />
  • All typography via the 5 semantic role system (see Typography below)
  • Dark mode: only via <TamaguiProvider defaultTheme={colorScheme} /> — no per-component theme toggling

Spacing system

Spacing system — every padding / margin / gap value comes from the 4px-grid space tokens at packages/ui/src/tokens.ts: $0=0 $1=4 $2=8 $3=12 $4=16 $5=20 $6=24 $7=32 $8=40 $9=48 $10=64. Never hard-code a number for spacing — always pick a $N. Semantic conventions:

  • $1 (4) — tight inline gap (badge ↔ icon)
  • $2 (8) — action button row gap, label ↔ input pair
  • $3 (12) — card-internal gap, section-internal gap, inter-card rhythm in a list (canonical: FeedList DefaultSeparator height={12}), inter-card rhythm in a <YStack> of stacked cards
  • $4 (16) — screen horizontal gutter (paddingHorizontal="$4"), default card padding (size="default"), chrome → content section break (title → chip, chip → content). Canonical: SegmentedTabs paddingBottom="$4", MainTabShell title-row paddingBottom="$4".
  • $5 (20) — hero card padding (size="hero")
  • $6 (24) — section ↔ section divider, scroll bottom buffer
  • $8 (40) — page-level breathing room (e.g. paddingBottom on long scrolls)

Proximity hierarchy rule — section breaks MUST be looser than intra-list rhythm. Items in the same group must be visually tighter than the boundary between groups. The canonical hierarchy:

  • Chrome → content boundaries (title → chip, chip → list): $4 (16px)
  • Intra-list rhythm (item → item in a list, card → card in a stacked YStack): $3 (12px)
  • If you ever set a chrome→content gap smaller than the intra-list gap, you've inverted proximity — the list items will read as more-separated than the list-vs-chrome boundary, breaking visual hierarchy.

Spacing ownership rule — when two adjacent containers can each contribute padding to a gap (e.g. chips + content pane), exactly ONE of them owns the gap. Convention: the upstream chrome (the chip wrapper, the section header, the card) owns its bottom padding; the downstream content does NOT add a matching paddingTop. Doubling = visible bug. The canonical chip-content gap is SegmentedTabs paddingBottom="$4" (16px) — temporal panes / tab content render directly under it with no paddingTop.

Directional spacing — vertical spacing flows DOWN. Components in packages/features/** must not declare paddingTop. Inter-child rhythm comes from parent gap; chrome-to-content gaps come from the chrome's paddingBottom. First-child top spacing belongs to the container, not the child. This is the "Lobotomized Owl" pattern — making it impossible for two adjacent boxes to both contribute to the same gap. Enforced by @twomore/no-padding-top-in-features ESLint rule (existing usages ratcheted; new code blocked). When a feature genuinely needs first-child anchoring with no upstream owner, opt out with // eslint-disable-next-line @twomore/no-padding-top-in-features and a comment justifying why.

Numeric spacing values are forbidden — use $N tokens. Padding / margin / gap props in packages/features/** and packages/ui/** must use 4px-grid tokens ($0..$10), not literal numbers (paddingTop={12}, gap={6}). The token system encodes the design system; numeric drift bypasses it silently. Enforced by @twomore/no-numeric-spacing-in-features ESLint rule. Off-grid intentional cases (hit-slop tricks, optical alignment, native-platform safe-area clearance) require an inline // eslint-disable-next-line @twomore/no-numeric-spacing-in-features directly above the violating attribute (NOT the JSX element line — the disable applies to the next physical line, so it must immediately precede the offending prop) plus a comment justifying why no token suffices. The rule does NOT inspect inline style={{}} objects — that's covered by @twomore/no-inline-style-with-vars.

Box-dimension constantswidth / height for avatars, status dots, and small icon frames use named constants from packages/ui/src/box-sizes.ts, not magic numbers. The 4px-grid $N tokens cover spacing only — avatar dimensions (28, 32, 40, 44, 80, 88) and status-dot dimensions (7, 8) are intentionally off the spacing scale. Use AVATAR_SIZE.{XS|SM|MD|LG|XL|2XL} and DOT_SIZE.{XS|SM} from @twomore/ui. For circle avatars, set borderRadius={AVATAR_SIZE.X / 2} so the rounding tracks the size constant. Add to box-sizes.ts only when the dimension is used in 2+ places AND can't be expressed as a $size token; one-offs stay as inline literals with a // reason: comment. (Not yet ESLint-enforced — width/height use sound judgment, not the magic-number ban that covers spacing.)

Typography

All typography via the 5 semantic role system. Using Text → pick a role, never hardcode fontSize/lineHeight/fontWeight. Core 5: pageTitle (24/32 700), cardTitle (16/24 600), cardBody (14/22 400), cardMeta (13/18 500 muted), badge (12/18 600). Nav chrome: navTitle (17/22 600 — AppHeader/DetailShell/MainTabShell titles, iOS HIG). Hero/stat numbers + emoji glyphs: displaySm (20/28 700), displayMd (28/36 700), displayLg (36/44 700). A role= + inline fontSize override is the anti-pattern this rule exists to prevent — if no role fits, the role belongs in text.tsx, not as a per-screen override.

The full role scale (11): pageTitle 24 · cardTitle 16 · navTitle 17 · cardBody 14 · cardMeta 13 · badge 12 · micro 10 · displaySm 20 · displayMd 28 · displayLg 36 · displayXl 48. Same hero number → same role across cards (displayMd for a screen protagonist, displaySm for a card-level hero stat). Enforced by @twomore/no-raw-fontsize-in-features (a fontWeight override for genuine state-based emphasis — winning ? '700' : '500' — is the sanctioned exception; a genuinely off-scale one-off, e.g. a 9px data-density tick, disables with a reason).

Icons

Icons: only @tamagui/lucide-icons. Always $textMuted or $textSubtle tint unless inside a primary/warning/error surface. Never tint icons green just to mark "my thing"

Use icons SPARINGLY — only where functional: navigation affordances (back/forward chevrons), status indicators (the live PulseDot), form-field leading icons. NEVER as decorative section/beat lead icons (a Calendar before a date, a Users before a count, a MapPin before a venue) and NOT inside content chips/badges by default — section content leads with typography, not icons. An emoji that encodes data (weather ☀️, the TMI traffic-light 🟢🟡🟠🔴) is preferred over a decorative lucide icon for at-a-glance meaning. When in doubt, drop the icon.

Navigation-chrome icons (back chevrons in AppHeader / DetailShell, header-right action icons in MainTabShell / DetailShell) → Icon size="xl" (24px). Use size="2xl" (28px) only when the icon stands alone as the user's primary recognition cue (canonical example: bell on home tab, also a notification entry point). In-content icons (form-field leading icons, list-row affordances, card icons, steppers) keep their context-appropriate sizes — xs / sm / md / lg per the scale documented in packages/ui/src/icon.tsx.

Icons render through the Icon wrapper — never raw <LucideIcon size={number}>. A raw numeric size bypasses the named scale (xs 12 · sm 14 · md 16 · lg 20 · xl 24 · 2xl 28); use <Icon component={X} size="md">. Enforced by @twomore/no-raw-icon-size-in-features (off-scale hero/onboarding icons opt out with an eslint-disable + reason).

Overlays (ModalPanel)

Bottom sheets are RETIRED from product surfaces (2026-06 Codex batch — AGENTS.md STYLE-3). Overlay/picker/confirm interactions use ModalPanel from @twomore/ui (packages/ui/src/modal-panel.tsx): a centered RN <Modal> (transparent, fade, statusBarTranslucent) with compound parts — ModalPanel.Overlay (full-screen backdrop, dismiss-on-press by default; per-instance dismissOnPress override, root-level dismissOnOverlayPress), ModalPanel.Frame (maxWidth 640, maxHeight 88%, built-in X close button — showCloseButton={false} to hide), ModalPanel.ScrollView. Hardware back always closes. There is deliberately NO drag handle and NO snap points — never recreate them (<ModalPanel.Handle> and the legacy modal/snapPoints/dismissOnSnapToBottom/animation props are lint errors). LAYOUT CONTRACT (the Frame is AUTO-HEIGHT, capped at 88% — learned from the 2026-06-12 "panels don't open fully" bug): content is Frame → [fixed header] → ModalPanel.ScrollView → [fixed footer as a NORMAL sibling]. NEVER flex={1} on the Frame's children (no resolved parent height → they collapse to 0); NEVER height/percentage-maxHeight on the ScrollView (percentages resolve against the auto-height Frame → 0) — the ScrollView bakes flexShrink: 1 (intrinsic when short; shrinks to the cap and scrolls when long; flex/height/maxHeight props on it are lint errors); NEVER position="absolute" footers over the scroll area (with an unconstrained ScrollView the frame clips at the cap, scroll never activates, and the footer permanently covers the bottom rows — the chip-picker bug). Keyboard handling (KeyboardAvoidingView) is baked into the root — text-input consumers need no local handling. The ONLY legal Tamagui Sheet is the dev panel (diagnostics-only); @gorhom/bottom-sheet stays forbidden in shared packages (native-only, crashes the web bundle). Enforced by @twomore/no-bottom-sheet-outside-dev-panel + @twomore/no-gorhom-bottom-sheet-in-shared. NOTE: many converted files keep their historical *-sheet.tsx names (manner-tags-sheet, score-call-sheet, playability-info-sheet, …) but are ModalPanel inside — don't infer the primitive from the filename.

Scrollable content inside the DEV PANEL's Tamagui Sheet (the one legal Sheet) → Sheet.ScrollView, never RN's bare ScrollView (drag-gesture integration — AGENTS.md STYLE-4). In product code use ModalPanel.ScrollView. useSheetBottomPadding is dev-panel-only.

ModalPanel.Frame defaults to $background (gray) — that IS the canonical overlay-frame surface (it sits on the dark $overlayBackdrop scrim, so it pops); don't restate it per-consumer, and don't override to $card.

Visual-consistency canon

Visual-consistency canon (codified 2026-06-23 after a full adversarial audit of every visual primitive). The single-source choices, so components don't drift from each other:

  • Icons render through the Icon wrapper — never raw <LucideIcon size={number}>. A raw numeric size bypasses the named scale (xs 12 · sm 14 · md 16 · lg 20 · xl 24 · 2xl 28); use <Icon component={X} size="md">. Enforced by @twomore/no-raw-icon-size-in-features (off-scale hero/onboarding icons opt out with an eslint-disable + reason).
  • Two muted-text tiers — $textSecondary (readable) vs $textTertiary (receded), both theme-aware. (Renamed 2026-06-23 from the ambiguous $textMuted/$textSubtle; the old base-token textSecondary/textTertiary that 8 files used were theme-INVARIANT hex — the rename consolidated everything onto the theme-aware tier and removed that trap.) $textSecondary (#455D49) = SECONDARY body copy + supporting metadata + nav-chrome icon tint (it's what the cardMeta role bakes). $textTertiary (#607565) = TERTIARY hints, placeholders, deliberately-quiet text. Putting $textTertiary on a cardMeta/cardBody Text is a "double-mute" that's almost always unintentional. Nav-affordance chevrons = $textTertiary (a receded secondary affordance); don't randomly alternate the two.
  • Dividers are <Divider /> (with inset="full" | "card") — never a hand-rolled <YStack height={1} backgroundColor="$borderSubtle"> or a borderBottomWidth/borderTopWidth inter-row separator. The lone exception is settings/InfoRow (a sanctioned iOS-grouped-list primitive). Vertical separators have no primitive yet — a bare 1px YStack width={1} is acceptable there. (5 stray borderTopWidth={1} sites in club-settings/info-section.tsx migrated to <Divider inset="card" /> in the 2026-07-19 drift-audit consolidation — InfoRow remains the one sanctioned exception, not a growing list of them.)
  • Hand-rolled selectable pills are retired — use SelectionChip. A bespoke Pressable-styled-as-a-pill for scope/filter selection is the same anti-pattern the Tabs & chips rule already names; 3 sites hand-rolling one were migrated onto SelectionChip in the 2026-07-19 consolidation. Don't reintroduce one — see the Tabs & chips SelectionChip rule in docs/canon/components.md.
  • Theme-invariance — NEVER hardcode a hex. No '#006633'-style fallback behind a useTheme() read; no color="white" (use $textInverse); chart series colors resolve via themeHex(theme, 'primary'|'success'|'error'|…), never a DEFAULT_COLORS = ['#147A45', …] literal (that leaks the Classic-Grass brand onto every other theme). Status colors stay theme-invariant via the status-badge palette.
  • ELO/trust tier ALWAYS renders via EloTierBadge / TrustTierBadge (emoji + $primarySubtle pill) — never an ad-hoc <Badge label={tierLabel(...)} variant={...}> (which renders an anonymous gray pill, the bug a dead tierBadgeVariant stub caused across the leaderboard + profile surfaces). getEmojiFontSize lives once in tier-badge-utils.ts.
  • ModalPanel.Frame defaults to $background (gray) — that IS the canonical overlay-frame surface (it sits on the dark $overlayBackdrop scrim, so it pops); don't restate it per-consumer, and don't override to $card.
  • Typography: every Text picks a role; no raw fontSize on a <Text> — enforced by @twomore/no-raw-fontsize-in-features (a fontWeight override for genuine state-based emphasis — winning ? '700' : '500' — is the sanctioned exception; a genuinely off-scale one-off, e.g. a 9px data-density tick, disables with a reason). The role scale (11): pageTitle 24 · cardTitle 16 · navTitle 17 · cardBody 14 · cardMeta 13 · badge 12 · micro 10 · displaySm 20 · displayMd 28 · displayLg 36 · displayXl 48. Same hero number → same role across cards (displayMd for a screen protagonist, displaySm for a card-level hero stat).

Render performance & animation

Render performance is React-Compiler-first (FOUNDATION). React Compiler v1.0 is ENABLED (app.config.jsexperiments.reactCompiler: true) — it auto-memoizes every component + hook at build time, so manual React.memo / useCallback / useMemo are NOT required on new code for re-render prevention. Write clean components that follow the Rules of React (no conditional hooks, no mutation during render, stable hook order); the compiler does the memoization. It bails out per-component on any Rules-of-React violation (safe — falls back to normal behavior), and runs only on app code (not node_modules). Healthcheck baseline: 755/755 components compile clean — keep it green by running npx react-compiler-healthcheck if you introduce an unusual hook/render pattern. Operational notes: New Architecture (Fabric) is on (RN 0.83 / Expo 55 default); the compiler is a pure JS-bundle transform (uses React 19's built-in react/compiler-runtime), so it ships via OTA — no native build needed. What the compiler does NOT replace (these stay required, they're architectural not memoization): list virtualization (FeedList/FlashList), useDeferredValue for heavy tab-content (via SegmentedTabs), and pause-on-blur (Protocol L). The existing manual-memoization rules below + the require-memoized-list-render-callbacks ESLint rule REMAIN as belt-and-suspenders (they also cover any component the compiler bails on) — don't rip them out, but don't treat hand-memoization as the primary mechanism either.

Mobile animation driver is @tamagui/animations-moti (Reanimated worklets, UI thread). Do not switch to @tamagui/animations-react-native — that puts all pressStyle and animation on the JS thread and causes stutter under concurrent renders. Web uses @tamagui/animations-css (GPU compositor) — do not touch.

Entrance animations live on the component itself, never on a wrapper. Use Tamagui's enterStyle + animation props directly on the component being revealed: <XStack animation="medium" enterStyle={{ opacity: 0, y: 4 }}>. Reduced-motion users: gate via usePrefersReducedMotion() and pass undefined for both props. Never re-introduce a MotionReveal/MotionStaggerItem-style wrapper component — those were removed because the wrapper participates in the parent's gap calculation as the measured child, AND the slide-up y transient creates a visible offset on first paint that doesn't appear on sibling surfaces rendering bare cards. The wrapper-vs-bare structural asymmetry is a real layout difference, not a perception bug. Canonical examples: AnimatedCount (animated value transitions); live-banner ScoreRow (entrance fade). Motion tokens are single-sourced in packages/ui/src/motion.ts — never hand-declare duration/easing constants. Two additive-motion surfaces now ship with built-in animation, both gated on useReducedMotion from @twomore/ui/motion.ts: ProgressBar (fill animates 0→ratio on mount + glides on data change via UI-thread scaleX worklet); DataSection (content cross-fades in from the skeleton state, eliminating pop-in). Deferred motion (grounded design decisions, not just technical): SegmentedTabs cross-fade — Material 3 fade-through is designed for UNRELATED content (e.g. bottom-nav between user-profile and shop), and that case is handled by our screen navigator. Our in-page SegmentedTabs switch RELATED views (scopes/filters of the same dataset, segmented controls) where instant switching via useDeferredValue + preload="all" is the deliberate felt-quality goal — adding a cross-fade would undercut the instant-switch architecture (and display:none pane toggling kills opacity transitions anyway). FlashList per-item stagger — confirmed by Shopify FlashList docs: views recycle, so per-item entrance animations re-fire on every scroll-into-view; a true initial-reveal stagger requires a recycling-aware reset and even then the re-fires aren't desirable. The only candidate non-virtualized list (the home feed) is independent self-gating cards with no list index, so stagger doesn't fit there either. Stagger genuinely fits short fixed .map lists, which aren't a high-value surface in this app.

See also

  • AGENTS.md — STYLE-1..4 (the machine-enforced styling subset: Tamagui-only, no bottom-sheets in product, Sheet.ScrollView in dev panel, no-gorhom in shared) and COMP-1/2 (component constraints).
  • Screen Blueprint — where these styling rules apply on each screen.
  • CLAUDE.md — orchestration core + the pointer index back to this doc.

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