| name | modular-design |
| description | Guide for creating modular, reusable UI components with proper architecture. Use when creating new UI components, organizing a component library, implementing design system patterns, or building reusable UI building blocks. Applies to tasks involving component architecture, atomic design, compound components, or styling abstractions. |
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:
import { Text, styled } from 'tamagui'
export const Label = styled(Text, {
name: 'Label',
fontSize: '$2',
fontWeight: '500',
color: '$color',
variants: {
required: {
true: {
},
},
error: {
true: {
color: '$red10',
},
},
} as const,
})
Molecules
Combinations of atoms that form functional units:
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:
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:
import { createStyledContext, styled, withStaticProperties, YStack, Text } from 'tamagui'
import type { SizeTokens } from 'tamagui'
const CardContext = createStyledContext({
size: '$4' as SizeTokens,
})
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',
},
})
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',
})
export const Card = withStaticProperties(CardFrame, {
Props: CardContext.Provider,
Header: CardHeader,
Title: CardTitle,
Content: CardContent,
Footer: CardFooter,
})
The asChild Pattern
Pass styles and behavior to a child component:
import { Button } from 'tamagui'
import { Link } from 'expo-router'
<Button asChild>
<Link href="/settings">Settings</Link>
</Button>
How it works:
asChild clones the child element
- Merges parent's styles and props onto child
- Child must accept
onPress/onClick and style props
import { Pressable } from 'react-native'
import { styled, GetProps } from 'tamagui'
const StyledPressable = styled(Pressable, {
})
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 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: {
size: {
'...size': (size, { tokens }) => ({
padding: tokens.space[size],
fontSize: tokens.fontSize[size],
}),
},
width: (val) => ({ width: val }),
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
export { Button } from './Button'
export { Input } from './Input'
export { Label } from './Label'
export type { ButtonProps, InputProps, LabelProps } from './types'
export * from './atoms'
export * from './molecules'
export * from './organisms'
Named vs Default Exports
export function Button() {}
export default Button
export default function HomeScreen() {}
Styling Best Practices
Use Tokens Consistently
<View padding={16} backgroundColor="#fff" />
<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>
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>Are you sure?</Dialog.Description>
</Dialog>
<Label htmlFor="email">Email</Label>
<Input id="email" />
<Button icon={<MenuIcon />} aria-label="Open menu" />
Focus Management
import { FocusScope } from 'tamagui'
<FocusScope trapped restoreFocus>
<Dialog.Content>...</Dialog.Content>
</FocusScope>
References
For detailed patterns and checklists, see: