Upgrading to v3

A complete guide for migrating Tamagui v2 apps to Tamagui v3.

Tamagui v3 removes deprecated APIs, makes tokens and themes more predictable, and gives every adaptive overlay the same composition model.

Most migrations are mechanical, but the Sheet anatomy, token changes, and focus behavior should be verified in real screens.

If you are on v1, migrate through the v2 guide first or run the v1-to-v3 AI migration prompt from the CLI. v3 assumes the v2 web-standard prop model, defaults to Config v6, and has a React 19, React Native 0.81+, and TypeScript 5+ baseline.

Start with a smaller, complete upgrade

The runtime version and config version are separate. V3 APIs with your existing design values is the recommended first checkpoint.

  • From V2: migrate the packages and required APIs while preserving your token, theme, font, media, and animation values. Do not replace a custom config with v6 defaults as part of the syntax conversion.
  • From Config v5 or v5-subtle: keep that supported config through the API migration. Keep its 12-step colors and physical scales; the v6 remaps later in this guide are for a separate design migration.
  • Already on V3 with Config v5: check the remaining API work and validate it. You do not need to adopt Config v6 to finish upgrading to V3.

Record representative screens before editing. Finish with typecheck, build, report review, and the same browser/native interactions and screenshots. Defer Config v6, wholesale html.* conversion, and Tailwind adoption unless you chose them as separate work. The agent setup prompt makes this checkpoint explicit:

Required: migrate tokens and conditional styles

V3 accepts bare token and theme names, with conditions expressed as typed objects or string clauses on each style property. There is no runtime compatibility path for sigil-prefixed values or V2 condition objects such as hoverStyle. Run the codemod from your project root before the API-specific steps below:

yarn dlx @tamagui/codemod-flat-values \ --source-semantics v2-pixels \ --report flat-values-report.md \ ./src npx @tamagui/codemod-flat-values --write \ --source-semantics v2-pixels \ --report flat-values-report.md \ --json flat-values-report.json \ ./src

For example, this V2 migration input:

<View bg="$background" hoverStyle={{ bg: '$backgroundHover' }} $sm={{ p: '$6' }} p="$4" />

becomes:

<View bg="background hover:background-hover" p="4 sm:6" />

Read and hand-fix every flagged report row, then rerun until the migrated path has no V2 authoring sites. See Flat Conditional Values for the grammar and full workflow.

Declare query containers separately from groups

group now provides parent interaction state only. It no longer adds CSS containment or native layout measurement. A named size clause needs an explicit container on the ancestor it targets:

// before: group implicitly established the query container
<View group="card">
<Text width="100% @sm/card:50%" />
</View>
// after: state and containment are authored separately
<View group="card" container="card">
<Text width="100% @sm/card:50%" />
</View>

Use container for an unnamed inline-size container, container="main" for a named inline-size container, and container="main" containerType="size" for containment on both axes. container={false} and container={undefined} do nothing, so conditional roots can use container={isRoot}. Raw CSS containerName and containerType longhands remain available in style() and style objects. An explicit longhand wins the property it names when used with container.

Remove webContainerType from createTamagui settings. V3 has no global container-type compatibility setting.

Plan for that review before starting a large migration. On the V2 Bento corpus, the codemod found 2,113 sites. It classified 1,681 as clean and left 412 of 2,052 JSX sites plus 20 of 61 styled() configuration sites for a person to review. That is 432 sites, or 20.4% of the corpus. After the proposed conversion, 63 of 208 files still contained legacy condition objects. A successful codemod run means the report was generated, not that the application is ready for V3.

1. Update packages

Bump all tamagui and @tamagui/* packages together. The tamagui command is @tamagui/cli, so add it as a dev dependency first if the app does not have it yet.

yarn dlx tamagui check

Then install the v3 versions with your package manager and make sure your lockfile does not contain duplicate @tamagui/core, @tamagui/web, or tamagui copies.

Choose the component surface

V3 gives each styling mode an explicit import path. Import the default styled skin from its generated tamagui/<component> subpath:

import { Button } from 'tamagui/button'
import { Toast, toast } from 'tamagui/toast'

The tamagui root remains the convenient full barrel. Component subpaths are the smaller delivery path for apps and compiler configuration that only need selected skins. Use tamagui/unstyled or @tamagui/ui for behavior primitives without the default skin, and @tamagui/tailwind for Tailwind-authored primitives. The Styled, Unstyled, and Tailwind switch above every v3 docs page changes examples between these authoring modes.

The V3 roots for Avatar, Tabs, and Group retain behavior and structural layout, but no longer supply the old opinionated visual defaults. There are no tamagui/avatar or tamagui/group default-skin subpaths. Keep the behavior imports and add the exact visual styles your application needs:

import { Avatar, Tabs, styled } from 'tamagui'
const AppAvatar = styled(Avatar, {
borderWidth: 1,
borderColor: 'border-color',
})
const AppTab = styled(Tabs.Tab, {
paddingHorizontal: '3',
paddingVertical: '2',
activeStyle: { backgroundColor: 'background-press' },
})

Apply the same treatment to Group and its children. Existing code that relied on V2 defaults can still compile while rendering without that chrome, so inspect these components visually rather than relying only on typecheck.

Remove tooling packages that no longer exist in v3:

Removed packageMigration
@tamagui/babel-pluginInstall @tamagui/cli and use @tamagui/cli/vite, @tamagui/cli/metro, or the tamagui build precompile command for Turbopack.
@tamagui/get-button-sizedCall resolveSizing from @tamagui/core with the size name; see §5.
@tamagui/sizable-contextUse createStyledContext from @tamagui/core with your own size names.
@tamagui/static-syncRemove direct imports. Compiler sessions are owned by the supported bundler adapters.
@tamagui/static-workerRemove direct imports. Metro owns its graph/cache workers through @tamagui/cli/metro.
@tamagui/theme-builderFreeze the generated themes or use @tamagui/config-v5 for the intermediate checkpoint. Adopt @tamagui/themes/builder recipes only in a separate v6 migration.
Removed package
@tamagui/babel-plugin
Migration
Install @tamagui/cli and use @tamagui/cli/vite, @tamagui/cli/metro, or the tamagui build precompile command for Turbopack.
Removed package
@tamagui/get-button-sized
Migration
Call resolveSizing from @tamagui/core with the size name; see §5.
Removed package
@tamagui/sizable-context
Migration
Use createStyledContext from @tamagui/core with your own size names.
Removed package
@tamagui/static-sync
Migration
Remove direct imports. Compiler sessions are owned by the supported bundler adapters.
Removed package
@tamagui/static-worker
Migration
Remove direct imports. Metro owns its graph/cache workers through @tamagui/cli/metro.
Removed package
@tamagui/theme-builder
Migration
Freeze the generated themes or use @tamagui/config-v5 for the intermediate checkpoint. Adopt @tamagui/themes/builder recipes only in a separate v6 migration.

LinearGradient needs no native setup

LinearGradient draws a backgroundImage gradient on native, so @tamagui/native/setup-expo-linear-gradient is gone. Delete that import, and remove expo-linear-gradient from your dependencies unless your own code uses it. Native gradients need React Native 0.76 or later with the new architecture.

Config and theme compatibility

V3 defaults to Config v6. It also keeps frozen static v5 and v5-subtle packs so existing applications can preserve their token values and generated themes while migrating the rest of the v3 API.

The dynamic v5 theme builders are not part of @tamagui/config. An application that generates its own v5 themes with createV5Theme, subtleChildrenThemes, or @tamagui/theme-builder has three options:

  1. Freeze the generated themes. The builder is a value generator. Run it once on your current version, serialize the result to a static literal, and the application stops importing the removed API while every resolved value survives unchanged. This is the cheapest path and the right one whenever the palette is settled, especially if its values were measured or tuned against a visual reference.
  2. @tamagui/config-v5, an opt-in package carrying the dynamic builders. It is deliberately separate from @tamagui/config so the builder code and its dependencies stay out of the default install.
  3. Rebuild on the v6 recipe API. The real destination, and a migration to schedule on its own rather than alongside the flat-values conversion.

No config bundles an animation driver, so import one alongside it. The old v5-* animation paths remain aliases for compatibility, while the unversioned paths are preferred for new code.

V2 entryV3 status or replacement
@tamagui/config (root, config / configWithoutAnimations)defaultConfig from @tamagui/config/v6
@tamagui/config/v3 or /v4defaultConfig from @tamagui/config/v6
@tamagui/config/v5 or /v5-subtleRetained as frozen static compatibility packs. Use /v6 for new applications.
@tamagui/config animations / reanimated@tamagui/config/animations-css, animations-rn, animations-reanimated, or animations-motion
@tamagui/config/v5-css, /v5-rn, /v5-reanimated, or /v5-motionRetained aliases for the matching unversioned animation entries.
old config fonts, media, breakpoints, and shorthandsthe matching exports from @tamagui/config/v6-base
@tamagui/config createGenericFontcreateSystemFont from @tamagui/config/v6-base
old @tamagui/themes root behaviorv6 static themes and tokens from @tamagui/themes
@tamagui/themes/v5, /v5-subtle, or /v5-tokensRetained as frozen static compatibility packs. The /v5-builder entries are removed.
@tamagui/theme-builderrecipe helpers from @tamagui/themes/builder
@tamagui/colors/legacy (the pre-v2 Radix ramps: blue/blueDark, gray/grayDark, …)@tamagui/colors, which exports the same names from the current Radix scales
V2 entry
@tamagui/config (root, config / configWithoutAnimations)
V3 status or replacement
defaultConfig from @tamagui/config/v6
V2 entry
@tamagui/config/v3 or /v4
V3 status or replacement
defaultConfig from @tamagui/config/v6
V2 entry
@tamagui/config/v5 or /v5-subtle
V3 status or replacement
Retained as frozen static compatibility packs. Use /v6 for new applications.
V2 entry
@tamagui/config animations / reanimated
V3 status or replacement
@tamagui/config/animations-css, animations-rn, animations-reanimated, or animations-motion
V2 entry
@tamagui/config/v5-css, /v5-rn, /v5-reanimated, or /v5-motion
V3 status or replacement
Retained aliases for the matching unversioned animation entries.
V2 entry
old config fonts, media, breakpoints, and shorthands
V3 status or replacement
the matching exports from @tamagui/config/v6-base
V2 entry
@tamagui/config createGenericFont
V3 status or replacement
createSystemFont from @tamagui/config/v6-base
V2 entry
old @tamagui/themes root behavior
V3 status or replacement
v6 static themes and tokens from @tamagui/themes
V2 entry
@tamagui/themes/v5, /v5-subtle, or /v5-tokens
V3 status or replacement
Retained as frozen static compatibility packs. The /v5-builder entries are removed.
V2 entry
@tamagui/theme-builder
V3 status or replacement
recipe helpers from @tamagui/themes/builder
V2 entry
@tamagui/colors/legacy (the pre-v2 Radix ramps: blue/blueDark, gray/grayDark, …)
V3 status or replacement
@tamagui/colors, which exports the same names from the current Radix scales

The v6 replacements above adopt new design defaults. For the intermediate checkpoint, retain @tamagui/config/v5 or /v5-subtle and import defaultConfig from that entry. If coming from older config entries or custom builders, freeze their resolved tokens and themes before replacing the removed imports; switching to v5 or v6 by name does not preserve those values.

For example, when separately adopting v6:

// before
import { config } from '@tamagui/config/v4'
import { createAnimations } from '@tamagui/animations-moti'
// after
import { defaultConfig } from '@tamagui/config/v6'
import { createAnimations } from '@tamagui/animations-reanimated'

Replace the React Native animation driver on web

V3 keeps @tamagui/animations-react-native on native and removes its web implementation. A web config that calls its createAnimations now throws during setup. Choose @tamagui/animations-css, @tamagui/animations-motion, or @tamagui/animations-reanimated for web. Use platform-specific config files if the same application keeps @tamagui/animations-react-native on native.

Apps that use Sheet or the animated-number hooks with the CSS driver must import createAnimations from @tamagui/animations-css/extras. The root @tamagui/animations-css entry omits those hooks, so Sheet throws when it tries to animate its position.

Remove @tamagui/babel-plugin from Babel configuration and dependencies. Vite and Metro perform compilation through their dedicated plugins:

// before: babel.config.js
plugins: ['@tamagui/babel-plugin']
// after: vite.config.ts
plugins: [tamaguiPlugin(tamaguiOptions)]
// after: metro.config.js
config = withTamagui(config, tamaguiOptions)

Clear Metro after config changes

The beta Metro plugin does not yet invalidate Metro’s transform cache when a Tamagui lowering plan changes but the app source does not. After editing tamagui.config.ts or another compiler input, stop the live Metro process and restart it with a cleared cache:

yarn dlx expo start -c

For a warm CLI production build, remove the app-local Tamagui cache and use the Expo command’s Metro reset flag:

Terminal

rm -rf node_modules/.cache/tamagui npx expo export --clear # native embedded bundle: add --reset-cache to expo export:embed

Without this step, Metro can serve stale compiled output from the earlier plan. Expect the first rebuild after clearing caches to be slower because Metro must replan every module. The beta measured about 12 ms per module: roughly 27 seconds for a 2,328-module starter and about two minutes for a 10,000-module application. Later warm builds are unaffected.

Upgrading to V3 and moving from Config v5 to v6 are two separate migrations, and a mature application should do only the first. Stay on @tamagui/config/v5, which V3 supports as a frozen static pack.

They cost differently. The V3 upgrade is a large but mechanical syntax change with a codemod, a report, and a visible finish line. The v6 move is a design change: it buys Tailwind alignment and costs a visible shift in spacing, sizing and color across the whole application, and no tool can tell you whether the result is right.

Stacking them means every visual difference has two possible causes and none of them can be bisected. Staying on v5 preserves the scales while you convert syntax. Component API changes can still affect layout, so compare actual controls and interactions against the baseline rather than accepting visual changes automatically.

Adopt v6 afterwards, piece by piece, when there is budget for the visual review.

The space, size and radius scales change value

Read this before running the codemod with --write against a v6 config. The codemod converts spelling and never edits createTamagui(), so a token keeps its name, changes its value, and the report calls the site clean.

The v5 scale is a hand-tuned curve; the v6 scale is Tailwind’s 4px grid. 15 of 16 integer space tokens differ, and they diverge further the larger the token: $4 is 18px in v5 and 16px in v6, $10 moves 60px to 40px, and $20 moves 186px to 80px.

Do not migrate these by name. Mapping by resolved value lands far closer:

v5 tokenvalueby nameby nearest value
$1.541-5 = 61 = 4, exact
$3.5163-5 = 144 = 16, exact
$5245 = 206 = 24, exact
$6326 = 248 = 32, exact
$1614416 = 6436 = 144, exact
v5 token
$1.5
value
4
by name
1-5 = 6
by nearest value
1 = 4, exact
v5 token
$3.5
value
16
by name
3-5 = 14
by nearest value
4 = 16, exact
v5 token
$5
value
24
by name
5 = 20
by nearest value
6 = 24, exact
v5 token
$6
value
32
by name
6 = 24
by nearest value
8 = 32, exact
v5 token
$16
value
144
by name
16 = 64
by nearest value
36 = 144, exact

Note also that the v6 spelling uses a hyphen: $2.5 becomes 2-5. Quarter steps such as $0.75 have no v6 equivalent at any spelling, so those sites need a real value rather than a rename, and a raw number is often the honest answer.

Theme migration

You can defer this section while using the frozen v5 compatibility packs. Apply it when moving the application’s values and themes to Config v6.

V3’s default adaptive ramp has 11 keys, and every key is hyphenated. The numbers still mean steps relative to the active theme’s own scheme, not Tailwind’s absolute 50 through 950 shades; the hyphen is the only spelling change. Because the ramp is one step shorter, the middle of a V2 12-step ramp compresses:

V2V3V2V3
color1color-1color7color-6
color2color-2color8color-7
color3color-3color9color-8
color4color-4color10color-9
color5color-5color11color-10
color6color-6color12color-11
V2
color1
V3
color-1
V2
color7
V3
color-6
V2
color2
V3
color-2
V2
color8
V3
color-7
V2
color3
V3
color-3
V2
color9
V3
color-8
V2
color4
V3
color-4
V2
color10
V3
color-9
V2
color5
V3
color-5
V2
color11
V3
color-10
V2
color6
V3
color-6
V2
color12
V3
color-11

The endpoints map exactly. color6 and color7 both land on color-6, so inspect contrast where those two used to be distinct.

Palette-specific steps such as blue9 are a separate migration. The default v6 config does not define blue-9 or any other palette step; it defines absolute Tailwind tokens such as blue-500. Either replace the old name with an absolute token, enter theme="blue" and use an adaptive color-N value from that theme, or opt into 12-step scales with createV6Config({ scales }), which adds blue-1 through blue-12 to the base themes.

A missing color value is dropped without an error or warning, and the web and native renderers can expose different fallback colors underneath it. The flat-values codemod cannot choose the intended replacement, because custom configs may still define those names and the closest Tailwind shade depends on the design. It preserves $blue10 as blue10 and emits a non-blocking legacy-palette-token configuration warning for every old palette name it finds. Write mode still applies the safe syntax conversion, so resolve every warning before running the converted application. This search also finds old ramp and palette names in source the flat-values codemod does not need to edit:

Terminal

rg '\b(color|shadow|gray|mauve|slate|sage|olive|sand|tomato|red|ruby|crimson|pink|plum|purple|violet|iris|indigo|blue|cyan|teal|jade|green|grass|bronze|gold|brown|orange|amber|yellow|lime|mint|sky)[0-9]{1,2}\b' src
// before: adaptive ramp
<Text color="color12" />
// after: the compressed, hyphenated step
<Text color="color-11" />
// palette-specific names are a separate migration
// before
<Text color="blue10" />
// after: choose the absolute shade that preserves the intended contrast
<Text color="blue-500" />

Shadows are per-theme values again, as in v5: shadow1 through shadow6 become shadow-1 through shadow-6, and the default themes carry a seventh step. Dark themes use stronger shadows than light themes, shadow-color is the active theme’s shadow-3, and boxShadow="0 8px 24px shadow-4" reads shadow-4 from the active theme rather than a fixed token.

Replace old surface themes with relative levels:

V2V3
surface1level2
surface2level3
surface3level4
surface4level4 (clamped)
V2
surface1
V3
level2
V2
surface2
V3
level3
V2
surface3
V3
level4
V2
surface4
V3
level4 (clamped)

Levels compose relative to the current theme. A nested level2 below an existing level2 resolves to absolute level 3 while preserving a surrounding color theme.

// before
<Theme name="surface1"><Panel /></Theme>
// after
<Theme name="level2"><Panel /></Theme>

Replace the old builder input:

// before
import { createThemes, defaultComponentThemes } from '@tamagui/theme-builder'
export const themes = createThemes({
componentThemes: defaultComponentThemes,
base,
accent,
childrenThemes,
})

Custom theme generation now uses the tree API:

import {
createThemes,
getTheme,
levels,
tokens,
} from '@tamagui/themes/builder'
const tree = {
light: { scheme: 'light', palette: 'gray' },
dark: { scheme: 'dark', palette: 'gray' },
children: {
...levels(),
inverse: ({ parent }) => ({
scheme: parent.scheme === 'light' ? 'dark' : 'light',
children: levels(),
}),
},
} as const
export const themes = createThemes(tokens, tree, { getTheme })

Remove the v2 builder exports and options defaultComponentThemes, componentThemes, templates, masks, childrenThemes, and grandChildrenThemes. Put hierarchy directly in the tree, semantic shade choices in scale objects, and exact one-theme overrides in values. Components no longer resolve uppercase name segments such as light_Button automatically. A component that needs a raised surface should create a normal level2 boundary in its own skin.

Search configuration and application code together:

Terminal

rg "@tamagui/theme-builder|defaultComponentThemes|themes/v5-builder|config/v[34]|createV5Theme|componentThemes|grandChildrenThemes|surface[1-4]|color-12"

See Creating Themes for the recipe API and Surfaces and levels for nesting semantics.

In monorepos, keep resolutions/overrides aligned:

{ "resolutions": { "@tamagui/core": "^3.0.0", "@tamagui/web": "^3.0.0", "tamagui": "^3.0.0" } }

2. Migrate Sheet anatomy

Sheet.Frame has been replaced by Sheet.Container and Sheet.Background. The flat-values codemod above rewrites every Sheet.Frame it can prove is Tamagui’s: the element becomes Sheet.Container, and a Sheet.Background carrying the surface props (bg, border*, shadow*, disableHideBottomOverflow) is inserted as its first child. A spread on the Frame and a styled(Sheet.Frame, …) target are rewritten too but reported for review, because the codemod cannot see which of those props are surface props. Review every changed Sheet against the rules below.

// before
<Sheet>
<Sheet.Overlay />
<Sheet.Frame padding="4" bg="background" borderTopRadius="6">
<Sheet.ScrollView>{children}</Sheet.ScrollView>
</Sheet.Frame>
</Sheet>
// after
<Sheet>
<Sheet.Overlay />
<Sheet.Container padding="4">
<Sheet.Background bg="background" borderTopRadius="6" />
<Sheet.ScrollView>{children}</Sheet.ScrollView>
</Sheet.Container>
</Sheet>

Rules to verify:

  • Sheet.Overlay must be a direct child of Sheet.
  • Sheet.Container owns layout props like padding, gap, height, maxHeight, and flex props.
  • Sheet.Background owns visual surface props like bg, borderRadius, shadow*, and decorative absolute layers.
  • Sheet.Container no longer clips with overflow="hidden". Add clipping explicitly if your content relied on the old frame clipping.
  • Sheet.Background owns disableHideBottomOverflow.

3. Replace removed props and aliases

Search for APIs that need migration or review. This scan deliberately excludes the supported Config v5 entries and their retained palette names. color-12, surface1–surface4, and builders imported from @tamagui/config-v5 do not require a v6 theme migration to complete this checkpoint.

Terminal

rg "createStyledHOC|focusable|fullscreen|themeInverse|<Theme inverse|Sheet\.Frame|styleable\(|inlineWhenUnflattened|\\\$true|getTokenRelative|stepTokenUpOrDown|forceRemoveScrollEnabled|sizeAdjust|selectable=|Select\.Item.*index|enterVariant|exitVariant|enterExitVariant|delayMs|disableTransparencyHide|disableRootThemeClass|themeClassNameOnRoot|isWindowDefined|getExpandedShorthands|usePropsAndStyle|useProps|useStyle|backgroundActive|@tamagui/config/v[34]|@tamagui/animations-moti|@tamagui/babel-plugin|animateOnly"

Use these replacements:

RemovedReplacement
focusabletabIndex
fullscreenexplicit position and inset props
themeInversetheme="inverse"
<Theme inverse><Theme name="inverse">
Sheet.FrameSheet.Container plus Sheet.Background
Component.styleable(fn)createStyledHOC(Component, fn)
createStyledHOC(Component)<Props>(fn)createStyledHOC(Component, (props: Props, ref) => ...)
forwardRef wrapper staticsdirect refs and normal component composition
inlineWhenUnflattenedremove it
deprecated UI kit aliasesimport the current component names
old platform condition keysuse flat web:, native:, ios:, android:, etc. clauses
forceRemoveScrollEnableddisableRemoveScroll with inverted intent
selectable on TextuserSelect (core maps it to RN’s selectable on native)
AnimatePresence enterVariant / exitVariant / enterExitVariantcustom={{ ... }} plus a variant on the child that emits enter: / exit: clauses
Avatar.Fallback delayMsdelay
Sheet disableTransparencyHidedisableHideWhenClosed
Select.Item indexremove it, registry order is authoritative
TamaguiProvider / ThemeProvider disableRootThemeClass and themeClassNameOnRootcreateTamagui({ settings: { addThemeClassName } })
@tamagui/input v1 Input / TextAreathe current Input and TextArea from @tamagui/input
Button from @tamagui/buttonButton from tamagui; @tamagui/button ships ButtonFrame, ButtonText, ButtonIcon and useButton only
isWindowDefinedisBrowser
createMedia from @tamagui/react-native-media-driverremove the call, createTamagui sets the driver up
getExpandedShorthandsgetExpandedShorthand(key, props) when behavior code needs one authored prop and accepts configured shorthands
useProps, useStyle(props), usePropsAndStylekeep conditional values on styled Tamagui components; use splitStyleProps only when a wrapper must partition authored props
createCheckbox sizeAdjustexplicit sizing math or component styles
animateOnlyproperties inside the transition, see Migrate transition values
transition array form, default, per-property typethe v3 transition grammar
@tamagui/animations-moti@tamagui/animations-reanimated
@tamagui/config/reanimated@tamagui/config/animations-reanimated
accept option in styled()piece-typed props (e.g. StylePiece) or real style keys
'...size' / '...fontSize' spread variant keyscodemod writes styled.dynamic<TokenType>(...); report flags sibling-prop reads for .resolve
':string' / ':number' / ':boolean' type-key variantscodemod writes one typed dynamic and combines object returns with typeof; report flags other bodies and mixed exact keys
'...' catch-all variant keyreport only; choose the explicit styled.dynamic<YourValue>(...) generic yourself
Removed
focusable
Replacement
tabIndex
Removed
fullscreen
Replacement
explicit position and inset props
Removed
themeInverse
Replacement
theme="inverse"
Removed
<Theme inverse>
Replacement
<Theme name="inverse">
Removed
Sheet.Frame
Replacement
Sheet.Container plus Sheet.Background
Removed
Component.styleable(fn)
Replacement
createStyledHOC(Component, fn)
Removed
createStyledHOC(Component)<Props>(fn)
Replacement
createStyledHOC(Component, (props: Props, ref) => ...)
Removed
forwardRef wrapper statics
Replacement
direct refs and normal component composition
Removed
inlineWhenUnflattened
Replacement
remove it
Removed
deprecated UI kit aliases
Replacement
import the current component names
Removed
old platform condition keys
Replacement
use flat web:, native:, ios:, android:, etc. clauses
Removed
forceRemoveScrollEnabled
Replacement
disableRemoveScroll with inverted intent
Removed
selectable on Text
Replacement
userSelect (core maps it to RN’s selectable on native)
Removed
AnimatePresence enterVariant / exitVariant / enterExitVariant
Replacement
custom={{ ... }} plus a variant on the child that emits enter: / exit: clauses
Removed
Avatar.Fallback delayMs
Replacement
delay
Removed
Sheet disableTransparencyHide
Replacement
disableHideWhenClosed
Removed
Select.Item index
Replacement
remove it, registry order is authoritative
Removed
TamaguiProvider / ThemeProvider disableRootThemeClass and themeClassNameOnRoot
Replacement
createTamagui({ settings: { addThemeClassName } })
Removed
@tamagui/input v1 Input / TextArea
Replacement
the current Input and TextArea from @tamagui/input
Removed
Button from @tamagui/button
Replacement
Button from tamagui; @tamagui/button ships ButtonFrame, ButtonText, ButtonIcon and useButton only
Removed
isWindowDefined
Replacement
isBrowser
Removed
createMedia from @tamagui/react-native-media-driver
Replacement
remove the call, createTamagui sets the driver up
Removed
getExpandedShorthands
Replacement
getExpandedShorthand(key, props) when behavior code needs one authored prop and accepts configured shorthands
Removed
useProps, useStyle(props), usePropsAndStyle
Replacement
keep conditional values on styled Tamagui components; use splitStyleProps only when a wrapper must partition authored props
Removed
createCheckbox sizeAdjust
Replacement
explicit sizing math or component styles
Removed
animateOnly
Replacement
properties inside the transition, see Migrate transition values
Removed
transition array form, default, per-property type
Replacement
the v3 transition grammar
Removed
@tamagui/animations-moti
Replacement
@tamagui/animations-reanimated
Removed
@tamagui/config/reanimated
Replacement
@tamagui/config/animations-reanimated
Removed
accept option in styled()
Replacement
piece-typed props (e.g. StylePiece) or real style keys
Removed
'...size' / '...fontSize' spread variant keys
Replacement
codemod writes styled.dynamic<TokenType>(...); report flags sibling-prop reads for .resolve
Removed
':string' / ':number' / ':boolean' type-key variants
Replacement
codemod writes one typed dynamic and combines object returns with typeof; report flags other bodies and mixed exact keys
Removed
'...' catch-all variant key
Replacement
report only; choose the explicit styled.dynamic<YourValue>(...) generic yourself

V2 could spread one style across its base prop, pseudo-style objects such as hoverStyle, media objects such as $sm, and platform objects. The removed hooks had to gather those objects to produce resolved props and styles. In v3, the base value and every conditional clause for a style live on that style’s single property:

<View opacity="1 hover:0.7 sm:0.8" />

The styled component interprets that value. Pass the property through instead of flattening the component’s styles in JavaScript. If a wrapper must separate style props from regular props, use splitStyleProps:

import { splitStyleProps } from '@tamagui/core'
const [styleProps, regularProps] = splitStyleProps(props, {
expandShorthands: true,
})

Pass a filter map to select only some canonical style keys. Rejected style props remain in the second object, which supports cases such as splitting text props from frame props without another loop. The filter may also be a callback that receives (key, value, originalKey, isStyleProp).

If behavior code needs one authored property and supports a configured shorthand, read it with the singular helper:

import { getExpandedShorthand } from '@tamagui/core'
const zIndex = getExpandedShorthand('zIndex', props)

getExpandedShorthand only chooses the longhand or shorthand property. It does not resolve tokens or choose an active conditional clause. Neither does splitStyleProps. Use useMedia() or useTheme() when behavior itself needs active responsive or theme state.

The curried createStyledHOC form is a separate signature break from the .styleable() rename. Move the custom prop type onto the render parameter when you combine the two calls:

// before
const Card = createStyledHOC(CardFrame)<CardProps>((props, ref) => ...)
// after
const Card = createStyledHOC(CardFrame, (props: CardProps, ref) => ...)

Spread, resolver-name, and type-key variants have no runtime matching in v3. A leftover '...size', Size, or number key only matches that exact literal. The flat-values codemod rewrites known spread and type keys to one branded dynamic. Its report leaves catch-all keys, mixed exact branches, and sibling-prop reads for manual migration:

// before
variants: {
size: {
'...size': (val, { tokens }) => ({ padding: tokens.size[val] }),
},
} as const
// after
variants: {
size: styled.dynamic<SizeTokens>((val, { tokens }) => ({
padding: tokens.size[val],
})),
} as const

accept is removed

The accept configuration option in styled() has been removed in v3. It previously handled two patterns:

  1. Piece-typed style props: Props that accept style objects (such as activeStyle on Checkbox, ToggleGroup, and Tabs, or contentContainerStyle on ScrollView) now accept a StylePiece created with style().
  2. Input color style keys: placeholderTextColor, selectionColor, cursorColor, and selectionHandleColor on Input and TextArea are now real style properties. On web, they emit CSS rules (::placeholder, caret-color, ::selection). On native, they map through the React Native compatibility layer.
// before
const MyScrollView = styled(
ScrollView,
{},
{
accept: {
contentContainerStyle: 'style',
} as const,
}
)
// after
import { ScrollView, style } from 'tamagui'
const contentStyle = style({ padding: 16 })
<ScrollView contentContainerStyle={contentStyle} />

Examples:

// before
<View focusable fullscreen />
// after
<View tabIndex={0} position="absolute" inset={0} />
// before
<Text selectable>Copy me</Text>
<Select.Item index={0} value="first" />
// after
<Text userSelect="text">Copy me</Text>
<Select.Item value="first" />
// before
<Button themeInverse />
<Theme inverse>
<Card />
</Theme>
// after
<Button theme="inverse" />
<Theme name="inverse">
<Card />
</Theme>

Web-standard style keys

Files you convert to html.* should use web-standard style keys. View and Text keep accepting the React Native keys with no warnings, so only rename where you adopt the web contract. Find legacy keys with:

Terminal

rg "marginHorizontal|marginVertical|paddingHorizontal|paddingVertical|marginStart|marginEnd|paddingStart|paddingEnd|textAlignVertical|writingDirection|includeFontPadding|elevation|shadowColor|shadowOffset|shadowOpacity|shadowRadius"

Use these replacements:

Legacy keyReplacement
marginHorizontalmarginInline (mx stays physical left/right, not RTL-aware inline start/end)
marginVerticalmarginBlock (my also works)
paddingHorizontalpaddingInline (px also works)
paddingVerticalpaddingBlock (py also works)
marginStartmarginInlineStart
marginEndmarginInlineEnd
paddingStartpaddingInlineStart
paddingEndpaddingInlineEnd
elevationboxShadow
shadowColor, shadowOffset, shadowOpacity, shadowRadiusone boxShadow string
textAlignVerticalverticalAlign
writingDirectiondirection
includeFontPaddingremove it, standard CSS font metrics govern text bounds
Legacy key
marginHorizontal
Replacement
marginInline (mx stays physical left/right, not RTL-aware inline start/end)
Legacy key
marginVertical
Replacement
marginBlock (my also works)
Legacy key
paddingHorizontal
Replacement
paddingInline (px also works)
Legacy key
paddingVertical
Replacement
paddingBlock (py also works)
Legacy key
marginStart
Replacement
marginInlineStart
Legacy key
marginEnd
Replacement
marginInlineEnd
Legacy key
paddingStart
Replacement
paddingInlineStart
Legacy key
paddingEnd
Replacement
paddingInlineEnd
Legacy key
elevation
Replacement
boxShadow
Legacy key
shadowColor, shadowOffset, shadowOpacity, shadowRadius
Replacement
one boxShadow string
Legacy key
textAlignVertical
Replacement
verticalAlign
Legacy key
writingDirection
Replacement
direction
Legacy key
includeFontPadding
Replacement
remove it, standard CSS font metrics govern text bounds
// before
<View marginHorizontal={12} marginVertical={8} writingDirection="rtl" />
// after
<html.div marginInline={12} marginBlock={8} direction="rtl" />

On native, direction keeps the Yoga direction and also maps to writingDirection, while verticalAlign maps to textAlignVertical, so web-authored styles apply on both platforms. Multi-value strings such as margin="10px 20px" and gap="10px 20px" expand per CSS slot order on native.

Image source compatibility

Image prefers web-standard src for URLs and native require() results. React Native’s source remains supported during migration, but is deprecated:

// before
<Image source={{ uri: 'https://example.com/photo.jpg', width: 200, height: 200 }} resizeMode="cover" />
// after
<Image src="https://example.com/photo.jpg" width={200} height={200} objectFit="cover" />

The width and height props control layout. Percentage dimensions also work when the parent has defined dimensions.

4. Remove true token references

The default v3 configs no longer export the legacy $true token key. The flat-values codemod writes $true on a style prop as 4, the bare key the default config aliased it to, and reports the site with a legacy-true-token warning so you can confirm your config agrees. On the size-typed variant props size and iconSize it writes the boolean instead, which resolves to the component’s default size.

// before
<XStack gap="$true" p="$true" />
<Button size="$true" />
// after
<XStack gap="4" p="4" />
<Button size />

The codemod does not edit token definitions or custom variants, so search config files and any remaining code for the alias:

Terminal

rg "\\\$true|true:" tamagui.config.* src code

Only replace token keys. Boolean variant values like circular or unstyled={false} are unrelated.

5. Control sizes are named

Every control that takes size (Button, Input, Select, Tabs, ListItem, Label, Card, Toggle, Checkbox, RadioGroup, Switch, Slider, Progress, Avatar, Group) accepts the same five names: xs, sm, md, lg, xl. A name is a rung in the shared sizing ladder, never a height: it picks a font size, horizontal and vertical padding, and a radius, and the control ends up its line-height plus padding tall. That keeps the frame, its text, and its icon aligned by construction. The default is md.

Under @tamagui/config/v6 the ladder mirrors shadcn:

namefont size / line-heightpaddingXpaddingYradiusiconheight
xs12 / 168441224
sm14 / 2012661632
md14 / 2016861636
lg16 / 2424861640
xl18 / 28321082048
name
xs
font size / line-height
12 / 16
paddingX
8
paddingY
4
radius
4
icon
12
height
24
name
sm
font size / line-height
14 / 20
paddingX
12
paddingY
6
radius
6
icon
16
height
32
name
md
font size / line-height
14 / 20
paddingX
16
paddingY
8
radius
6
icon
16
height
36
name
lg
font size / line-height
16 / 24
paddingX
24
paddingY
8
radius
6
icon
16
height
40
name
xl
font size / line-height
18 / 28
paddingX
32
paddingY
10
radius
8
icon
20
height
48

Height excludes the 1px border. @tamagui/config/v5 ships its own sizing on the v5 keys with the same geometry pinned, so the same names work under either config. Button, ListItem, Checkbox, RadioGroup, and Switch derive every value from the ladder; add rungs by spreading defaultSizing (see Sizing).

import { Button } from 'tamagui'
<Button size="sm" />
<Button size="lg" icon={Check} />

Token keys and numbers no longer size controls: size on a control only takes a name (or true for the default), so size="4" on a control is a type error. Numbers stay geometry on shapes (Square, Circle, Avatar take size tokens) and font keys on text and icons.

Text components speak Tailwind’s own scale (xs, sm, base, lg, xl, 2xl and up). <Button size="md"> text and <Paragraph size="sm"> text are the same 14/20. Text keeps base rather than md because text-base is what Tailwind users know.

If you also adopt the unstyled primitives from @tamagui/ui, those own no size scale at all, so you provide sizing yourself or through your copied styles.

An explicit undefined prop no longer clears a default

undefined

<Button size={props.size}> with props.size undefined now renders the md default. In v2 an explicitly passed undefined replaced the defaultVariants value and the styled-context value for that key, and the control rendered with no size styles at all. Core now treats an explicit undefined the way React defaultProps do: as absent (and the size resolvers default it too). This applies to every styled component and every createStyledContext provider, not only size. To deliberately turn a default variant off, pass null:

<Button size={maybeSize} /> // undefined falls back to md
<Button size={null} /> // no size rung applied
  • Checkbox, RadioGroup and Switch now step through five real sizes (17, 20, 22, 25 and 28px tall). In the first betas sm, md and lg all rendered at 22px.
  • Tooltip no longer accepts size. It only ever set the arrow px for TooltipSimple, which keeps size for that purpose.
  • @tamagui/ui no longer exports a ListItemIcon component. The behavior package publishes color through ListItemContext; the icon px is the skin’s decision (the tamagui ListItem still ships ListItem.Icon). The ListItemIconProps type stays exported.
  • The unstyled Slider and Slider.Thumb take size as a px number or a font size key. Skin size names (sm, md) belong to the styled tamagui Slider, which resolves them itself.
  • useToggleGroupItem().color is undefined when no color was set, where it used to be an empty string.

6. Replace token stepping

@tamagui/get-token no longer supports runtime scale stepping. Removed APIs:

  • stepTokenUpOrDown
  • getTokenRelative
  • the second options argument to getSize, getSpace, and getRadius
  • options such as shift, bounds, and excludeHalfSteps
// before
const padding = getSpace(size, { shift: -2 })
const radius = getRadius(radiusToken, { shift: 1 })
// after
import { getVariableValue } from 'tamagui'
import { getSpace, getRadius } from '@tamagui/get-token'
const padding = getVariableValue(getSpace(size)) * 0.6
const radius = getVariableValue(getRadius(radiusToken)) * 1.2

If you need to move from 5 to 4, use explicit token keys or a helper that only handles numeric token names. Do not rebuild runtime scale sorting in app code.

7. Migrate line height and audit exact font sizes

V3 changes numeric lineHeight authored on Tamagui components from an absolute value to a ratio on both web and native. The migration command above selects the old source semantics explicitly, so every proven V2 number becomes a px string. The value does not use a magnitude heuristic: V2 lineHeight={1.5} becomes lineHeight="1.5px", while a new V3 ratio stays lineHeight={1.5}.

The codemod follows Tamagui bindings through JSX, styled() styles and variants, resolvers, and inline style objects. It leaves raw React Native and Restyle styles alone. A shared style object, untyped dynamic expression, or extracted token .val is reported for a manual decision at the Tamagui boundary. Rerunning with --source-semantics v2-pixels is safe because explicit px output is idempotent.

Review existing numeric strings separately. The codemod leaves strings unchanged; lineHeight="24" is a ratio unless 24 names a configured font token. Use lineHeight="24px" if that string previously represented a native pixel length.

Font sizes keep their existing contract:

  • fontSize={17} is a numeric platform value and keeps the platform-default line-height path.
  • fontSize="17px" is an exact pixel value. Web passes it through as CSS; native parses it to a number.

Font configuration is a separate boundary. Numeric lineHeight entries remain absolute pixels, including the existing v5 and v6 scales. Use a numeric string such as "1.5" for a relative font token, or "24px" to make an absolute length explicit.

On native, a text node with its own font size and no local line height keeps a runtime inheritance boundary, even when its children are literal text. An ancestor in another module can supply a ratio that must be recomputed at this node’s font size. Token-sized Paragraph and SizableText with their own default line height can still be flattened by the compiler.

// platform-default line height behavior
<Paragraph fontSize={17} />
// exact px behavior
<Paragraph fontSize="17px" />
// V3 relative line height on both platforms
<Paragraph fontSize={20} lineHeight={1.5} />
// explicit absolute line height
<Paragraph lineHeight="24px" />

8. Update FocusScope usage

FocusScope now renders a display: contents wrapper. Function-as-children is removed; pass JSX children directly.

// before
<FocusScope loop>
{({ ref, onKeyDown, tabIndex }) => (
<View ref={ref} onKeyDown={onKeyDown} tabIndex={tabIndex} />
)}
</FocusScope>
// after
<FocusScope loop>
<View />
</FocusScope>

Use noFocus when a surface should temporarily reject all focus:

<FocusScope noFocus>
<Input placeholder="Cannot receive focus" />
</FocusScope>

noFocus takes precedence over trapped and auto-focus behavior. It is web-only; FocusScope remains a no-op on native.

9. Update Dialog, Popover, and Select Adapt usage

Dialog, Popover, and Select now share the same Adapt handoff model. Open state lives above AdaptParent, and adapted sheet content remains mounted until the sheet finishes hiding.

Use the current Adapt anatomy:

<Popover>
<Popover.Trigger />
<Popover.Content>
<Popover.Arrow />
<Adapt.Contents />
</Popover.Content>
<Adapt when="max-md" platform="touch">
<Sheet modal dismissOnSnapToBottom>
<Sheet.Container padding="4">
<Sheet.Background />
<Adapt.Contents />
</Sheet.Container>
<Sheet.Overlay />
</Sheet>
</Adapt>
</Popover>

Component-specific notes:

  • Dialog.Content no longer accepts the no-op size variant.
  • Non-modal Dialog.Content no longer enables RemoveScroll while open.
  • Dialog, Popover, and Select parts own their presence lifecycles.
  • Animation drivers can now complete part-level exits through the typed onTransition lifecycle.
  • Popover.Content forceMount now matches Dialog semantics and disables part presence gating.
  • Removed legacy Popover and Select sheet-controller internals. If app code imported internal helpers like useShowPopoverSheet or useSelectBreakpointActive, remove those imports and use Adapt state instead.

10. Update Select APIs

Select has a few public API changes and parity additions.

Keep your form props. Select still accepts name on web, and custom Select renders hidden <input> elements for it, so the selected value(s) submit with a surrounding form. form associates the control with an external form by id. Both are inert on native.

Do not remove name during migration. Removing it drops the hidden form inputs and breaks form submission. Earlier drafts of this guide told you to delete name; that was wrong.

<form onSubmit={(e) => { e.preventDefault() const data = new FormData(e.currentTarget) console.log(data.get('fruit')) // the selected value }} >
<Select name="fruit" defaultValue="apple">
{/* ...Select.Trigger, Select.Content, items... */}
</Select>
<button type="submit">Save</button>
</form>

Removed from Select root:

  • autoComplete was removed. It never drove autofill in the custom Select, so delete it.

Added or expanded:

  • Select.Separator
  • Select.Content onEscapeKeyDown
  • Select.Content onInteractOutside
  • data-state="open" | "closed" on Select.Trigger
  • data-state="open" | "closed" on the web Select.Viewport
<Select.Content onEscapeKeyDown={(event) => { console.log('escape', event) }} onInteractOutside={(event) => { console.log('outside', event) }} >
<Select.Viewport>
<Select.Group>
<Select.Label>Fruit</Select.Label>
<Select.Item value="apple">
<Select.ItemText>Apple</Select.ItemText>
</Select.Item>
<Select.Separator />
<Select.Item value="orange">
<Select.ItemText>Orange</Select.ItemText>
</Select.Item>
</Select.Group>
</Select.Viewport>
</Select.Content>

11. Update themed icon usage

Themed icons now size token values through the current font size scale, not the space/size token scale. Raw numeric sizes are unchanged.

// v3: aligns with font.size.4
<Search size="4" />
// unchanged: exact numeric size
<Search size={18} />

Themed icons no longer accept Tamagui media or pseudo props directly because they no longer run full style resolution.

// before
<Search size="$4" $sm={{ opacity: 0.6 }} hoverStyle={{ scale: 1.05 }} />
// after
const IconFrame = styled(View, {
opacity: 'sm:0.6',
scale: 'hover:1.05',
})
<IconFrame>
<Search size="4" />
</IconFrame>

