Claude Code with Tamagui: Universal UI Without the Style Drift
Why Tamagui without CLAUDE.md ships apps that look different on web and native
Tamagui is the universal UI library that promises one component, one set of style props, one theme, rendered correctly on web (with the compiler producing zero-runtime CSS) and on React Native (with the runtime applying styles through StyleSheet). The promise is real and the tradeoff is the configuration surface: a tamagui.config.ts with tokens, themes, fonts, animations, and shorthands; a compiler that needs Babel and Metro plugins on native and a Webpack or Vite plugin on web; a styled-components-style styled() API that competes with inline style props; and an animation system with three driver choices. Every one of those choices has a default that Claude Code will pick wrong without guidance.
Claude Code does not know any of this by default. It generates <Stack padding="$4" backgroundColor="$blue10">, ships a component, and stops. The result demos beautifully on the web preview and drifts on native: the padding token resolved differently because the theme override on the native target was not configured, the background colour shifted because the theme switch on web went through CSS variables and on native goes through the runtime resolver. The most common Claude defaults that break Tamagui in production: tokens declared as plain numbers without the $ prefix so the compiler treats them as one-off values, themes referenced before the TamaguiProvider is mounted causing hydration mismatch, the styled() factory used without exporting as a stable identifier so the compiler cannot extract the styles, animations wired with the css driver on a native target causing a runtime error, and the Expo Router root layout missing the TamaguiProvider wrapper so the first render has no theme context.
This guide covers the CLAUDE.md configuration that locks Claude Code into Tamagui's correct usage patterns: the tamagui.config.ts shape with tokens, themes, fonts, animations declared explicitly; the styled() factory exported from a stable module path so the compiler can find it; inline style props used for one-offs, styled() for reusable components; animation drivers selected per platform; and the provider mounted at the root so the first render has a theme. For the React Native rendering patterns the runtime depends on, Claude Code with React Native covers the platform model that pairs cleanly with Tamagui's runtime.
The Tamagui CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Tamagui project it needs to declare: the package versions, the configuration shape, the styled factory rules, the token usage, the animation driver per platform, the provider mounting, and the hard rules.
# Tamagui rules
## Stack
- tamagui ^1.x
- @tamagui/core, @tamagui/config, @tamagui/animations-moti for native, @tamagui/animations-css for web
- React Native 0.74+ on native, React 18 with Next.js or Vite on web
- Expo SDK 51+ with Expo Router for native navigation
## Project structure
- src/ui/ , app-specific UI components built on Tamagui
- src/ui/components/ , styled() factories for reusable components
- src/ui/screens/ , screen-level compositions
- tamagui.config.ts , single source of truth for tokens + themes + fonts
- src/providers/Theme.tsx , TamaguiProvider wrapper with theme persistence
- babel.config.js , compiler plugin for native (and web if not using Vite plugin)
## Config (MANDATORY)
- tamagui.config.ts at the project root, exporting `config` as the default
- Tokens declared under `tokens` for size, color, space, radius, zIndex
- Themes declared under `themes` with `light` and `dark` as minimum
- Fonts declared under `fonts` with platform-specific family overrides
- Shorthands declared under `shorthands` for common abbreviations
## Tokens and theme values (MANDATORY)
- Use the `
Claude Code with Tamagui: Universal UI Without the Style Drift
prefix for tokens: padding="$4", color="$color"
- NEVER use raw numbers for spacing or colors (compiler skips extraction)
- Theme values referenced as $color, $background, $borderColor, etc.
- Custom theme values declared in tamagui.config.ts, not inline
## styled() factory (MANDATORY)
- Reusable components built with styled(BaseComponent, { ... })
- Export the styled factory as a NAMED EXPORT from a stable module path
- NEVER define styled() inside a component function (compiler cannot extract)
- Variants declared under `variants` with type-safe keys
## Animation drivers (MANDATORY per platform)
- Web: createAnimations from @tamagui/animations-css
- Native: createAnimations from @tamagui/animations-moti (or react-native-reanimated)
- Mixed: detect platform in tamagui.config.ts and choose the right driver
- NEVER mix drivers within a single config
## Provider (MANDATORY)
- TamaguiProvider mounted at the ROOT of the tree
- On Next.js: in app/layout.tsx as a client component
- On Expo Router: in app/_layout.tsx with the root Stack inside
- Theme persisted via AsyncStorage on native, localStorage on web
## Hard rules
- NEVER use raw numbers for padding/margin/colors (use $ tokens)
- NEVER define styled() inside a component or hook (compiler bypass)
- NEVER mount the provider conditionally (causes hydration mismatch)
- NEVER mix animation drivers
- NEVER skip the compiler plugin on native (runtime cost balloons)
- ALWAYS export styled() factories from a stable module path
- ALWAYS declare tokens in tamagui.config.ts, not inline
Five rules here prevent the majority of incidents Claude generates without them.
The token usage rule prevents the compiler bypass. Tamagui's compiler scans the source for style props with token values (the $ prefix) and extracts them into static CSS at build time. A prop like padding={16} cannot be extracted because the compiler does not know if 16 is a token or a runtime value, so the prop falls through to the runtime path and adds work to every render. The rule "Use the $ prefix for tokens" forces extraction.
The styled() module path rule prevents the same compiler bypass at the component level. The compiler walks the call graph from imports to find styled() invocations and the variants they declare. A styled() defined inside a component function is invisible to the compiler because it does not run until render time. The rule "export the styled factory as a NAMED EXPORT from a stable module path" makes the factory findable.
The animation driver rule prevents the runtime crash on native. The @tamagui/animations-css driver uses CSS transitions and is web-only. Loading it on a React Native target throws at runtime because the underlying DOM API does not exist. The rule "Web: createAnimations from @tamagui/animations-css; Native: createAnimations from @tamagui/animations-moti" makes the platform-specific choice explicit.
The provider mounting rule prevents the hydration mismatch. The first render on web needs the theme to be available so the SSR HTML matches the client hydration. A TamaguiProvider mounted inside a child component instead of the root layout leaves the first render unthemed, which produces a flash of unstyled content and a hydration warning. The rule "TamaguiProvider mounted at the ROOT of the tree" forces the correct position.
The theme persistence rule prevents the theme flicker on reload. The user's theme choice (light vs dark) needs to persist across sessions. Without explicit persistence, every reload starts at the default theme, the provider then reads the stored choice and switches, and the user sees a flicker. The rule prescribes AsyncStorage on native and localStorage on web with an initial-value gate to prevent the flicker.
The tamagui.config.ts shape
The configuration file is the canonical source for tokens, themes, fonts, and animations. A complete configuration:
// tamagui.config.ts
import { createTamagui, createTokens, createFont } from 'tamagui';
import { createAnimations } from '@tamagui/animations-moti';
import { shorthands } from '@tamagui/shorthands';
import { themes } from '@tamagui/themes';
const tokens = createTokens({
size: {
'0': 0,
'1': 4,
'2': 8,
'3': 12,
'4': 16,
'5': 20,
'6': 24,
'8': 32,
'10': 40,
'12': 48,
'true': 16,
},
space: {
'0': 0,
'1': 4,
'2': 8,
'3': 12,
'4': 16,
'5': 20,
'6': 24,
'8': 32,
'true': 16,
},
radius: {
'0': 0,
'1': 4,
'2': 8,
'3': 12,
'4': 16,
'full': 9999,
'true': 8,
},
zIndex: {
'0': 0,
'1': 100,
'2': 200,
'3': 300,
'true': 0,
},
color: {
white: '#ffffff',
black: '#000000',
zinc50: '#fafafa',
zinc900: '#18181b',
blue500: '#3b82f6',
blue700: '#1d4ed8',
},
});
const interFont = createFont({
family: 'Inter',
size: {
'1': 11,
'2': 12,
'3': 13,
'4': 14,
'5': 16,
'6': 18,
'7': 20,
'8': 24,
'9': 30,
'10': 36,
'true': 14,
},
lineHeight: {
'4': 18,
'5': 22,
'6': 24,
'8': 30,
'true': 18,
},
weight: {
'4': '400',
'5': '500',
'6': '600',
'7': '700',
'true': '400',
},
});
const animations = createAnimations({
fast: { type: 'spring', damping: 20, mass: 1.2, stiffness: 250 },
medium: { type: 'spring', damping: 10, mass: 0.9, stiffness: 100 },
slow: { type: 'spring', damping: 20, mass: 0.9, stiffness: 60 },
});
export const config = createTamagui({
tokens,
themes,
shorthands,
fonts: { body: interFont, heading: interFont },
animations,
defaultTheme: 'light',
defaultFont: 'body',
});
export default config;
export type AppConfig = typeof config;
declare module 'tamagui' {
interface TamaguiCustomConfig extends AppConfig {}
}
Three things in this config that the CLAUDE.md rule enforces. Tokens are declared under tokens with explicit keys and a true default so the prop padding={true} resolves to a sensible value. Themes are imported from @tamagui/themes which gives a baseline light and dark, overridable via the themes field if you want custom palettes. The declare module 'tamagui' block extends the library's type definitions with your config, so the $ token references are type-checked end-to-end.
The true token convention is a Tamagui quirk worth knowing. The boolean prop <Stack padding> resolves to the true value in each token scale. Set it to your most-used value so the prop is a useful shortcut.
Authoring a reusable component with styled()
The styled() factory is the unit of reusable styling. Components built with styled() participate in the compiler's extraction pass, get static CSS on web, and stay performant on native because the style resolution is cached.
A complete Button component:
// src/ui/components/Button.tsx
import { styled, GetProps } from 'tamagui';
import { Stack, Text } from 'tamagui';
const ButtonFrame = styled(Stack, {
name: 'Button',
tag: 'button',
role: 'button',
cursor: 'pointer',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: '$2',
paddingHorizontal: '$4',
paddingVertical: '$2',
borderRadius: '$2',
borderWidth: 1,
borderColor: 'transparent',
hoverStyle: { borderColor: '$borderColor' },
pressStyle: { scale: 0.97 },
animation: 'fast',
variants: {
variant: {
solid: {
backgroundColor: '$color',
color: '$background',
},
outline: {
backgroundColor: 'transparent',
borderColor: '$borderColor',
color: '$color',
},
ghost: {
backgroundColor: 'transparent',
color: '$color',
hoverStyle: { backgroundColor: '$backgroundHover' },
},
},
size: {
sm: {
paddingHorizontal: '$3',
paddingVertical: '$1',
fontSize: '$3',
},
md: {
paddingHorizontal: '$4',
paddingVertical: '$2',
fontSize: '$4',
},
lg: {
paddingHorizontal: '$5',
paddingVertical: '$3',
fontSize: '$5',
},
},
disabled: {
true: {
opacity: 0.5,
cursor: 'not-allowed',
pointerEvents: 'none',
},
},
} as const,
defaultVariants: {
variant: 'solid',
size: 'md',
},
});
export const Button = ButtonFrame.styleable<{ children?: React.ReactNode }>(
({ children, ...rest }, ref) => (
<ButtonFrame ref={ref} {...rest}>
<Text>{children}</Text>
</ButtonFrame>
),
);
export type ButtonProps = GetProps<typeof Button>;
Four things in this component that the CLAUDE.md rule enforces. The styled() factory is a named export at module scope, so the compiler can extract its variants. The variants object has as const so TypeScript can infer the literal types, which enables auto-complete and type-checking on the variant and size props. The tag: 'button' and role: 'button' give the web rendering accessible semantics (a real button element), which the native rendering ignores in favour of the React Native equivalent. The animation: 'fast' references the named animation from tamagui.config.ts, so the press animation uses the configured spring.
The styleable wrapper produces a component that accepts both the underlying frame's props (including all style props) and the wrapper's custom props (like children). This is the canonical pattern for adding component-specific logic without losing the compiler-friendly variant surface.
Add a styled() scaffolding rule to CLAUDE.md:
## styled() component template (CONCRETE)
A new reusable component goes in src/ui/components/<Name>.tsx with this shape:
const <Name>Frame = styled(BaseComponent, {
name: '<Name>', // for devtools
tag: '...', // for accessibility
// base styles
variants: {
variant: { ... },
size: { ... },
} as const,
defaultVariants: { ... },
});
export const <Name> = <Name>Frame.styleable(...);
export type <Name>Props = GetProps<typeof <Name>>;
ALWAYS: `as const` on the variants object for type inference
NEVER: define styled() inside a component body
TamaguiProvider with theme persistence
The provider mount needs to happen at the root, but the theme value needs to come from a persisted source so the first render uses the user's last choice.
// src/providers/Theme.tsx
'use client';
import { TamaguiProvider } from 'tamagui';
import { useEffect, useState, type ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { config } from '../../tamagui.config';
type ThemeName = 'light' | 'dark';
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<ThemeName>('light');
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
(async () => {
const stored = await AsyncStorage.getItem('theme');
if (stored === 'dark' || stored === 'light') {
setTheme(stored);
}
setHydrated(true);
})();
}, []);
if (!hydrated) return null;
return (
<TamaguiProvider config={config} defaultTheme={theme}>
{children}
</TamaguiProvider>
);
}
Three things in this provider that the CLAUDE.md rule enforces. The provider is a client component (the 'use client' directive marks it for Next.js, and Expo Router treats all components as client by default). The hydration gate prevents the flash of unthemed content by blocking the render until AsyncStorage has resolved. The defaultTheme prop drives the initial theme, switchable later via useTheme() from anywhere in the tree.
For the Expo Router root layout that mounts this provider, Claude Code with React Native covers the navigation patterns that pair cleanly with Tamagui's theme system. For the Next.js App Router layout that mounts the same provider on web, Claude Code with Next.js covers the App Router patterns for client providers at the root.
Compiler setup per target
The compiler is what makes Tamagui zero-runtime on web. It runs at build time, scans for styled() factories and inline style props with token values, and extracts them into static CSS. Without the compiler, every style prop runs through the runtime resolver on every render, which is fine for small apps and untenable for large ones.
For Vite (web):
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { tamaguiPlugin } from '@tamagui/vite-plugin';
export default defineConfig({
plugins: [
react(),
tamaguiPlugin({
config: './tamagui.config.ts',
components: ['tamagui'],
optimize: true,
}),
],
});
For Metro (native):
// babel.config.js
module.exports = {
presets: ['babel-preset-expo'],
plugins: [
[
'@tamagui/babel-plugin',
{
components: ['tamagui'],
config: './tamagui.config.ts',
logTimings: true,
},
],
'react-native-reanimated/plugin',
],
};
The components array tells the compiler which package's components to optimise. Add custom component packages here if you publish them.
Add a compiler rule to CLAUDE.md:
## Compiler
- Vite: @tamagui/vite-plugin in vite.config.ts
- Metro: @tamagui/babel-plugin in babel.config.js, BEFORE react-native-reanimated/plugin
- components array MUST include 'tamagui' and any custom component packages
- NEVER ship without the compiler on production builds
Common Claude Code mistakes with Tamagui
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Raw numbers for style values
Claude generates <Stack padding={16} backgroundColor="#3b82f6">. The compiler cannot extract either, and the runtime falls through to inline styles every render.
Correct pattern: <Stack padding="$4" backgroundColor="$blue500">.
2. styled() inside a component body
Claude generates:
function MyScreen() {
const Btn = styled(Stack, { backgroundColor: '$blue500' });
return <Btn />;
}
The compiler never sees Btn. The styles run through the runtime, and every render creates a new Btn identity that confuses memoization.
Correct pattern: define styled() at module scope and export it.
3. Animations driver mismatched to platform
Claude generates import { createAnimations } from '@tamagui/animations-css' in a project that targets React Native. The native build crashes at runtime.
Correct pattern: @tamagui/animations-moti for native, @tamagui/animations-css for web, conditionally chosen if the config serves both.
4. Provider mounted in a child component
Claude generates a ThemeProvider inside a screen component instead of the root layout. The first render of every other screen is unthemed.
Correct pattern: mount the provider at app/_layout.tsx (Expo Router) or app/layout.tsx (Next.js).
5. Missing TamaguiCustomConfig declaration
Claude generates the tamagui.config.ts without the declare module 'tamagui' block. The $token references in style props are typed as string, so typos and missing tokens go uncaught.
Correct pattern: extend TamaguiCustomConfig with your AppConfig for end-to-end type checking.
6. Compiler plugin missing on native
Claude generates the Vite plugin for web and forgets the Babel plugin for native. The native build runs but every style prop falls through the runtime, which destroys performance on lists.
Correct pattern: both targets get the compiler plugin, with the Babel plugin ordered before react-native-reanimated/plugin.
Add this list to CLAUDE.md with the corrected form for each.
Permission hooks for Tamagui projects
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pnpm add tamagui*)",
"Bash(pnpm add @tamagui/*)",
"Bash(pnpm test*)",
"Bash(pnpm tsc --noEmit*)",
"Bash(pnpm expo*)"
],
"deny": [
"Bash(pnpm remove tamagui*)",
"Bash(rm -rf tamagui.config.ts*)",
"Bash(rm -rf src/ui*)"
]
}
}
Building universal apps that look the same on every target
The Tamagui CLAUDE.md in this guide produces apps where every style prop uses a $-prefixed token so the compiler can extract it, every reusable component is a styled() factory exported from a stable module path so the compiler can find it, every animation uses the right driver for the target platform so the build does not crash, and the provider is mounted at the root so the first render of every screen has a theme.
The underlying principle is the same as any universal UI library with Claude Code. Tamagui without a CLAUDE.md produces apps that work on one target and drift on the other: web looks right because the compiler caught most things and native looks wrong because the runtime resolver fell through, themes flicker on reload because persistence was not wired, styles regress over time because token discipline drifted to raw numbers, and animations crash because the wrong driver shipped in production.
For the React Native rendering patterns the native runtime depends on, Claude Code with React Native covers the platform model that pairs cleanly with Tamagui's runtime. For the Next.js App Router patterns that host the web build, Claude Code with Next.js covers the routing patterns for client providers. Claudify includes a Tamagui-specific CLAUDE.md template with the config shape, the styled() factory discipline, the compiler setup for both targets, the provider mounting contract, and all six common-mistake rules pre-configured.
Get Claudify. Ship Tamagui apps that look the same on web and native.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify