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.