Theme color, fill, stroke, size, strokeWidth, style, and testID still work.

12. Check ScrollView web usage

@tamagui/scroll-view now has its own web implementation. It no longer routes through react-native-web.

Covered web surface:

  • scrollTo
  • scrollToEnd
  • getScrollableNode
  • RN-shaped onScroll
  • contentContainerStyle
  • horizontal
  • scroll indicator props

Unsupported old lite/RNW props should be replaced:

  • momentum scroll events
  • snapTo* props
  • keyboardDismissMode
  • any app-specific reliance on RNW’s legacy style system

13. Migrate transition values

V3 has one transition grammar: a CSS transition string, or an object whose keys are the same values under names. The v2 array form, the default key, the per-property type key, inline spring physics, and the animateOnly prop are all gone. The flat-values codemod rewrites every static site, so run it first and hand-migrate what it flags.

V2V3
transition={['quick', { opacity: 'lazy' }]}transition={{ preset: 'quick', opacity: 'lazy' }}
transition={{ default: 'quick' }}transition="quick"
transition={{ x: { type: 'bouncy' } }}transition={{ x: 'bouncy' }}
transition={{ x: { type: 'bouncy', overshootClamping: true } }}transition={{ x: { preset: 'bouncy', spring: { overshootClamping: true } } }}
transition={{ damping: 20, stiffness: 250 }}transition={{ spring: { damping: 20, stiffness: 250 } }}
transition={{ bounciness: 8, speed: 12 }}transition={{ spring: { stiffness: 342.101, damping: 24.684 } }}
animateOnly={['transform', 'opacity']}properties: 'transform, opacity' inside the transition
animateOnly={[]}transition="none"
V2
transition={['quick', { opacity: 'lazy' }]}
V3
transition={{ preset: 'quick', opacity: 'lazy' }}
V2
transition={{ default: 'quick' }}
V3
transition="quick"
V2
transition={{ x: { type: 'bouncy' } }}
V3
transition={{ x: 'bouncy' }}
V2
transition={{ x: { type: 'bouncy', overshootClamping: true } }}
V3
transition={{ x: { preset: 'bouncy', spring: { overshootClamping: true } } }}
V2
transition={{ damping: 20, stiffness: 250 }}
V3
transition={{ spring: { damping: 20, stiffness: 250 } }}
V2
transition={{ bounciness: 8, speed: 12 }}
V3
transition={{ spring: { stiffness: 342.101, damping: 24.684 } }}
V2
animateOnly={['transform', 'opacity']}
V3
properties: 'transform, opacity' inside the transition
V2
animateOnly={[]}
V3
transition="none"

