
Tamagui Best Practices
- 340 installs
- 52 repo stars
- Updated June 24, 2026
- 0xbigboss/claude-code
tamagui-best-practices is an agent skill that teaches Tamagui patterns for shared React Native and web UIs so developers building cross-platform product screens follow performant styling, theming, and component conventio
About
tamagui-best-practices is an agent skill from 0xbigboss/claude-code that encodes Tamagui conventions for building shared React Native and web interfaces. It guides agents through cross-platform screens, design tokens, theme setup, and performant styled components so one Tamagui codebase renders consistently on iOS, Android, and web. Developers reach for tamagui-best-practices when implementing product UI with Tamagui instead of maintaining separate React DOM and React Native style systems. The skill reduces inconsistent theming, ad-hoc styling, and platform-specific UI drift during feature development.
- cross-platform UI
- design tokens
- styled components
- performance
- theming
Tamagui Best Practices by the numbers
- 340 all-time installs (skills.sh)
- Ranked #705 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
npx skills add https://github.com/0xbigboss/claude-code --skill tamagui-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 340 |
|---|---|
| repo stars | ★ 52 |
| Last updated | June 24, 2026 |
| Repository | 0xbigboss/claude-code ↗ |
How do you build shared React Native and web UIs with Tamagui?
Build shared React Native and web UIs with Tamagui when agents implement cross-platform screens, themes, tokens, and performant styled components for product interfaces.
Who is it for?
Frontend developers shipping cross-platform React Native and web product UIs with a single Tamagui design system.
Skip if: Projects using only React DOM, only bare React Native StyleSheet, or a different cross-platform UI kit like NativeWind without Tamagui.
When should I use this skill?
The user asks to build or refactor Tamagui screens, themes, tokens, or styled components for React Native and web.
What you get
Cross-platform Tamagui screens, theme token definitions, and performant styled component implementations.
- Tamagui screen components
- theme token config
- styled component patterns
Files
Tamagui Best Practices
Tamagui v1.x patterns beyond fundamentals: Config v4, compiler optimization, compound components, and gotchas.
Reference Files — Read Before Writing Code
| Context | File | What it covers |
|---|---|---|
| Dialog, Sheet, modal overlays | @DIALOG_PATTERNS.md | Adapt component, accessibility |
| Form, Input, Label, validation | @FORM_PATTERNS.md | zod integration |
| Animations, transitions | @ANIMATION_PATTERNS.md | drivers, enterStyle/exitStyle |
| Popover, Tooltip, Select | @OVERLAY_PATTERNS.md | overlay primitives |
| Compiler optimization | @COMPILER_PATTERNS.md | what the compiler can/cannot flatten |
| Design tokens, theming | @DESIGN_SYSTEM.md | palette, token structure |
Config v4
Minimal setup with @tamagui/config/v4. Add styleCompat: 'react-native' for new projects to align flexBasis with React Native behavior:
import { defaultConfig } from '@tamagui/config/v4'
import { createTamagui } from 'tamagui'
export const config = createTamagui({
...defaultConfig,
settings: { ...defaultConfig.settings, styleCompat: 'react-native' },
})
declare module 'tamagui' {
interface TamaguiCustomConfig extends typeof config {}
}For custom themes use createThemes with palette/accent/childrenThemes — see @DESIGN_SYSTEM.md.
Compiler Optimization Rules
- Use
styled()variants instead of inline conditionals — dynamic values break flattening. - Avoid
style={{ ... }}with variables; use variant props instead. - The
contextpattern (createStyledContext) disables compiler flattening — use for higher-level components (Button, Card), not primitives.
// BAD — breaks compiler
<View backgroundColor={isDark ? '$gray1' : '$gray12'} />
// GOOD — use variants
const Box = styled(View, {
variants: {
dark: { true: { backgroundColor: '$gray1' }, false: { backgroundColor: '$gray12' } },
},
})styled() vs Inline
styled(): reusable components, variant-driven behavior, compiler-optimizable primitives.- Inline props: one-off layout adjustments on already-styled components.
- Always use
as constonvariantsobjects (TypeScript limitation until inferred const generics).
Key Gotchas
Prop order determines override priority — props after a spread cannot be overridden by callers:
// width is locked; backgroundColor can be overridden
<View backgroundColor="$red10" {...props} width={200} />Variant order matters — later props win:
<Component scale={3} huge /> // scale = 3 (scale listed first)
<Component huge scale={3} /> // scale = 2 (huge overrides, comes first in variants)Use `.styleable()` when wrapping styled components — preserves variant inheritance:
const CorrectWrapper = StyledText.styleable((props, ref) => (
<StyledText ref={ref} {...props} />
))`accept` prop for non-standard token resolution (SVG fill/stroke, contentContainerStyle):
const StyledSVG = styled(SVG, {}, { accept: { fill: 'color', stroke: 'color' } as const })Import consistency — tamagui, @tamagui/core, and @tamagui/button are different packages; pick one approach per project.
Never mix RN StyleSheet with Tamagui — StyleSheet values don't resolve tokens.
Platform branching for Dialog/Sheet — use Adapt instead of Platform.OS checks (see @DIALOG_PATTERNS.md).
Quick Reference
Config v4 shorthands: bg backgroundColor, p padding, m margin, w width, h height, br borderRadius
Media breakpoints: $xs 660px, $sm 800px, $md 1020px, $lg 1280px, $xl 1420px
Animation drivers: css (web, default), react-native-reanimated (native, required)
Token `$` prefix: use in props (color="$color"), omit in theme definitions ({ color: palette[11] })
Fetching Current Docs
curl -sL "https://tamagui.dev/docs/core/configuration.md"
curl -sL "https://tamagui.dev/llms.txt" # full indexAnimation Patterns
Prescriptive patterns for animations and transitions. Read this before adding animations.
Mandatory Rules
1. Never Conditionally Include animation Prop
Animation hooks are called based on prop presence. Toggling the prop causes errors:
// WRONG - causes hook errors
<View animation={isAnimated ? 'quick' : undefined} />
<View {...(isAnimated && { animation: 'quick' })} />
// CORRECT - use null to disable
<View animation={isAnimated ? 'quick' : null} />2. Use animateOnly for Performance
Restrict animations to necessary properties:
// WRONG - animates everything
<View animation="quick" opacity={0.5} scale={0.9} />
// CORRECT - only animate what's needed
<View
animation="quick"
animateOnly={['opacity', 'transform']}
opacity={0.5}
scale={0.9}
/>3. Animation Props on Content, Not Containers
For Dialog/Sheet/Popover, apply to Content component:
// WRONG
<Dialog.Portal animation="quick">
// CORRECT
<Dialog.Content animation="quick" enterStyle={{...}}>Animation Drivers
Choose based on platform:
| Driver | Package | Best For |
|---|---|---|
| CSS | @tamagui/animations-css | Web apps, small bundle |
| Animated | @tamagui/animations-react-native | Cross-platform RN |
| Reanimated | @tamagui/animations-moti | Native apps, complex motion |
CSS Driver Config
// tamagui.config.ts
import { createAnimations } from '@tamagui/animations-css'
const animations = createAnimations({
fast: 'ease-in 150ms',
medium: 'ease-in 300ms',
slow: 'ease-in 450ms',
quick: 'ease-out 100ms',
bouncy: 'cubic-bezier(0.175, 0.885, 0.32, 1.275) 300ms',
})Reanimated Driver Config
// tamagui.config.ts
import { createAnimations } from '@tamagui/animations-moti'
const animations = createAnimations({
fast: {
type: 'spring',
damping: 20,
mass: 1.2,
stiffness: 250,
},
medium: {
type: 'spring',
damping: 15,
mass: 1,
stiffness: 200,
},
slow: {
type: 'spring',
damping: 20,
stiffness: 60,
},
quick: {
type: 'spring',
damping: 20,
mass: 0.8,
stiffness: 300,
},
bouncy: {
type: 'spring',
damping: 10,
mass: 0.9,
stiffness: 100,
},
})enterStyle and exitStyle
Define initial and final animation states:
<View
animation="quick"
enterStyle={{ opacity: 0, y: -20, scale: 0.9 }}
exitStyle={{ opacity: 0, y: 10, scale: 0.95 }}
>Default Normalization
Tamagui normalizes unmapped properties:
opacitydefaults to1x,ydefault to0scaledefaults to1
So enterStyle={{ opacity: 0 }} animates from 0 to 1 automatically.
Per-Property Animation
Customize animation per property:
// Simple - same animation for all
<View animation="quick" />
// Per-property config
<View
animation={{
opacity: 'slow',
y: {
type: 'quick',
overshootClamping: true,
},
}}
enterStyle={{ opacity: 0, y: -20 }}
/>
// Array syntax for base + overrides
<View
animation={[
'quick',
{
opacity: {
overshootClamping: true,
},
},
]}
/>AnimatePresence
For mount/unmount animations:
import { AnimatePresence } from 'tamagui'
function Notification({ show, message }: { show: boolean; message: string }) {
return (
<AnimatePresence>
{show && (
<View
key="notification"
animation="quick"
enterStyle={{ opacity: 0, y: -50 }}
exitStyle={{ opacity: 0, y: -50 }}
padding="$4"
backgroundColor="$blue10"
>
<Text color="white">{message}</Text>
</View>
)}
</AnimatePresence>
)
}Unique Keys Required
Each child needs a unique key for AnimatePresence to track:
<AnimatePresence>
{items.map(item => (
<View
key={item.id} // REQUIRED
animation="quick"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
>
{item.content}
</View>
))}
</AnimatePresence>Exit Direction with custom
Pass direction for exit animations:
<AnimatePresence custom={{ direction: 'left' }}>
{show && (
<View
key="slide"
animation="quick"
enterStyle={{ x: 100 }}
exitStyle={(custom) => ({ x: custom.direction === 'left' ? -100 : 100 })}
/>
)}
</AnimatePresence>Common Animation Presets
Fade
<View
animation="quick"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>Fade + Scale
<View
animation="quick"
animateOnly={['opacity', 'transform']}
enterStyle={{ opacity: 0, scale: 0.9 }}
exitStyle={{ opacity: 0, scale: 0.95 }}
/>Slide Up
<View
animation="quick"
animateOnly={['opacity', 'transform']}
enterStyle={{ opacity: 0, y: 20 }}
exitStyle={{ opacity: 0, y: 20 }}
/>Slide Down (Dialog style)
<View
animation={['quick', { opacity: { overshootClamping: true } }]}
animateOnly={['opacity', 'transform']}
enterStyle={{ opacity: 0, y: -20, scale: 0.9 }}
exitStyle={{ opacity: 0, y: 10, scale: 0.95 }}
/>Bounce In
<View
animation="bouncy"
animateOnly={['transform']}
enterStyle={{ scale: 0.5 }}
/>Overlay Animations
Standard patterns for Dialog/Sheet/Popover:
Overlay
<Dialog.Overlay
animation="quick"
opacity={0.5}
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>Content
<Dialog.Content
animation={['quick', { opacity: { overshootClamping: true } }]}
animateOnly={['transform', 'opacity']}
enterStyle={{ x: 0, y: -20, opacity: 0, scale: 0.9 }}
exitStyle={{ x: 0, y: 10, opacity: 0, scale: 0.95 }}
/>Performance Notes
1. Animation hooks are expensive - only add animation prop when needed 2. Hot reload issues - adding animations during HMR may error; save again or reload 3. Bundle size - CSS driver is smallest, Reanimated is largest 4. Native performance - Reanimated runs off-thread for smoother native animations 5. Use animateOnly - always restrict to needed properties
Disabling Animations
For reduced motion or performance:
// Disable with null
<View animation={prefersReducedMotion ? null : 'quick'} />
Debug Animations
Enable debug mode to see animation frames:
// In development
<View animation="quick" debug />Tamagui Compiler Optimization Patterns
The Tamagui compiler extracts styles at build time, generating atomic CSS and flattening components to native primitives (div on web, View on native).
What Gets Optimized
Static Props
// OPTIMIZED - all values known at compile time
<View backgroundColor="$blue10" padding="$4" borderRadius="$2" />Variants
// OPTIMIZED - variant values are static
<Button size="$large" variant="primary" />Spread from Variables (with constraints)
// OPTIMIZED - if buttonProps contains only static values
const buttonProps = { size: '$large' } as const
<Button {...buttonProps} />What Breaks Optimization
Dynamic Values
// NOT OPTIMIZED - runtime calculation
<View width={containerWidth * 0.5} />
<View backgroundColor={isDark ? '$gray1' : '$gray12'} />
// FIX: Use variants
const Box = styled(View, {
variants: {
half: { true: { width: '50%' } },
dark: { true: { bg: '$gray1' }, false: { bg: '$gray12' } },
},
})Inline Functions
// NOT OPTIMIZED - function reference
<View onPress={() => doSomething()} />Non-deterministic Spread
// NOT OPTIMIZED - props could be anything
<View {...props} />
// PARTIALLY OPTIMIZED - known static props extracted
<View backgroundColor="$blue10" {...props} />Theme Usage on Native (experimental)
// NOT OPTIMIZED on native by default
<View backgroundColor="$background" />
// Enable with experimentalFlattenThemesOnNative (v1.75+)Escape Hatches
File-Level: // tamagui-ignore
Disable compiler for entire file:
// tamagui-ignore
import { View } from 'tamagui'
// All components in this file skip optimizationComponent-Level: disableOptimization
Disable for single component instance:
<View disableOptimization backgroundColor="$blue10" />Bundler Configuration
Vite
// vite.config.ts
import { tamaguiPlugin } from '@tamagui/vite-plugin'
export default defineConfig({
plugins: [
tamaguiPlugin({
config: 'src/tamagui.config.ts',
components: ['tamagui'],
optimize: true,
}),
],
})Webpack
// webpack.config.js
const { TamaguiPlugin } = require('tamagui-loader')
module.exports = {
plugins: [
new TamaguiPlugin({
config: './tamagui.config.ts',
components: ['tamagui'],
importsWhitelist: ['constants.js', 'colors.js'],
logTimings: true,
disableExtraction: process.env.NODE_ENV === 'development',
}),
],
}Next.js
// next.config.js
const { withTamagui } = require('@tamagui/next-plugin')
module.exports = withTamagui({
config: './tamagui.config.ts',
components: ['tamagui'],
disableExtraction: process.env.NODE_ENV === 'development',
excludeReactNativeWebExports: ['Switch', 'ProgressBar', 'Picker'],
})Babel / Metro (React Native)
// babel.config.js
module.exports = {
plugins: [
[
'@tamagui/babel-plugin',
{
components: ['tamagui'],
config: './tamagui.config.ts',
logTimings: true,
disableExtraction: process.env.NODE_ENV === 'development',
// v1.75+: experimental native theme flattening
experimentalFlattenThemesOnNative: true,
},
],
],
}CLI-Based (Turbopack, any bundler)
For bundlers without plugins, use pre-compilation:
# Install
yarn add -D @tamagui/cli
# Build for web
npx tamagui build --target web ./src
# Build for native
npx tamagui build --target native ./src
# Verify optimization count in CI
npx tamagui build --target web --expect-optimizations 10 ./srcConfig file:
// tamagui.build.ts
import type { TamaguiBuildOptions } from 'tamagui'
export default {
config: './tamagui.config.ts',
components: ['tamagui'],
importsWhitelist: ['constants.js', 'colors.js'],
outputCSS: './public/tamagui.css',
} satisfies TamaguiBuildOptionsKey Options
| Option | Description |
|---|---|
config | Path to tamagui.config.ts |
components | Array of component packages to optimize |
importsWhitelist | Files whose exports can be evaluated at compile time |
disableExtraction | Skip optimization (faster dev builds) |
logTimings | Log compilation timing info |
enableDynamicEvaluation | Experimental: optimize inline styled() calls |
Development vs Production
Development: Set disableExtraction: true for faster HMR and easier debugging.
Production: Enable full extraction for optimal bundle size and runtime performance.
{
disableExtraction: process.env.NODE_ENV === 'development',
}Debugging Optimization
1. Check output: Look for .tamagui directory with compiled output 2. Add to .gitignore: The .tamagui directory should not be committed 3. Use logTimings: See which files are being processed 4. Inspect data- attributes: In dev mode, optimized components get data- attributes showing optimization info
Design System Thinking for Tamagui
This guide helps create distinctive, polished UIs using Tamagui's design system approach. Adapted from frontend design principles for cross-platform (web + native) contexts.
Design Thinking Before Coding
Before writing Tamagui code, understand the context:
Purpose: What problem does this interface solve? Who uses it?
Platform Reality: Will this run on web, iOS, Android, or all three? Each has different interaction patterns (hover vs press, scroll behavior, safe areas).
Tone: Commit to an aesthetic direction that works cross-platform:
- Brutally minimal (works everywhere)
- Soft/organic (leverage borderRadius tokens, gentle shadows)
- Editorial/magazine (typography-forward, works best on larger screens)
- Playful/animated (use Tamagui animation drivers strategically)
- Industrial/utilitarian (strong contrast, functional)
Differentiation: What makes this unforgettable? The constraint of cross-platform actually forces cleaner design decisions.
Typography in Tamagui
Custom Fonts via createFont
import { createFont } from 'tamagui'
const headingFont = createFont({
family: 'SpaceGrotesk',
size: {
1: 12,
2: 14,
3: 16,
// ...
true: 16, // default
},
lineHeight: {
1: 17,
2: 20,
3: 22,
},
weight: {
4: '400',
6: '600',
7: '700',
},
letterSpacing: {
4: 0,
7: -0.5,
},
})Font Loading by Platform
Web: Use @font-face or services like Google Fonts, then reference family name.
Native: Use expo-font or react-native-asset to bundle fonts, then reference family name.
Avoid generic system fonts - Inter, Roboto, Arial create "AI slop" aesthetics. Choose distinctive fonts:
- Display: Space Grotesk, Clash Display, Satoshi, General Sans
- Body: iA Writer, IBM Plex, Source Serif
- Monospace: JetBrains Mono, Berkeley Mono, Monaspace
Typography Variants
const config = createTamagui({
fonts: {
heading: headingFont,
body: bodyFont,
},
})
// Usage
<Text fontFamily="$heading" fontSize="$8" fontWeight="$7">
Bold heading
</Text>Theme-Driven Color Systems
Intentional Palettes with createThemes
Don't accept default grayscale. Create palettes with character:
const warmPalette = [
'#faf8f5', // cream white
'#f5f0e8',
'#ebe3d6',
// ... 12 steps
'#1a1612', // warm black
]
const coolPalette = [
'#f8fafc', // cool white
'#f0f4f8',
'#e2e8f0',
// ... 12 steps
'#0f172a', // deep navy
]
createThemes({
base: {
palette: {
light: warmPalette,
dark: coolPalette, // Different character, not just inverted
},
},
})Accent Themes for Bold Contrast
createThemes({
accent: {
palette: {
light: vibrantAccentPalette,
dark: deepAccentPalette,
},
},
})
// Usage - dramatic color shift
<Theme name="accent">
<Button>Call to Action</Button>
</Theme>childrenThemes for Semantic States
createThemes({
childrenThemes: {
success: { palette: { light: greenPalette, dark: greenDarkPalette } },
warning: { palette: { light: amberPalette, dark: amberDarkPalette } },
error: { palette: { light: redPalette, dark: redDarkPalette } },
},
})
// Usage - contextual coloring
<Theme name="error">
<Card>
<Text color="$color">Error state inherits theme</Text>
</Card>
</Theme>Motion with Tamagui
Animation Drivers
| Driver | Platform | Performance | Use Case |
|---|---|---|---|
css | Web | Excellent | Default for web |
react-native-reanimated | Native | Native thread | Required for native |
Configure in tamagui.config.ts:
import { createAnimations } from '@tamagui/animations-css'
// or
import { createAnimations } from '@tamagui/animations-reanimated'Page Transitions with enterStyle/exitStyle
const FadeIn = styled(View, {
opacity: 1,
y: 0,
enterStyle: {
opacity: 0,
y: 20,
},
animation: 'quick',
})Micro-interactions via Pseudo Styles
const InteractiveCard = styled(Card, {
pressStyle: {
scale: 0.98,
opacity: 0.9,
},
hoverStyle: {
scale: 1.02,
shadowRadius: 20,
},
animation: 'quick',
})Staggered Reveals
Use animation delay for orchestrated page loads:
{items.map((item, i) => (
<FadeInItem
key={item.id}
animation="quick"
animateOnly={['opacity', 'transform']}
enterStyle={{ opacity: 0, y: 20 }}
style={{ animationDelay: `${i * 50}ms` }}
>
{item.content}
</FadeInItem>
))}AnimatePresence for Exit Animations
import { AnimatePresence } from 'tamagui'
<AnimatePresence>
{show && (
<View
key="modal"
animation="quick"
enterStyle={{ opacity: 0, scale: 0.9 }}
exitStyle={{ opacity: 0, scale: 0.95 }}
>
Content
</View>
)}
</AnimatePresence>Spatial Composition
Intentional Layouts with Stacks
// Asymmetric hero layout
<XStack flex={1}>
<YStack flex={2} padding="$6" justifyContent="center">
<Text fontSize="$10">Hero Title</Text>
</YStack>
<View flex={3} backgroundColor="$color5" />
</XStack>Responsive Asymmetry
<XStack
flexDirection="column"
$md={{ flexDirection: 'row' }}
>
<YStack flex={1} $md={{ flex: 2 }} />
<YStack flex={1} $md={{ flex: 3 }} />
</XStack>Negative Space via Tokens
// Generous breathing room
<YStack padding="$8" gap="$6">
<Text>Content with space to breathe</Text>
</YStack>
// Controlled density
<YStack padding="$2" gap="$1">
<Text>Compact information-dense area</Text>
</YStack>Grid-Breaking Overlays
<View position="relative">
<Image source={bg} />
<View
position="absolute"
top="$-4" // Negative token - breaks grid intentionally
right="$6"
backgroundColor="$background"
padding="$4"
borderRadius="$4"
elevate
>
<Text>Floating element</Text>
</View>
</View>Anti-Patterns: Avoiding Generic Tamagui
Don't: Use defaultConfig without customization
// GENERIC
import { defaultConfig } from '@tamagui/config/v4'
export const config = createTamagui(defaultConfig)
// DISTINCTIVE
export const config = createTamagui({
...defaultConfig,
fonts: { heading: myCustomFont, body: myBodyFont },
themes: myCustomThemes,
})Don't: Rely on default component styling
Every Button, Card, Input should have intentional styling that reflects your brand, not Tamagui's defaults.
Don't: Ignore platform-specific opportunities
- Web: Use hover states, cursor changes, keyboard focus rings
- Native: Use native haptics, native sheets, platform navigation patterns
Don't: Converge on common choices
If you find yourself using the same tokens everywhere ($4, $blue10, $gray5), you're not exploring the design space. Create semantic tokens:
const semanticTokens = {
$heroSpacing: '$8',
$cardRadius: '$4',
$subtleBackground: '$gray2',
$emphasisColor: '$blue9',
}Design System Checklist
- [ ] Custom fonts loaded (not system defaults)
- [ ] Theme palette has character (not generic grayscale)
- [ ] Accent/semantic themes defined
- [ ] Animation driver configured for target platforms
- [ ] Micro-interactions on interactive elements
- [ ] Responsive breakpoints used intentionally
- [ ] Platform-specific adaptations (Adapt pattern)
- [ ] No reliance on Tamagui default styling
Dialog & Sheet Patterns
Prescriptive patterns for modal overlays. Read this before writing Dialog or Sheet code.
Mandatory Rules
1. Title and Description Are Required
Accessibility requirement - always include both, even if hidden:
// REQUIRED - use VisuallyHidden if you don't want visible title
<Dialog.Content>
<VisuallyHidden>
<Dialog.Title>Edit Profile</Dialog.Title>
<Dialog.Description>Update your account settings</Dialog.Description>
</VisuallyHidden>
{/* visible content */}
</Dialog.Content>Omitting Title/Description breaks screen readers.
2. Always Use Adapt for Cross-Platform
Never use Platform.OS branching. Use Adapt to transform Dialog to Sheet on touch devices:
// CORRECT - single Dialog that adapts
<Dialog modal open={open} onOpenChange={setOpen}>
<Dialog.Portal>
<Dialog.Overlay
animation="quick"
opacity={0.5}
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<Dialog.Content
bordered
elevate
animateOnly={['transform', 'opacity']}
animation={['quick', { opacity: { overshootClamping: true } }]}
enterStyle={{ y: -20, opacity: 0, scale: 0.9 }}
exitStyle={{ y: 10, opacity: 0, scale: 0.95 }}
>
<VisuallyHidden>
<Dialog.Title>Title Here</Dialog.Title>
<Dialog.Description>Describe the dialog purpose</Dialog.Description>
</VisuallyHidden>
<DialogBody />
</Dialog.Content>
</Dialog.Portal>
<Adapt when="sm" platform="touch">
<Sheet modal dismissOnSnapToBottom snapPoints={[80]}>
<Sheet.Frame padding="$4">
<Sheet.ScrollView>
<Adapt.Contents />
</Sheet.ScrollView>
</Sheet.Frame>
<Sheet.Overlay />
</Sheet>
</Adapt>
</Dialog>// WRONG - manual platform branching
if (Platform.OS === 'web') {
return <Dialog>...</Dialog>
}
return <Sheet>...</Sheet>3. Animation on Content, Not Portal
Apply animation props to Dialog.Content, never to Dialog.Portal:
// CORRECT
<Dialog.Portal>
<Dialog.Content animation="quick" enterStyle={{...}}>
// WRONG
<Dialog.Portal animation="quick">4. Use animateOnly for Performance
Restrict animations to necessary properties:
<Dialog.Content
animateOnly={['transform', 'opacity']}
animation="quick"
>Common Dialog Types
Confirmation Dialog
function ConfirmDialog({
open,
onOpenChange,
onConfirm,
title,
description
}: {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
title: string
description: string
}) {
return (
<Dialog modal open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay
animation="quick"
opacity={0.5}
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<Dialog.Content
bordered
elevate
animation={['quick', { opacity: { overshootClamping: true } }]}
animateOnly={['transform', 'opacity']}
enterStyle={{ y: -20, opacity: 0, scale: 0.9 }}
exitStyle={{ y: 10, opacity: 0, scale: 0.95 }}
width="90%"
maxWidth={400}
padding="$4"
gap="$4"
>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
<XStack gap="$3" justifyContent="flex-end">
<Dialog.Close asChild>
<Button chromeless>Cancel</Button>
</Dialog.Close>
<Button theme="active" onPress={onConfirm}>
Confirm
</Button>
</XStack>
</Dialog.Content>
</Dialog.Portal>
<Adapt when="sm" platform="touch">
<Sheet modal dismissOnSnapToBottom>
<Sheet.Frame padding="$4">
<Adapt.Contents />
</Sheet.Frame>
<Sheet.Overlay />
</Sheet>
</Adapt>
</Dialog>
)
}Form Dialog
function FormDialog({ open, onOpenChange }: DialogProps) {
return (
<Dialog modal open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay
animation="quick"
opacity={0.5}
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<Dialog.Content
bordered
elevate
animation={['quick', { opacity: { overshootClamping: true } }]}
animateOnly={['transform', 'opacity']}
enterStyle={{ y: -20, opacity: 0, scale: 0.9 }}
exitStyle={{ y: 10, opacity: 0, scale: 0.95 }}
width="90%"
maxWidth={500}
>
<Dialog.Title>Create Item</Dialog.Title>
<VisuallyHidden>
<Dialog.Description>Fill out the form to create a new item</Dialog.Description>
</VisuallyHidden>
<Form onSubmit={handleSubmit}>
<YStack gap="$3" padding="$4">
<Label htmlFor="name">Name</Label>
<Input id="name" placeholder="Enter name" />
<Label htmlFor="description">Description</Label>
<TextArea id="description" placeholder="Enter description" />
<XStack gap="$3" justifyContent="flex-end" marginTop="$2">
<Dialog.Close asChild>
<Button chromeless>Cancel</Button>
</Dialog.Close>
<Form.Trigger asChild>
<Button theme="active">Create</Button>
</Form.Trigger>
</XStack>
</YStack>
</Form>
</Dialog.Content>
</Dialog.Portal>
<Adapt when="sm" platform="touch">
<Sheet modal dismissOnSnapToBottom snapPoints={[85]}>
<Sheet.Frame>
<Sheet.ScrollView>
<Adapt.Contents />
</Sheet.ScrollView>
</Sheet.Frame>
<Sheet.Overlay />
</Sheet>
</Adapt>
</Dialog>
)
}Sheet-Only Patterns
When you only need a Sheet (no Dialog on desktop):
function BottomSheet({ open, onOpenChange, children }: SheetProps) {
return (
<Sheet
modal
open={open}
onOpenChange={onOpenChange}
snapPoints={[80, 50]}
dismissOnSnapToBottom
dismissOnOverlayPress
>
<Sheet.Overlay
animation="quick"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<Sheet.Frame>
<Sheet.Handle />
<Sheet.ScrollView>
{children}
</Sheet.ScrollView>
</Sheet.Frame>
</Sheet>
)
}Sheet Props Reference
| Prop | Type | Description |
|---|---|---|
modal | boolean | Renders in portal, adds overlay |
snapPoints | number[] | Snap positions as % of screen |
dismissOnSnapToBottom | boolean | Close when dragged to bottom |
dismissOnOverlayPress | boolean | Close on overlay tap |
position | number | Current snap index |
onPositionChange | (pos: number) => void | Snap position changed |
Preventing Dismiss
Stop dismissal on outside click:
<Dialog.Content
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>Controlled vs Uncontrolled
// Uncontrolled - Dialog manages state
<Dialog modal>
<Dialog.Trigger asChild>
<Button>Open</Button>
</Dialog.Trigger>
<Dialog.Portal>...</Dialog.Portal>
</Dialog>
// Controlled - you manage state
const [open, setOpen] = useState(false)
<Dialog modal open={open} onOpenChange={setOpen}>
<Dialog.Portal>...</Dialog.Portal>
</Dialog>
<Button onPress={() => setOpen(true)}>Open</Button>Form Patterns
Prescriptive patterns for forms, inputs, and validation. Read this before writing form code.
Cross-Skill: Load typescript-best-practices
Form validation uses type-first patterns. Load the typescript-best-practices skill for:
- Zod schema definitions
- Discriminated unions for form state
- Type inference from schemas
The examples below use patterns from that skill.
Mandatory Rules
1. Form.Trigger Is Required
Without Form.Trigger, onSubmit never fires. This is the most common mistake:
// WRONG - onSubmit will never fire
<Form onSubmit={handleSubmit}>
<Input />
<Button>Submit</Button>
</Form>
// CORRECT - Form.Trigger enables submission
<Form onSubmit={handleSubmit}>
<Input />
<Form.Trigger asChild>
<Button>Submit</Button>
</Form.Trigger>
</Form>2. Always Use asChild on Form.Trigger
For proper styling and control:
<Form.Trigger asChild>
<Button theme="active">Submit</Button>
</Form.Trigger>3. Label htmlFor Must Match Input id
Accessibility requirement - IDs must match exactly:
// CORRECT
<Label htmlFor="email">Email</Label>
<Input id="email" />
// WRONG - broken accessibility
<Label htmlFor="email">Email</Label>
<Input id="emailInput" />4. No Built-in Validation
Tamagui Form has no validation. Use external libraries:
react-hook-form+zod(recommended)formik+yup
Form State Pattern
Use discriminated unions for form state (from typescript-best-practices):
type FormState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'error'; error: string }
| { status: 'success'; data: ResponseData }
const [state, setState] = useState<FormState>({ status: 'idle' })Complete Form Example
With react-hook-form + zod
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Form, Input, Label, Button, YStack, XStack, Text } from 'tamagui'
// 1. Define schema (type-first)
const createUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.coerce.number().min(18, 'Must be 18 or older').optional(),
})
type CreateUserInput = z.infer<typeof createUserSchema>
// 2. Form state union
type FormState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'error'; error: string }
| { status: 'success' }
function CreateUserForm({ onSuccess }: { onSuccess: () => void }) {
const [state, setState] = useState<FormState>({ status: 'idle' })
const {
control,
handleSubmit,
formState: { errors },
reset,
} = useForm<CreateUserInput>({
resolver: zodResolver(createUserSchema),
defaultValues: { name: '', email: '' },
})
const onSubmit = async (data: CreateUserInput) => {
setState({ status: 'submitting' })
try {
await api.createUser(data)
setState({ status: 'success' })
reset()
onSuccess()
} catch (err) {
setState({
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
return (
<Form onSubmit={handleSubmit(onSubmit)}>
<YStack gap="$3" padding="$4">
{/* Name field */}
<YStack gap="$1">
<Label htmlFor="name">Name</Label>
<Controller
control={control}
name="name"
render={({ field: { onChange, onBlur, value } }) => (
<Input
id="name"
value={value}
onChangeText={onChange}
onBlur={onBlur}
placeholder="Enter name"
borderColor={errors.name ? '$red10' : undefined}
/>
)}
/>
{errors.name && (
<Text color="$red10" fontSize="$2">
{errors.name.message}
</Text>
)}
</YStack>
{/* Email field */}
<YStack gap="$1">
<Label htmlFor="email">Email</Label>
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<Input
id="email"
value={value}
onChangeText={onChange}
onBlur={onBlur}
placeholder="Enter email"
keyboardType="email-address"
autoCapitalize="none"
borderColor={errors.email ? '$red10' : undefined}
/>
)}
/>
{errors.email && (
<Text color="$red10" fontSize="$2">
{errors.email.message}
</Text>
)}
</YStack>
{/* Form-level error */}
{state.status === 'error' && (
<Text color="$red10">{state.error}</Text>
)}
{/* Submit button */}
<Form.Trigger asChild>
<Button
theme="active"
disabled={state.status === 'submitting'}
opacity={state.status === 'submitting' ? 0.5 : 1}
>
{state.status === 'submitting' ? 'Submitting...' : 'Create User'}
</Button>
</Form.Trigger>
</YStack>
</Form>
)
}Field Component Pattern
Extract reusable form fields:
type FieldProps = {
label: string
id: string
error?: string
children: React.ReactNode
}
function Field({ label, id, error, children }: FieldProps) {
return (
<YStack gap="$1">
<Label htmlFor={id}>{label}</Label>
{children}
{error && (
<Text color="$red10" fontSize="$2">
{error}
</Text>
)}
</YStack>
)
}
// Usage
<Field label="Email" id="email" error={errors.email?.message}>
<Controller
control={control}
name="email"
render={({ field: { value, onChange, onBlur } }) => (
<Input
id="email"
value={value}
onChangeText={onChange}
onBlur={onBlur}
borderColor={errors.email ? '$red10' : undefined}
/>
)}
/>
</Field>Input Variants
Text Input
<Input
id="name"
placeholder="Enter name"
autoCapitalize="words"
/>Email Input
<Input
id="email"
placeholder="Enter email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>Password Input
const [showPassword, setShowPassword] = useState(false)
<XStack alignItems="center">
<Input
id="password"
flex={1}
placeholder="Enter password"
secureTextEntry={!showPassword}
autoCapitalize="none"
autoComplete="password"
/>
<Button
size="$2"
chromeless
onPress={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</Button>
</XStack>TextArea
<TextArea
id="description"
placeholder="Enter description"
numberOfLines={4}
/>Checkbox and Switch
import { Checkbox, Switch, Label, XStack } from 'tamagui'
import { Check } from '@tamagui/lucide-icons'
// Checkbox
<XStack alignItems="center" gap="$2">
<Checkbox id="terms" checked={agreed} onCheckedChange={setAgreed}>
<Checkbox.Indicator>
<Check />
</Checkbox.Indicator>
</Checkbox>
<Label htmlFor="terms">I agree to the terms</Label>
</XStack>
// Switch
<XStack alignItems="center" gap="$2">
<Switch id="notifications" checked={enabled} onCheckedChange={setEnabled}>
<Switch.Thumb animation="quick" />
</Switch>
<Label htmlFor="notifications">Enable notifications</Label>
</XStack>Form in Dialog
See @DIALOG_PATTERNS.md for complete form-in-dialog example. Key points:
- Use controlled Dialog state
- Close dialog on successful submit
- Handle loading/error states
- Use Sheet.ScrollView in Adapt for long forms
Validation Schemas Reference
Common zod patterns for forms:
import { z } from 'zod'
// Required string
z.string().min(1, 'Required')
// Email
z.string().email('Invalid email')
// Password with requirements
z.string()
.min(8, 'Must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[0-9]/, 'Must contain number')
// Optional with transform
z.string().optional().transform(v => v || undefined)
// Number from string input
z.coerce.number().min(0).max(100)
// Enum/select
z.enum(['option1', 'option2', 'option3'])
// Refinement for confirm password
const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
})Overlay Patterns
Prescriptive patterns for Popover, Tooltip, and Select. Read this before using these components.
Mandatory Rules
1. Always Use Adapt for Touch Devices
All overlay components should adapt to Sheet on touch:
<Popover>
<Popover.Trigger asChild>
<Button>Open</Button>
</Popover.Trigger>
{/* Adapt to Sheet on touch devices */}
<Adapt when="sm" platform="touch">
<Popover.Sheet modal dismissOnSnapToBottom>
<Popover.Sheet.Frame padding="$4">
<Adapt.Contents />
</Popover.Sheet.Frame>
<Popover.Sheet.Overlay />
</Popover.Sheet>
</Adapt>
<Popover.Content>
{/* content */}
</Popover.Content>
</Popover>2. PortalProvider Required
All overlay components need PortalProvider in app root:
// App.tsx or _app.tsx
import { PortalProvider } from '@tamagui/portal'
function App() {
return (
<PortalProvider shouldAddRootHost>
<YourApp />
</PortalProvider>
)
}3. Use asChild on Triggers
For proper styling and event handling:
// CORRECT
<Popover.Trigger asChild>
<Button>Open</Button>
</Popover.Trigger>
// WRONG - wraps Button in another element
<Popover.Trigger>
<Button>Open</Button>
</Popover.Trigger>Popover
Complete Example
import { Popover, Adapt, Button, YStack, Text } from 'tamagui'
function PopoverDemo() {
return (
<Popover size="$5" allowFlip placement="bottom">
<Popover.Trigger asChild>
<Button>Show Info</Button>
</Popover.Trigger>
<Adapt when="sm" platform="touch">
<Popover.Sheet modal dismissOnSnapToBottom>
<Popover.Sheet.Frame padding="$4">
<Adapt.Contents />
</Popover.Sheet.Frame>
<Popover.Sheet.Overlay />
</Popover.Sheet>
</Adapt>
<Popover.Content
borderWidth={1}
borderColor="$borderColor"
enterStyle={{ y: -10, opacity: 0 }}
exitStyle={{ y: -10, opacity: 0 }}
elevate
animation={['quick', { opacity: { overshootClamping: true } }]}
padding="$4"
>
<Popover.Arrow borderWidth={1} borderColor="$borderColor" />
<YStack gap="$3">
<Text>Popover content here</Text>
<Popover.Close asChild>
<Button size="$3">Close</Button>
</Popover.Close>
</YStack>
</Popover.Content>
</Popover>
)
}Positioning Props
| Prop | Values | Description |
|---|---|---|
placement | 'top', 'bottom', 'left', 'right' | Base position |
+ -start, -end variants | Alignment | |
allowFlip | boolean | Auto-flip when not enough space |
offset | number | Distance from trigger |
Controlled Popover
const [open, setOpen] = useState(false)
<Popover open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<Button onPress={() => setOpen(true)}>Open</Button>
</Popover.Trigger>
{/* ... */}
</Popover>Tooltip
Simpler than Popover - for hover hints only:
import { Tooltip, Button, Text } from 'tamagui'
function TooltipDemo() {
return (
<Tooltip>
<Tooltip.Trigger asChild>
<Button>Hover me</Button>
</Tooltip.Trigger>
<Tooltip.Content
enterStyle={{ x: 0, y: -5, opacity: 0, scale: 0.9 }}
exitStyle={{ x: 0, y: -5, opacity: 0, scale: 0.9 }}
animation={['quick', { opacity: { overshootClamping: true } }]}
padding="$2"
borderRadius="$2"
>
<Tooltip.Arrow />
<Text fontSize="$2">Helpful hint</Text>
</Tooltip.Content>
</Tooltip>
)
}Tooltip Props
| Prop | Type | Description |
|---|---|---|
delay | number | ms before showing |
restMs | number | ms to wait after last pointer move |
placement | string | Same as Popover |
Select
Complete Example
import { Check, ChevronDown, ChevronUp } from '@tamagui/lucide-icons'
import { Select, Adapt, Sheet } from 'tamagui'
const items = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'orange', label: 'Orange' },
]
function SelectDemo() {
const [value, setValue] = useState('apple')
return (
<Select value={value} onValueChange={setValue}>
<Select.Trigger width={220} iconAfter={ChevronDown}>
<Select.Value placeholder="Select a fruit" />
</Select.Trigger>
<Adapt when="sm" platform="touch">
<Sheet modal dismissOnSnapToBottom snapPoints={[50]}>
<Sheet.Frame>
<Sheet.ScrollView>
<Adapt.Contents />
</Sheet.ScrollView>
</Sheet.Frame>
<Sheet.Overlay />
</Sheet>
</Adapt>
<Select.Content zIndex={200000}>
<Select.ScrollUpButton
alignItems="center"
justifyContent="center"
height="$3"
>
<ChevronUp size={20} />
</Select.ScrollUpButton>
<Select.Viewport minWidth={200}>
<Select.Group>
<Select.Label>Fruits</Select.Label>
{items.map((item, i) => (
<Select.Item key={item.value} index={i} value={item.value}>
<Select.ItemText>{item.label}</Select.ItemText>
<Select.ItemIndicator marginLeft="auto">
<Check size={16} />
</Select.ItemIndicator>
</Select.Item>
))}
</Select.Group>
</Select.Viewport>
<Select.ScrollDownButton
alignItems="center"
justifyContent="center"
height="$3"
>
<ChevronDown size={20} />
</Select.ScrollDownButton>
</Select.Content>
</Select>
)
}Select Structure
Required components in order: 1. Select - root 2. Select.Trigger - opens dropdown 3. Select.Value - displays selected value 4. Adapt - touch device handling 5. Select.Content - dropdown container 6. Select.Viewport - scrollable area 7. Select.Group - groups items 8. Select.Item - individual option
Native Select
Use native picker on mobile:
<Select native>
{/* ... */}
</Select>Select Item Index
Each item needs an index prop for keyboard navigation:
{items.map((item, i) => (
<Select.Item key={item.value} index={i} value={item.value}>
<Select.ItemText>{item.label}</Select.ItemText>
</Select.Item>
))}zIndex Considerations
Overlays need high zIndex to appear above other content:
<Select.Content zIndex={200000}>
<Popover.Content zIndex={200000}>
<Dialog.Portal zIndex={200000}>When nesting overlays (e.g., Select inside Dialog), ensure inner overlay has higher zIndex.
Common Patterns
Menu Popover
function MenuPopover({ items }: { items: MenuItem[] }) {
return (
<Popover placement="bottom-start">
<Popover.Trigger asChild>
<Button icon={Menu} />
</Popover.Trigger>
<Adapt when="sm" platform="touch">
<Popover.Sheet modal dismissOnSnapToBottom>
<Popover.Sheet.Frame>
<Adapt.Contents />
</Popover.Sheet.Frame>
<Popover.Sheet.Overlay />
</Popover.Sheet>
</Adapt>
<Popover.Content
padding={0}
borderWidth={1}
borderColor="$borderColor"
animation="quick"
enterStyle={{ y: -10, opacity: 0 }}
exitStyle={{ y: -10, opacity: 0 }}
>
<YStack>
{items.map((item) => (
<Popover.Close key={item.id} asChild>
<Button
chromeless
justifyContent="flex-start"
icon={item.icon}
onPress={item.onPress}
>
{item.label}
</Button>
</Popover.Close>
))}
</YStack>
</Popover.Content>
</Popover>
)
}Confirmation Popover
function ConfirmPopover({
trigger,
onConfirm,
message,
}: {
trigger: React.ReactNode
onConfirm: () => void
message: string
}) {
const [open, setOpen] = useState(false)
const handleConfirm = () => {
onConfirm()
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
<Adapt when="sm" platform="touch">
<Popover.Sheet modal dismissOnSnapToBottom>
<Popover.Sheet.Frame padding="$4">
<Adapt.Contents />
</Popover.Sheet.Frame>
<Popover.Sheet.Overlay />
</Popover.Sheet>
</Adapt>
<Popover.Content
padding="$4"
borderWidth={1}
borderColor="$borderColor"
animation="quick"
enterStyle={{ y: -10, opacity: 0 }}
exitStyle={{ y: -10, opacity: 0 }}
elevate
>
<Popover.Arrow borderWidth={1} borderColor="$borderColor" />
<YStack gap="$3" maxWidth={250}>
<Text>{message}</Text>
<XStack gap="$2" justifyContent="flex-end">
<Popover.Close asChild>
<Button size="$3" chromeless>Cancel</Button>
</Popover.Close>
<Button size="$3" theme="red" onPress={handleConfirm}>
Confirm
</Button>
</XStack>
</YStack>
</Popover.Content>
</Popover>
)
}Form Select Field
Note: Select does not wire Label htmlFor to the trigger. Use aria-label/aria-labelledby on Select.Trigger or wrap the field in a fieldset/legend.
function SelectField({
label,
id,
value,
onValueChange,
options,
error,
}: SelectFieldProps) {
const labelId = `${id}-label`
return (
<YStack gap="$1">
<Label id={labelId}>{label}</Label>
<Select value={value} onValueChange={onValueChange}>
<Select.Trigger
width="100%"
iconAfter={ChevronDown}
borderColor={error ? '$red10' : undefined}
aria-labelledby={labelId}
>
<Select.Value placeholder={`Select ${label.toLowerCase()}`} />
</Select.Trigger>
<Adapt when="sm" platform="touch">
<Sheet modal dismissOnSnapToBottom>
<Sheet.Frame>
<Sheet.ScrollView>
<Adapt.Contents />
</Sheet.ScrollView>
</Sheet.Frame>
<Sheet.Overlay />
</Sheet>
</Adapt>
<Select.Content zIndex={200000}>
<Select.Viewport>
{options.map((option, i) => (
<Select.Item key={option.value} index={i} value={option.value}>
<Select.ItemText>{option.label}</Select.ItemText>
<Select.ItemIndicator marginLeft="auto">
<Check size={16} />
</Select.ItemIndicator>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select>
{error && (
<Text color="$red10" fontSize="$2">{error}</Text>
)}
</YStack>
)
}Related skills
How it compares
Pick tamagui-best-practices over generic React Native skills when the stack is Tamagui and the goal is one shared UI layer across mobile and web.
FAQ
What does tamagui-best-practices cover?
tamagui-best-practices is an agent skill for Tamagui cross-platform UI development. It teaches shared React Native and web screens, theme tokens, and performant styled components so agents follow consistent Tamagui conventions.
When should I invoke tamagui-best-practices?
Invoke tamagui-best-practices when building or refactoring Tamagui product interfaces across React Native and web. The skill fits theme setup, token definitions, and styled component work during active frontend development.