Tamagui Patterns — TwoMore v2
Superseded (2026-07): folded into Tamagui & Styling Conventions. Kept for reference.
Status: Superseded
Concrete dos and don'ts for using Tamagui in this codebase. Update as we learn.
The Two APIs
Tamagui has two styling APIs. Use them for different things.
1. styled() — for reusable primitives
Use in packages/ui/src/* only. The compiler extracts these at build time.
// packages/ui/src/button.tsx
import { Button as TButton, styled } from 'tamagui';
export const Button = styled(TButton, {
name: 'Button',
variants: {
variant: {
primary: { backgroundColor: '$brand600', color: '$white' },
secondary: { backgroundColor: '$gray100', color: '$brand600' },
outline: { backgroundColor: 'transparent', borderColor: '$border' },
},
size: {
xs: { paddingHorizontal: '$2', paddingVertical: '$1', fontSize: '$2' },
sm: { paddingHorizontal: '$3', paddingVertical: '$2', fontSize: '$3' },
md: { paddingHorizontal: '$4', paddingVertical: '$3', fontSize: '$4' },
lg: { paddingHorizontal: '$5', paddingVertical: '$4', fontSize: '$5' },
},
shape: {
default: { borderRadius: '$3' },
pill: { borderRadius: '$7' },
circle: { borderRadius: '$7', aspectRatio: 1 },
},
} as const,
defaultVariants: {
variant: 'primary',
size: 'md',
shape: 'default',
},
});2. Inline props — for one-offs
Use in feature/screen code. These tokens resolve at render time but the compiler flattens static ones.
<Stack padding="$4" backgroundColor="$surface1" gap="$3">
<Text fontSize="$6" fontWeight="700" color="$text">
Hello
</Text>
</Stack>Don'ts
❌ Never use StyleSheet.create
Tokens don't resolve. Defeats the compiler.
// WRONG
const styles = StyleSheet.create({ card: { padding: 16 } });❌ Never use inline style={{}} with variables
Breaks compiler flattening.
// WRONG
<View style={{ padding: isLarge ? 20 : 16 }}>
// RIGHT
<Stack padding={isLarge ? '$5' : '$4'}>❌ Never wrap Pressable / TouchableOpacity with styled()
Conflicts with Tamagui's event handling and bypasses the canonical clickable surface rules. Use Button or Pressable from @twomore/ui. Feature code must not put onPress on Stack / Card or use raw React Native Pressable.
❌ Never define styled() outside packages/ui
The Babel plugin is configured with components: ['@twomore/ui'] — only those modules get compile-time extraction. Anywhere else falls back to runtime (slow).
If you need a one-off styled component in a feature package, use inline props instead.
❌ Never use JSX or function render props
Compiler deopts on these patterns:
// WRONG (compiler deopts)
<Button icon={<LucideIcon name="trophy" />} />
// RIGHT (string render prop stays optimized)
<Button iconName="trophy" />❌ Never use createStyledContext on primitives
It disables flattening. Only use on high-level compound components (Card, Dialog).
❌ Never use className or NativeWind syntax
v2 is pure Tamagui. className is a smell.
❌ Never mix dark-mode approaches
Only use <TamaguiProvider defaultTheme={colorScheme} /> at the root. Don't wrap subtrees in <Theme name="dark"> unless you're explicitly previewing a different theme (e.g., the theme picker itself).
Dos
✅ Use $token syntax everywhere
<Stack padding="$4" backgroundColor="$brand500" borderRadius="$3" />✅ Use shorthands for common props
Configured in tamagui.config.ts. p = padding, m = margin, bg = backgroundColor, jc = justifyContent, ai = alignItems, etc.
✅ Use variants for component flexibility
Never re-export multiple styled components. One component, many variants.
✅ Use media queries for responsive
<Stack padding="$4" $gtMd={{ padding: '$6' }} $lg={{ padding: '$8' }}>✅ Use theme switching via provider only
<TamaguiProvider config={config} defaultTheme={colorScheme === 'dark' ? 'dark' : 'light'}>✅ Use <Theme> wrappers for scoped color shifts
Only when you want a subtree to use a different theme (e.g., a warning card inside a neutral screen).
<Theme name="warning">
<Card>...</Card>
</Theme>✅ Animation: use the Reanimated driver
For our case (already using Reanimated for gestures), use @tamagui/animations-moti driver. Don't install two animation systems.
File Organization
packages/ui/src/
- One file per primitive:
button.tsx,card.tsx,badge.tsx,text.tsx,input.tsx - Barrel
index.tsre-exports all - Only
styled()definitions here - No business logic, no hooks beyond
useTheme
Feature packages (packages/features/*)
- Import from
@twomore/uifor primitives - Use inline props for layout
- Only use
styled()if absolutely required — but prefer moving to@twomore/uiif it's reusable
Token access
- Always via
$tokensyntax in props, never imported as JS values - Exception: when we need a color for React Native's
StatusBaror similar platform APIs. UseuseTheme()hook in that case.
Performance Gotchas
Compiler extraction
Compiler only extracts styled() from modules listed in Babel plugin components array. For TwoMore v2: ['@twomore/ui', 'tamagui', '@tamagui/lucide-icons'].
Dev mode
Set disableExtraction: process.env.NODE_ENV === 'development' in Babel plugin. Extraction slows HMR. Pay the cost in prod builds only.
Inline style props are mostly fine
The compiler flattens static inline props (padding="$4"). Dynamic ones (padding={x}) stay at runtime but are still fast.
Testing
Known issue with @testing-library/react-native
userEvent.click() doesn't fire correctly on Tamagui components. Use fireEvent.press().
Rendering in tests
Wrap in <TamaguiProvider config={config}> for any test that renders a Tamagui component. Share a renderWithProvider helper.
// packages/app/src/test-utils/render.tsx
import { render } from '@testing-library/react-native';
import { TamaguiProvider } from 'tamagui';
import config from '../tamagui.config';
export function renderWithProvider(ui: React.ReactElement) {
return render(<TamaguiProvider config={config}>{ui}</TamaguiProvider>);
}When to add a new primitive to @twomore/ui
Ask:
- Is this used in 2+ places? → Add as primitive
- Is this a variant of an existing primitive? → Add a variant
- Is this a one-off composition? → Keep in feature package as inline JSX
Never ship a new styled component in @twomore/ui without adding at least one usage — dead primitives rot.