The array form and default were two more ways to say “the base timing”, which is what a bare string and the preset key already say. Per-property type meant a preset name in one place and a spring-or-timing mechanism in another; in v3 a preset name is always a preset name, and physics always live under spring.

bounciness, speed, tension, and friction are removed. The codemod converts them with React Native’s own Origami formulas, so a migrated spring keeps the motion it had.

animateOnly is now part of the transition

animateOnly was an exclusive filter over the properties a transition named, which is what a CSS transition-property list already is. Name the properties in the transition itself:

// before
<Square transition="quick" animateOnly={['transform', 'opacity']} />
// after
<Square transition={{ preset: 'quick', properties: 'transform, opacity' }} />
// or, in string form
<Square transition="transform 200ms, opacity 200ms" />

A property the transition does not name is set rather than animated, on every driver. transition="none" animates nothing and completes exits immediately, which is what an empty animateOnly array did.

A standalone transitionProperty longhand narrows plain-CSS transitions in the browser, but it does not reach the animation drivers. Use properties inside the transition, or the property token in a transition string, so the list means the same thing on all four drivers.

Preset names and values changed

Presets are springs now, defined once and identical on every driver. In v2 the same name was a different motion per driver: bouncy was a 350ms cubic-bezier on the web, a stiffness-120 spring on React Native, and a stiffness-90 spring on Motion. Expect timing to shift slightly and check screens where motion matters.

