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.createwith Tamagui — tokens don't resolve - Never use inline
style={{}}with variables — breaks compiler flattening. Use variants. - Never define
styled()outsidepackages/ui— compiler won't extract it - Never use
classNameor 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:FeedListDefaultSeparator 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",MainTabShelltitle-rowpaddingBottom="$4".$5(20) — hero card padding (size="hero")$6(24) — section ↔ section divider, scroll bottom buffer$8(40) — page-level breathing room (e.g.paddingBottomon 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 constants — width / 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
Iconwrapper — never raw<LucideIcon size={number}>. A raw numericsizebypasses 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 aneslint-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-tokentextSecondary/textTertiarythat 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 thecardMetarole bakes).$textTertiary(#607565) = TERTIARY hints, placeholders, deliberately-quiet text. Putting$textTertiaryon acardMeta/cardBodyText 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 />(withinset="full" | "card") — never a hand-rolled<YStack height={1} backgroundColor="$borderSubtle">or aborderBottomWidth/borderTopWidthinter-row separator. The lone exception issettings/InfoRow(a sanctioned iOS-grouped-list primitive). Vertical separators have no primitive yet — a bare 1pxYStack width={1}is acceptable there. (5 strayborderTopWidth={1}sites inclub-settings/info-section.tsxmigrated to<Divider inset="card" />in the 2026-07-19 drift-audit consolidation —InfoRowremains the one sanctioned exception, not a growing list of them.) - Hand-rolled selectable pills are retired — use
SelectionChip. A bespokePressable-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 ontoSelectionChipin the 2026-07-19 consolidation. Don't reintroduce one — see the Tabs & chipsSelectionChiprule indocs/canon/components.md. - Theme-invariance — NEVER hardcode a hex. No
'#006633'-style fallback behind auseTheme()read; nocolor="white"(use$textInverse); chart series colors resolve viathemeHex(theme, 'primary'|'success'|'error'|…), never aDEFAULT_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 +$primarySubtlepill) — never an ad-hoc<Badge label={tierLabel(...)} variant={...}>(which renders an anonymous gray pill, the bug a deadtierBadgeVariantstub caused across the leaderboard + profile surfaces).getEmojiFontSizelives once intier-badge-utils.ts. ModalPanel.Framedefaults to$background(gray) — that IS the canonical overlay-frame surface (it sits on the dark$overlayBackdropscrim, so it pops); don't restate it per-consumer, and don't override to$card.- Typography: every
Textpicks a role; no rawfontSizeon a<Text>— enforced by@twomore/no-raw-fontsize-in-features(afontWeightoverride 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):pageTitle24 ·cardTitle16 ·navTitle17 ·cardBody14 ·cardMeta13 ·badge12 ·micro10 ·displaySm20 ·displayMd28 ·displayLg36 ·displayXl48. Same hero number → same role across cards (displayMdfor a screen protagonist,displaySmfor a card-level hero stat).
Render performance & animation
Render performance is React-Compiler-first (FOUNDATION). React Compiler v1.0 is ENABLED (app.config.js → experiments.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.