Tamagui Core
Tamagui is a universal UI library for React Native and Expo that provides 100% parity between web and native with an optimizing compiler. This skill covers core styling, theming, and component patterns.
Quick Start with Expo Go
Installation
bunx create-expo-app@latest my-app -t expo-template-blank-typescript cd my-app bun add tamagui @tamagui/config
Configuration
Create tamagui.config.ts at the project root:
import { config } from '@tamagui/config/v4'
import { createTamagui } from 'tamagui'
export const tamaguiConfig = createTamagui(config)
export default tamaguiConfig
export type Conf = typeof tamaguiConfig
declare module 'tamagui' {
interface TamaguiCustomConfig extends Conf {}
}
Provider Setup
Wrap the app root with TamaguiProvider:
import { TamaguiProvider } from 'tamagui'
import tamaguiConfig from './tamagui.config'
export default function App() {
return (
<TamaguiProvider config={tamaguiConfig} defaultTheme="light">
{/* App content */}
</TamaguiProvider>
)
}
Babel Configuration (Optional but Recommended)
Update babel.config.js for compiler optimization:
module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
plugins: [
[
'@tamagui/babel-plugin',
{
components: ['tamagui'],
config: './tamagui.config.ts',
logTimings: true,
disableExtraction: process.env.NODE_ENV === 'development',
},
],
],
}
}
styled() Function
The styled() function creates typed, optimized components:
import { View, Text, styled } from 'tamagui'
export const Card = styled(View, {
backgroundColor: '$background',
borderRadius: '$4',
padding: '$4',
variants: {
elevated: {
true: {
shadowColor: '$shadowColor',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
},
size: {
small: { padding: '$2' },
medium: { padding: '$4' },
large: { padding: '$6' },
},
} as const,
defaultVariants: {
size: 'medium',
},
})
Variant Patterns
Boolean variants use true/false keys:
variants: {
centered: {
true: {
alignItems: 'center',
justifyContent: 'center',
},
},
}
Token-spread variants use ... prefix to map token scales:
variants: {
size: {
'...size': (size, { tokens }) => ({
width: tokens.size[size] ?? size,
height: tokens.size[size] ?? size,
}),
},
}
Functional variants receive the value and extras:
variants: {
width: (val) => ({ width: val }),
}
Property Order
Style property order matters for overrides. Properties defined later take precedence:
const Button = styled(View, {
backgroundColor: '$blue', // Can be overridden
...props, // Props override above
borderRadius: '$4', // Cannot be overridden by props
})
Tokens
Tokens are design system variables defined via createTokens():
import { createTokens } from 'tamagui'
const tokens = createTokens({
size: {
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
// ...
},
space: {
0: 0,
1: 4,
2: 8,
'-1': -4, // Negative space tokens
'-2': -8,
},
radius: {
0: 0,
1: 4,
2: 8,
full: 9999,
},
color: {
white: '#fff',
black: '#000',
primary: '#007AFF',
},
zIndex: {
0: 0,
1: 100,
2: 200,
},
})
Token Usage
Access tokens with $ prefix in style values:
<View padding="$4" backgroundColor="$primary" borderRadius="$2" />
Token categories auto-map to CSS properties:
- •
size→ width, height, minWidth, maxWidth, minHeight, maxHeight - •
space→ padding, margin, gap - •
radius→ borderRadius - •
color→ color, backgroundColor, borderColor - •
zIndex→ zIndex
Custom Token Categories
Define arbitrary token groups:
const tokens = createTokens({
icon: {
small: 16,
medium: 24,
large: 32,
},
})
// Usage: width="$icon.small"
Themes
Themes provide contextual styling that changes based on the React tree:
import { createTamagui } from 'tamagui'
const config = createTamagui({
tokens,
themes: {
light: {
background: '#fff',
color: '#000',
primary: '$color.primary',
},
dark: {
background: '#000',
color: '#fff',
primary: '$color.primary',
},
// Sub-themes follow pattern: parentTheme_subTheme
light_accent: {
background: '$color.primary',
color: '#fff',
},
dark_accent: {
background: '$color.primary',
color: '#000',
},
},
})
Theme Component
Change themes anywhere in the component tree:
import { Theme, YStack, Text } from 'tamagui'
function App() {
return (
<Theme name="dark">
<YStack backgroundColor="$background">
<Text color="$color">Dark theme text</Text>
<Theme name="accent">
{/* Resolves to "dark_accent" */}
<Text color="$color">Accent theme text</Text>
</Theme>
<Theme inverse>
{/* Switches to "light" */}
<Text>Inverted theme text</Text>
</Theme>
</YStack>
</Theme>
)
}
useTheme Hook
Access theme values programmatically:
import { useTheme } from 'tamagui'
function Component() {
const theme = useTheme()
// Use .val for raw values
const bgColor = theme.background.val
// Use .get() for platform-optimized access (CSS var on web)
const optimizedBg = theme.background.get()
return <View style={{ backgroundColor: bgColor }} />
}
Media Queries & Responsive Design
Configuration
Define breakpoints in config:
const config = createTamagui({
media: {
xs: { maxWidth: 660 },
sm: { maxWidth: 860 },
md: { maxWidth: 980 },
lg: { maxWidth: 1120 },
xl: { minWidth: 1121 },
short: { maxHeight: 820 },
tall: { minHeight: 821 },
hoverNone: { hover: 'none' },
pointerCoarse: { pointer: 'coarse' },
},
})
Inline Media Props
Apply responsive styles with $ prefix:
<YStack
padding="$4"
$sm={{ padding: '$2' }}
$md={{ padding: '$3' }}
flexDirection="column"
$lg={{ flexDirection: 'row' }}
/>
useMedia Hook
Access breakpoints programmatically:
import { useMedia } from 'tamagui'
function Component() {
const media = useMedia()
return (
<YStack
backgroundColor={media.sm ? '$blue' : '$green'}
{...(media.lg && { padding: '$6' })}
>
{media.sm ? <MobileView /> : <DesktopView />}
</YStack>
)
}
Performance note: useMedia uses proxies to track accessed keys and only re-renders when those specific breakpoints change.
Animations
Animation Drivers
Tamagui supports three swappable animation drivers:
- •CSS Animations (web-optimized):
@tamagui/animations-css - •React Native Animated:
@tamagui/animations-react-native - •Reanimated:
@tamagui/animations-moti
Configuration
import { createAnimations } from '@tamagui/animations-css'
const animations = createAnimations({
fast: 'ease-in 150ms',
medium: 'ease-in 300ms',
slow: 'ease-in 450ms',
bouncy: 'cubic-bezier(0.175, 0.885, 0.32, 1.275) 300ms',
})
const config = createTamagui({
animations,
// ...
})
For spring animations with React Native Animated:
import { createAnimations } from '@tamagui/animations-react-native'
const animations = createAnimations({
fast: {
type: 'spring',
damping: 20,
mass: 1.2,
stiffness: 250,
},
slow: {
type: 'spring',
damping: 15,
stiffness: 100,
},
})
Using Animations
<YStack
animation="fast"
enterStyle={{ opacity: 0, scale: 0.9 }}
opacity={1}
scale={1}
hoverStyle={{ scale: 1.05 }}
pressStyle={{ scale: 0.95 }}
/>
AnimatePresence for Exit Animations
import { AnimatePresence } from 'tamagui'
function Modal({ isOpen, children }) {
return (
<AnimatePresence>
{isOpen && (
<YStack
key="modal"
animation="medium"
enterStyle={{ opacity: 0, y: 20 }}
exitStyle={{ opacity: 0, y: -20 }}
opacity={1}
y={0}
>
{children}
</YStack>
)}
</AnimatePresence>
)
}
Important: Once animation prop is added, keep it present. Use null, false, or undefined to disable rather than removing the prop conditionally.
Pseudo-State Styles
Apply interactive state styles:
<Button
backgroundColor="$blue"
hoverStyle={{ backgroundColor: '$blueHover' }}
pressStyle={{ backgroundColor: '$bluePress', scale: 0.98 }}
focusStyle={{ outlineWidth: 2, outlineColor: '$blueFocus' }}
focusVisibleStyle={{ outlineWidth: 2 }}
disabledStyle={{ opacity: 0.5 }}
/>
Platform-Specific Styles
Target specific platforms:
<YStack
padding="$4"
$platform-native={{ padding: '$2' }}
$platform-web={{ cursor: 'pointer' }}
$platform-ios={{ paddingTop: '$6' }}
$platform-android={{ elevation: 4 }}
/>
Groups (Parent-Child Styling)
Enable parent-based child styling:
<YStack group="card" backgroundColor="$background">
<Text
color="$color"
$group-card-hover={{ color: '$primary' }}
$group-card-press={{ opacity: 0.8 }}
>
Hover the card to change my color
</Text>
</YStack>
Component Composition with Context
Create compound components with shared context:
import { createStyledContext, styled, withStaticProperties } from 'tamagui'
const ButtonContext = createStyledContext({
size: '$4' as SizeTokens,
})
const ButtonFrame = styled(View, {
name: 'Button',
context: ButtonContext,
backgroundColor: '$primary',
borderRadius: '$4',
variants: {
size: {
'...size': (size, { tokens }) => ({
padding: tokens.space[size],
}),
},
},
})
const ButtonText = styled(Text, {
name: 'ButtonText',
context: ButtonContext,
color: '$color',
variants: {
size: {
'...size': (size, { tokens }) => ({
fontSize: tokens.size[size],
}),
},
},
})
export const Button = withStaticProperties(ButtonFrame, {
Props: ButtonContext.Provider,
Text: ButtonText,
})
// Usage:
<Button size="$4">
<Button.Text>Click me</Button.Text>
</Button>
The asChild Pattern
The asChild prop passes 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/Tooltip triggers
// Dialog trigger with custom button
<Dialog.Trigger asChild>
<Button theme="red">Delete</Button>
</Dialog.Trigger>
// Popover trigger
<Popover.Trigger asChild>
<Button icon={<MoreHorizontal />} circular />
</Popover.Trigger>
Requirements for asChild targets:
- •Must accept and forward style props
- •Must accept
onPress/onClickhandlers - •Must be a valid React element
Size Scaling System
Tamagui's size tokens scale multiple properties from a single size prop:
const ScaledComponent = styled(View, {
name: 'ScaledComponent',
variants: {
size: {
'...size': (size, { tokens }) => ({
height: tokens.size[size] ?? size,
paddingHorizontal: tokens.space[size],
borderRadius: tokens.radius[size] ?? tokens.radius.$4,
}),
},
} as const,
})
// size="$2" → small height, small padding, small radius
// size="$6" → large height, large padding, large radius
Size Token Convention:
| Token | Use Case |
|---|---|
$2 | Compact/dense UI |
$3 | Small elements |
$4 | Default (recommended as defaultVariant) |
$5 | Large elements |
$6 | Extra large/hero |
Token Spread Patterns:
// Spread size tokens
'...size': (size, { tokens }) => ({ ... })
// Spread space tokens
'...space': (space, { tokens }) => ({ ... })
// Spread radius tokens
'...radius': (radius, { tokens }) => ({ ... })
ThemeableStack
For components that need theme-aware backgrounds with hover/press states:
import { ThemeableStack, styled } from 'tamagui'
const InteractiveCard = styled(ThemeableStack, {
name: 'InteractiveCard',
padding: '$4',
borderRadius: '$4',
// ThemeableStack provides these automatically
// backgroundColor: '$background',
// hoverStyle: { backgroundColor: '$backgroundHover' },
// pressStyle: { backgroundColor: '$backgroundPress' },
})
Used by: Card, Checkbox, Group, ListItem, and other interactive containers.
Cross-Platform Patterns
Adapt Component
Convert between components based on platform:
import { Adapt, Dialog, Sheet } from 'tamagui'
function ResponsiveDialog({ children }) {
return (
<Dialog>
{/* On mobile, convert Dialog.Content to Sheet */}
<Adapt when="sm" platform="touch">
<Sheet modal dismissOnSnapToBottom>
<Sheet.Frame padding="$4">
<Adapt.Contents /> {/* Renders Dialog content here */}
</Sheet.Frame>
<Sheet.Overlay />
</Sheet>
</Adapt>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>{children}</Dialog.Content>
</Dialog.Portal>
</Dialog>
)
}
Platform-Specific Styles
<YStack
padding="$4"
$platform-web={{ cursor: 'pointer' }}
$platform-native={{ padding: '$2' }}
$platform-ios={{ paddingTop: '$6' }} // Safe area
$platform-android={{ elevation: 4 }}
/>
Web-Only Features
Some features only work on web:
- •
FocusScope- Focus trapping - •
cursorproperty - •CSS animations with
@tamagui/animations-css - •
outlineStyle,outlineOffset
Native Adaptations
- •Use
Sheetinstead ofDialogon mobile for better UX - •
elevationfor Android shadows - •Gesture handlers for swipe interactions
Accessibility Quick Reference
Required Elements
| Component | Requirements |
|---|---|
| Dialog | Must have Dialog.Title and Dialog.Description |
| Button | Text content or aria-label for icon-only |
| Input | Associated Label with matching htmlFor/id |
| Image | alt prop or aria-label |
Focus Management
// FocusScope for dialogs (web)
import { FocusScope } from 'tamagui'
<Dialog.Content>
<FocusScope trapped restoreFocus>
{/* Focus trapped inside, restored on close */}
</FocusScope>
</Dialog.Content>
Common Patterns
// Icon-only button
<Button icon={<MenuIcon />} aria-label="Open menu" />
// Form field with error
<Label htmlFor="email">Email</Label>
<Input
id="email"
aria-describedby={error ? 'email-error' : undefined}
aria-invalid={!!error}
/>
{error && <Text id="email-error">{error}</Text>}
// Loading state
<Button aria-busy={isLoading} disabled={isLoading}>
{isLoading ? <Spinner /> : 'Submit'}
</Button>
forceMount for Animations
Keep elements mounted for exit animations:
<Dialog>
<Dialog.Portal forceMount>
<AnimatePresence>
{open && (
<Dialog.Content
key="content"
animation="medium"
enterStyle={{ opacity: 0, y: 10 }}
exitStyle={{ opacity: 0, y: 10 }}
>
{children}
</Dialog.Content>
)}
</AnimatePresence>
</Dialog.Portal>
</Dialog>
Modular Design
For comprehensive guidance on component architecture, file organization, and building component libraries, see the modular-design skill. It covers:
- •Atomic design structure (atoms, molecules, organisms)
- •Compound component patterns
- •File naming and export conventions
- •Component creation checklists
Best Practices
- •Use tokens consistently - Define all design values as tokens for maintainability
- •Prefer inline styles for variants - The compiler optimizes these to static CSS
- •Use useMedia sparingly - Prefer inline
$sm={{ }}syntax when possible - •Keep themes focused on colors - Other values should be tokens
- •Use enterStyle for mount animations - SSR-safe and compiler-optimized
- •Leverage the compiler - Add
// debugcomment to see optimization output - •Use groups for hover effects - More performant than manual state management
- •Clear cache after config changes - Run
bunx expo start -c - •Use full property names - Avoid shorthands like
ai,jc,br,p,px,pyas they may not be typed correctly. UsealignItems,justifyContent,borderRadius,padding,paddingHorizontal,paddingVerticalinstead - •Use textProps for Button text styling -
textTransformand other text styles don't apply to Button's internal text viastyled(). UsetextProps={{ style: {...} }} - •Use useTheme() for dynamic theme values - When passing theme tokens to non-Tamagui components (like icons), access the raw value via
theme.tokenName?.val
Common Patterns
Responsive Stack Direction
<XStack
flexDirection="column"
$md={{ flexDirection: 'row' }}
gap="$4"
>
<Item />
<Item />
</XStack>
Theme-Aware Component
const ThemedCard = styled(YStack, {
backgroundColor: '$background',
borderColor: '$borderColor',
borderWidth: 1,
$theme-dark: {
borderColor: '$gray800',
},
$theme-light: {
borderColor: '$gray200',
},
})
Accessible Button
const AccessibleButton = styled(View, {
tag: 'button',
role: 'button',
backgroundColor: '$primary',
padding: '$3',
borderRadius: '$2',
cursor: 'pointer',
hoverStyle: { backgroundColor: '$primaryHover' },
pressStyle: { backgroundColor: '$primaryPress' },
focusVisibleStyle: {
outlineWidth: 2,
outlineColor: '$primaryFocus',
outlineStyle: 'solid',
},
})
References
For detailed API documentation, see the references folder:
- •
references/components.md- UI component catalog - •
references/config-options.md- Full configuration reference - •
references/troubleshooting.md- Common issues and solutions