Modular Design Patterns
This skill provides guidance for building beautiful, maintainable UI components using atomic design principles and Tamagui's composition patterns. It covers component architecture, file organization, and reusable patterns.
Core Philosophy
- •Composition over inheritance - Build complex UIs from simple, composable pieces
- •Single responsibility - Each component does one thing well
- •Props down, events up - Data flows predictably
- •Style at the edges - Keep logic and styling separate
- •Progressive disclosure - Simple defaults, advanced options available
Atomic Design Structure
Organize components into layers based on complexity:
components/ ├── atoms/ # Basic building blocks (Button, Input, Text) ├── molecules/ # Simple combinations (FormField, SearchBar) ├── organisms/ # Complex sections (Header, Card, Form) ├── templates/ # Page layouts (MainLayout, AuthLayout) └── index.ts # Public exports
Atoms
Smallest building blocks that can't be broken down further:
// components/atoms/Label.tsx
import { Text, styled } from 'tamagui'
export const Label = styled(Text, {
name: 'Label',
fontSize: '$2',
fontWeight: '500',
color: '$color',
variants: {
required: {
true: {
// Add asterisk via ::after on web, or handle in component
},
},
error: {
true: {
color: '$red10',
},
},
} as const,
})
Molecules
Combinations of atoms that form functional units:
// components/molecules/FormField.tsx
import { YStack } from 'tamagui'
import { Label } from '../atoms/Label'
import { Input } from '../atoms/Input'
import { ErrorMessage } from '../atoms/ErrorMessage'
interface FormFieldProps {
label: string
error?: string
required?: boolean
children: React.ReactNode
}
export function FormField({ label, error, required, children }: FormFieldProps) {
return (
<YStack gap="$2">
<Label required={required}>{label}</Label>
{children}
{error && <ErrorMessage>{error}</ErrorMessage>}
</YStack>
)
}
Organisms
Complete, self-contained UI sections:
// components/organisms/LoginForm.tsx
import { YStack, Button } from 'tamagui'
import { FormField } from '../molecules/FormField'
import { Input } from '../atoms/Input'
export function LoginForm({ onSubmit, isLoading }) {
return (
<YStack gap="$4" padding="$4">
<FormField label="Email" required>
<Input placeholder="email@example.com" />
</FormField>
<FormField label="Password" required>
<Input secureTextEntry placeholder="Password" />
</FormField>
<Button onPress={onSubmit} disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</YStack>
)
}
Compound Components
Build components with sub-components that work together via context:
// components/organisms/Card/index.tsx
import { createStyledContext, styled, withStaticProperties, YStack, Text } from 'tamagui'
import type { SizeTokens } from 'tamagui'
// 1. Create shared context
const CardContext = createStyledContext({
size: '$4' as SizeTokens,
})
// 2. Create styled frame
const CardFrame = styled(YStack, {
name: 'Card',
context: CardContext,
backgroundColor: '$background',
borderRadius: '$4',
borderWidth: 1,
borderColor: '$borderColor',
overflow: 'hidden',
variants: {
size: {
'...size': (size, { tokens }) => ({
padding: tokens.space[size],
}),
},
elevated: {
true: {
shadowColor: '$shadowColor',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
},
} as const,
defaultVariants: {
size: '$4',
},
})
// 3. Create sub-components
const CardHeader = styled(YStack, {
name: 'CardHeader',
context: CardContext,
paddingBottom: '$2',
borderBottomWidth: 1,
borderBottomColor: '$borderColor',
})
const CardTitle = styled(Text, {
name: 'CardTitle',
context: CardContext,
fontSize: '$5',
fontWeight: '600',
color: '$color',
})
const CardContent = styled(YStack, {
name: 'CardContent',
context: CardContext,
paddingTop: '$2',
})
const CardFooter = styled(YStack, {
name: 'CardFooter',
context: CardContext,
paddingTop: '$4',
borderTopWidth: 1,
borderTopColor: '$borderColor',
flexDirection: 'row',
justifyContent: 'flex-end',
gap: '$2',
})
// 4. Export with static properties
export const Card = withStaticProperties(CardFrame, {
Props: CardContext.Provider,
Header: CardHeader,
Title: CardTitle,
Content: CardContent,
Footer: CardFooter,
})
// Usage:
// <Card size="$4" elevated>
// <Card.Header>
// <Card.Title>Title</Card.Title>
// </Card.Header>
// <Card.Content>Content here</Card.Content>
// <Card.Footer>
// <Button>Action</Button>
// </Card.Footer>
// </Card>
The asChild Pattern
Pass styles and behavior to a child component:
import { Button } from 'tamagui'
import { Link } from 'expo-router'
// Button styles apply to Link
<Button asChild>
<Link href="/settings">Settings</Link>
</Button>
// Common uses:
// - Wrapping navigation links with styled buttons
// - Applying trigger styles to custom elements
// - Dialog/Popover triggers
How it works:
- •
asChildclones the child element - •Merges parent's styles and props onto child
- •Child must accept
onPress/onClickand style props
// Custom component supporting asChild
import { Pressable } from 'react-native'
import { styled, GetProps } from 'tamagui'
const StyledPressable = styled(Pressable, {
// styles...
})
type Props = GetProps<typeof StyledPressable> & {
asChild?: boolean
children: React.ReactNode
}
export function CustomButton({ asChild, children, ...props }: Props) {
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, props)
}
return <StyledPressable {...props}>{children}</StyledPressable>
}
Size Scaling System
Tamagui's size system scales multiple properties from a single prop:
const ScaledButton = styled(View, {
name: 'ScaledButton',
variants: {
size: {
'...size': (size, { tokens }) => {
const sizeValue = tokens.size[size] ?? size
return {
height: sizeValue,
paddingHorizontal: tokens.space[size],
borderRadius: tokens.radius[size] ?? tokens.radius.$4,
}
},
},
} as const,
defaultVariants: {
size: '$4',
},
})
// size="$2" → small height, small padding, small radius
// size="$6" → large height, large padding, large radius
Size token convention:
| Token | Typical Use |
|---|---|
$2 | Compact/dense UI |
$3 | Small elements |
$4 | Default/medium (use as default) |
$5 | Large elements |
$6 | Extra large/hero |
Variant Patterns
Boolean Variants
variants: {
disabled: {
true: {
opacity: 0.5,
pointerEvents: 'none',
},
},
active: {
true: {
backgroundColor: '$primary',
color: '$background',
},
},
} as const
Enum Variants
variants: {
variant: {
solid: {
backgroundColor: '$primary',
color: '$background',
},
outline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '$primary',
color: '$primary',
},
ghost: {
backgroundColor: 'transparent',
color: '$primary',
},
},
} as const
Functional Variants
variants: {
// Spread tokens with custom logic
size: {
'...size': (size, { tokens }) => ({
padding: tokens.space[size],
fontSize: tokens.fontSize[size],
}),
},
// Direct value mapping
width: (val) => ({ width: val }),
// Conditional logic
truncate: (lines: number) => ({
numberOfLines: lines,
overflow: 'hidden',
}),
} as const
Component File Structure
Single Component
components/atoms/Button/ ├── index.tsx # Main component + exports ├── Button.tsx # Component implementation (if complex) ├── Button.test.tsx # Tests └── types.ts # TypeScript types (if many)
Compound Component
components/organisms/Dialog/ ├── index.tsx # Main export with withStaticProperties ├── DialogContext.tsx # Shared context ├── DialogFrame.tsx # Main container ├── DialogTitle.tsx # Title sub-component ├── DialogContent.tsx # Content sub-component ├── DialogActions.tsx # Actions sub-component └── types.ts # Shared types
Export Patterns
Barrel Exports
// components/atoms/index.ts
export { Button } from './Button'
export { Input } from './Input'
export { Label } from './Label'
export type { ButtonProps, InputProps, LabelProps } from './types'
// components/index.ts
export * from './atoms'
export * from './molecules'
export * from './organisms'
Named vs Default Exports
// Prefer named exports for components
export function Button() {} // Good
export default Button // Avoid
// Exception: page components in Expo Router
export default function HomeScreen() {} // Required by router
Styling Best Practices
Use Tokens Consistently
// Wrong - hardcoded values
<View padding={16} backgroundColor="#fff" />
// Correct - token references
<View padding="$4" backgroundColor="$background" />
Responsive Design
<YStack
padding="$4"
$sm={{ padding: '$2' }}
flexDirection="column"
$md={{ flexDirection: 'row' }}
/>
Pseudo-States
<Button
backgroundColor="$primary"
hoverStyle={{ backgroundColor: '$primaryHover' }}
pressStyle={{ backgroundColor: '$primaryPress', scale: 0.98 }}
focusVisibleStyle={{ outlineWidth: 2, outlineColor: '$primaryFocus' }}
/>
Accessibility Patterns
Required Elements
// Dialog must have title and description
<Dialog>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>Are you sure?</Dialog.Description>
</Dialog>
// Form inputs need labels
<Label htmlFor="email">Email</Label>
<Input id="email" />
// Icon-only buttons need accessible label
<Button icon={<MenuIcon />} aria-label="Open menu" />
Focus Management
// Use FocusScope for dialogs (web)
import { FocusScope } from 'tamagui'
<FocusScope trapped restoreFocus>
<Dialog.Content>...</Dialog.Content>
</FocusScope>
References
For detailed patterns and checklists, see:
- •references/structure.md - File organization patterns
- •references/checklist.md - Component creation checklist