Skip to content

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.

tsx
// 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.

tsx
<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.

tsx
// WRONG
const styles = StyleSheet.create({ card: { padding: 16 } });

❌ Never use inline style={{}} with variables ​

Breaks compiler flattening.

tsx
// 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:

tsx
// 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 ​

tsx
<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 ​

tsx
<Stack padding="$4" $gtMd={{ padding: '$6' }} $lg={{ padding: '$8' }}>

✅ Use theme switching via provider only ​

tsx
<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).

tsx
<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.ts re-exports all
  • Only styled() definitions here
  • No business logic, no hooks beyond useTheme

Feature packages (packages/features/*) ​

  • Import from @twomore/ui for primitives
  • Use inline props for layout
  • Only use styled() if absolutely required — but prefer moving to @twomore/ui if it's reusable

Token access ​

  • Always via $token syntax in props, never imported as JS values
  • Exception: when we need a color for React Native's StatusBar or similar platform APIs. Use useTheme() 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.

tsx
// 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:

  1. Is this used in 2+ places? → Add as primitive
  2. Is this a variant of an existing primitive? → Add a variant
  3. 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.

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