The '100ms'-style preset names are removed. A duration is CSS in v3, so transition="100ms" and transition="100ms ease-out" work with nothing configured.

14. Update animation and sheet lifecycle callbacks

The untyped, enter-only onDidAnimate prop is gone. Every animated component now takes a typed onTransition that fires at the start and end of enter, exit, and in-place update transitions.

// before
<View animation="quick" onDidAnimate={() => {}} />
// after
<View transition="quick" onTransition={(e) => { // e: { phase: 'start' | 'end', cause: 'enter' | 'exit' | 'update', finished?: boolean } if (e.phase === 'end' && e.cause === 'exit') { // exit finished } }} />

Sheet’s bespoke onAnimationComplete prop is replaced by a sheet-shaped onTransition:

// before
<Sheet onAnimationComplete={() => {}} />
// after
<Sheet onTransition={(e) => { // e: { phase, cause: 'open' | 'close' | 'snap', position, finished? } }} />

onPositionChange is unchanged.

The sheet no longer fades itself. A fully-closed sheet is hidden with display: 'none' instead of by animating opacity to transparent, and there is no overlay fade baked in. If you relied on the built-in fade, drive it yourself with one of the two overlay fade patterns. unmountChildrenWhenHidden semantics are unchanged.

The stable-2.4.x disableTransparencyHide prop is renamed to disableHideWhenClosed (matching unmountChildrenWhenHidden). The old name is removed, not aliased.

Sheet.Handle ships no opacity of its own in v3. If you used the default handle dim or its fade-in on open, set opacity (and an open-driven opacity) on your own Sheet.Handle.

15. Update active-state background colors

Checkbox and Switch checked states, Tabs active tab, and Toggle Group active toggle used to default to backgroundActive. V3 components now read background-press. The frozen v5 themes still retain backgroundActive, so audit the component behavior even when keeping Config v5; the presence of the old token alone does not mean the config must be migrated to v6.

Each of these components applies its default active background only when you do not pass activeStyle, and a supplied activeStyle wins because it is applied last. Override the active color per-instance with activeStyle:

<Checkbox activeStyle={{ backgroundColor: 'blue-500' }} />
<Tabs.Tab activeStyle={{ backgroundColor: 'blue-500' }} />

If a custom theme defined backgroundActive to color these states, move that color to background-press or pass it through activeStyle.

// before
const themes = { light: { backgroundActive: '#ddd' } }
// after
const themes = { light: { 'background-press': '#ddd' } }

16. Optional: adopt the Tailwind frontend

Tailwind authoring is optional for v3. Select it by importing components from @tamagui/tailwind; there is no global config mode.

import { Text, View, styled } from '@tamagui/tailwind'

Regular Tamagui components and Tailwind components can share a tree. Each component has one styling language: Tailwind components accept className, while components imported from tamagui or @tamagui/core accept Tamagui style props.

import { View as TamaguiView } from 'tamagui'
import { View as TailwindView } from '@tamagui/tailwind'
<TamaguiView backgroundColor="blue-100">
<TailwindView className="rounded-lg p-4" />
</TamaguiView>

Read Tailwind for Tamagui for current utility coverage and known gaps.

17. Migrate createTamagui defaultProps

createTamagui({ defaultProps }) is removed in v3. Each of its uses has a more direct replacement:

  • Styling defaults move to theme updates: declare the value once in variables, read it in your component styles, and redefine it per subtree on <ThemeUpdate> instead of forcing props onto every instance of a component.
  • A component’s default look belongs in the component itself — v3 components are behavior primitives plus styles you own, so edit your copied component instead of patching it from config.
  • Prop propagation to children uses createStyledContext, which makes the shared keys typed props on every styled component that consumes the context.
// before
createTamagui({
defaultProps: { Square: { backgroundColor: 'violet6' } },
})
// after
createTamagui({
variables: { squareBackground: 'violet-500' },
})
// in your Square styles
styled(View, { backgroundColor: 'squareBackground' })

18. Migrate off the imperative Toast

v3 removes the old imperative Toast. @tamagui/toast (and the Toast export from tamagui) is now the composable + toast() API, and it’s the only Toast — there is no longer a separate “v2” toast entry point. These removed exports have no replacement under the same name:

Removed (v2)Replacement (v3)
ToastProvider + ToastViewport<Toast> + <Toast.Viewport><Toast.List /></Toast.Viewport>
useToastController().show(title, opts)toast(title, { description: opts.message, ...opts })
useToastController().hide()toast.dismiss()
useToastState()rendering is handled by <Toast.List>; use useToasts() for the list
Removed (v2)
ToastProvider + ToastViewport
Replacement (v3)
<Toast> + <Toast.Viewport><Toast.List /></Toast.Viewport>
Removed (v2)
useToastController().show(title, opts)
Replacement (v3)
toast(title, { description: opts.message, ...opts })
Removed (v2)
useToastController().hide()
Replacement (v3)
toast.dismiss()
Removed (v2)
useToastState()
Replacement (v3)
rendering is handled by <Toast.List>; use useToasts() for the list

The toast() function is global (no provider needed around callers). Mount the toast parts once at the app root and trigger toasts from anywhere:

// before (v1)
import { ToastProvider, ToastViewport, useToastController } from '@tamagui/toast'
function Root({ children }) {
return (
<ToastProvider>
{children}
<ToastViewport />
</ToastProvider>
)
}
function Save() {
const toast = useToastController()
return <Button onPress={() => toast.show('Saved!', { message: 'All good.' })}>Save</Button>
}
// after (v3)
import { Toast, toast } from 'tamagui/toast'
function Root({ children }) {
return (
<>
{children}
<Toast>
<Toast.Viewport>
<Toast.List />
</Toast.Viewport>
</Toast>
</>
)
}
function Save() {
return <Button onPress={() => toast('Saved!', { description: 'All good.' })}>Save</Button>
}

19. Replace ThemeableStack and SizableStack

ThemeableStack and SizableStack (and their themeableVariants / themeableVariantStyles) are removed from @tamagui/stacks. Their one good idea — composable chrome variants — moves into the copied layer as the Surface fixture and its facets.

  • Extending ThemeableStack: use styled(YStack, { … }) and add the styles you actually used inline. The removed variants map to plain styles:

    // before
    const Panel = styled(ThemeableStack, { bordered: true })
    // after
    const Panel = styled(YStack, { borderWidth: 1, borderColor: 'border-color' })
  • elevate → a shadow read from generics.

  • bordered → borderWidth + borderColor: 'border-color'.

  • circular → borderRadius: 100_000 (+ equal width/height for a circle).

  • For panels/wells/toolbars, copy the Surface fixture from the registry (npx shadcn add surface). It is a YStack + a level variant (theme level2-4) + the composable facets filled outlined elevated rounded interactive, with nothing on by default:

    <Surface level={2} filled rounded interactive />

    Facets read theme generics (background, border-color, …), never the color scale, so they restyle for free under any level or theme change.

20. Verification checklist

Run validation at the layer your app changed.

Code checks:

yarn dlx tamagui check tsc --noEmit

Build checks:

yarn build

Runtime checks:

  • Open every screen that uses Sheet, especially fit sheets and sheets with scrollable content.
  • Test Dialog, Popover, and Select at the breakpoints where they Adapt to Sheet.
  • Verify close animations complete before content unmounts.
  • Verify any onDidAnimate / Sheet onAnimationComplete callbacks were moved to onTransition and fire as expected.
  • Verify sheet overlays still fade if you depended on the old built-in fade.
  • Verify Checkbox, Switch, Tabs, and Toggle Group active/checked states still read the way you want after the backgroundActive removal.
  • Inspect Avatar, Tabs, and Group anywhere the V2 default chrome was part of the design. Add app-owned skins where needed.
  • Verify non-modal Dialogs do not lock page scroll.
  • Verify keyboard focus, Escape, outside click, and return focus behavior.
  • Inspect icons next to text at every app size token you use.
  • Check any custom config tokens that used true, numeric font sizes, or token stepping.
  • After changing Tamagui config, restart Metro with expo start -c before evaluating compiled output.
  • If you use Tailwind mode, compare web and native output for your supported utility classes.

What a clean typecheck will not catch

A flat value is a string program, so most of this grammar is not something TypeScript can check. The first large app through this migration reached zero type errors with several things quietly broken, and none of them would have been found without running it. Each item below is a real defect from that migration.

Token detection by sigil. Code shaped like value[0] !== '$' was a correct way to tell a token from a raw color in v2 and is wrong in v3, where tokens have no sigil. One icon helper doing this passed every token straight through as a literal SVG fill and blanked every icon in the app. Resolve through the theme and treat what it does not know as a literal value:

// before: the shape of the string decided
const value = color[0] !== '$' ? color : theme[color]?.get()
// after: the theme decides
const value = theme[color]?.get() ?? color

Conditions the codemod cannot reach. The codemod converts a style object at a conversion site: a JSX attribute list, a styled() config, or a variant branch. A hoverStyle returned from a helper or spread in conditionally is none of those, so it survives, and v3 forwards an unrecognized hoverStyle prop rather than applying it. The interaction silently stops happening.

Note that hover and press now ride the property they modify, so a helper that used to return a separate hoverStyle object has to own the resting value too. A caller that still sets its own bg next to the spread will overwrite the whole program:

// v2: two different props, both applied
<XStack bg={resting} {...rowStates(active)} />
// v3: one program, so the helper returns all of it
<XStack {...rowStates(active, resting)} />

Flat values are space separated, so any color spliced into a program has to be space free. rgba(0,0,0,0.5) is fine and rgba(0, 0, 0, 0.5) splits into garbage clauses.

Variants that look like style props. The codemod converts style props and the size-typed variant props size and iconSize, since v3 reads those values as size-scale keys. On one element it rewrites color="$color12" to color="color12" and size="$2" to size="2". It drops the sigil without renaming the ramp, so apply the hyphen table above afterwards. A custom variant that takes a token value keeps its $ spelling, and it typechecks while resolving to nothing, so grep for ="$ after the run.

Half steps are renamed, not removed. $0.5 becomes 0-5 and $1.5 becomes 1-5. Keeping the same v5 scale preserves the resolved values through this spelling change. Moving from v5 to v6 also changes the scale values; it needs separate mapping and visual review, even when a token has the same name.

undefined inside an interpolated value. A conditional whose false branch is undefined becomes the literal string "undefined" once it is wrapped in a template literal, which produces invalid padding, color, and font values. Keep the conditional as an expression instead:

// wrong: renders the string "undefined"
py={`${compact ? undefined : '1'}`}
// right
py={compact ? undefined : '1'}

Provider order. SafeAreaProvider now has to wrap TamaguiProvider. The v3 provider mounts a tracker that reads safe-area context to publish the inset tokens, so the v2 nesting throws NO_INSETS_ERROR at startup and the whole app renders an error screen. The message names SafeAreaProvider, which points away from the upgrade.

Quick reference

Terminal

# things that typecheck and are still wrong rg "\[0\] [!=]== '\\\$'|startsWith\('\\\$'\)" # token detection by sigil rg "hoverStyle|pressStyle|focusStyle|enterStyle|exitStyle" # conditions the codemod missed rg '\b(size|fontSize)="\$' # variants left with a sigil rg '`\$\{[^`]*undefined[^`]*\}`' # "undefined" interpolated into a value rg 'animateOnly|transition=\{\[' # v2 transition spellings # duplicate package / lockfile scan npx tamagui check # Sheet anatomy still requiring migration rg 'Sheet\.Frame' # APIs requiring migration or review (Config v5 remains supported) rg "createStyledHOC|focusable|fullscreen|themeInverse|<Theme inverse|Sheet\.Frame|styleable\(|inlineWhenUnflattened|\\\$true|getTokenRelative|stepTokenUpOrDown|forceRemoveScrollEnabled|sizeAdjust|selectable=|Select\.Item.*index|enterVariant|exitVariant|enterExitVariant|delayMs|disableTransparencyHide|disableRootThemeClass|themeClassNameOnRoot|isWindowDefined|getExpandedShorthands|usePropsAndStyle|useProps|useStyle|backgroundActive|@tamagui/config/v[34]|@tamagui/animations-moti|@tamagui/babel-plugin|animateOnly"

v1 to v3 path

For v1 apps, migrate in two passes:

  1. Apply the v1 to v2 changes: React 19, React Native 0.81+, TypeScript 5, Config v5, animation to transition, tag to render, Stack to View, space to gap, ARIA/web prop renames, native setup imports, and v2 component API changes.
  2. Apply the v2 to v3 changes in this guide.

The CLI migration prompt can generate an AI-agent checklist for either path:

yarn dlx tamagui migrate --from v2 npx tamagui migrate --from v1