Tamagui 3

React Strict DOM and Tailwind syntax on one compiler, plus simpler APIs, a web-first core, and stronger composition.

Nate Wienert

·

·

23 min read

Tamagui 3 removes legacy aliases and hidden wrappers, moves the public API to web-standard names, and runs Tamagui props and Tailwind utility classes through one compiler.

Version 3 still targets one UI codebase for React Native and the web. It removes the compatibility layers that made the internals hard to follow, so the public surface is smaller, tokens are more predictable, and overlays compose better. The Tailwind frontend runs utility classes through the same compiler, theme system, and native output.

Give this blog post and the upgrade guide to your coding agent before migrating:

Version 3 has three goals:

  • Simplify the core. Deprecated props and compatibility helpers are gone, so component behavior is easier to type, inspect, and optimize.
  • Make web the public API language. Prefer tabIndex, ARIA props, CSS-like style names, and explicit layout props, while Tamagui maps what it can to native.
  • Unify syntax choices. Tamagui props and Tailwind classes now flow through one compiler and one token system instead of competing styling stacks.

React Strict DOM and Tamagui Tailwind

The two headline features of v3 are React Strict DOM and a Tailwind frontend selected by package import.

React Strict DOM makes html the authoring surface. You write real semantic elements, and they render as DOM on web and as their React Native equivalents on native. The Tailwind frontend puts utility classes in className on that same surface:

import { html } from '@tamagui/tailwind'
export function Card() {
return (
<html.div className="flex flex-row items-center gap-3 p-4 rounded-lg bg-slate-100">
<html.div className="w-10 h-10 rounded-full bg-blue-500" />
<html.div className="flex-1 h-2 rounded-md bg-slate-300" />
</html.div>
)
}

On web, the compiler extracts those utilities to atomic CSS. On iOS and Android, the same string resolves to React Native styles. Components imported from tamagui keep the regular prop frontend, so both authoring styles can share a tree without a global mode.

Read the full Tamagui Tailwind post for compiler details and current utility coverage.

Styled and unstyled, by import path

Every component now comes in two halves. The tamagui package is the styled kit: Button, Dialog, Select, and the rest, with the default skin on. The per-component packages and @tamagui/ui hold the unstyled half: the behavior hooks and the parts, with no styles of their own.

// the styled kit
import { Button } from 'tamagui'
// the behavior and parts, for a button of your own
import { ButtonFrame, ButtonText, useButton } from '@tamagui/button'

tamagui/unstyled re-exports @tamagui/ui for apps that want the whole behavior layer under one import. @tamagui/tailwind is the same primitive layer with Tailwind authoring. The Styled, Unstyled, and Tailwind switch on the v3 documentation rewrites every example between them.

Conditional styles are flat values

Tokens and conditional styles now live in one property value. A bare string resolves against the active config first, and a raw number stays a platform value. The conditions come in a string form and an object form. Both compile to the same CSS, mix freely per prop, and work in styled() definitions, in variant values, and on variant props.

// before
<View bg="$background" hoverStyle={{ bg: '$backgroundHover' }} $sm={{ p: '$6' }} p="$4" />
// after, string form
<View bg="background hover:background-hover" p="4 sm:6" />
// after, object form
<View bg={{ default: 'background', hover: 'background-hover', 'sm:hover': 'gray-4' }} scale={{ press: 0.98 }} />
// the same values in styled()
const Card = styled(View, {
bg: 'background hover:background-hover',
p: '4 sm:6',
scale: { press: 0.98 },
})

Object keys are the same modifiers strings use, with default for the base value and colons joining chains (dark:sm:hover). Strings are the shortest way to write a condition or two. Objects keep numbers as numbers, read better once conditions stack up, and can be built in code. An object with no modifier keys, like shadowOffset={{ width: 0, height: 2 }}, passes through untouched.

Both forms are on by default. Set settings.styleValueSyntax to 'string' or 'object' to keep one. The types narrow to that form, the runtime warns on the other in development, and tamagui generate-prompt and the agent skills only show the form you chose.

Config v6 also renames multi-word theme keys to kebab case, such as border-color and background-hover. Run the flat-values codemod in report mode first, then use --write for conversions it can prove safe. The v3 upgrade guide lists the commands, the full rename table, and the cases that still need manual review.

A language server, for every editor

Moving conditions into the value means TypeScript cannot describe the whole grammar. The full cross product is over a million union members. TypeScript refuses to represent that and returns zero completions instead of a partial list. Types still cover each prop’s tokens and the bare modifier prefixes. The rest moved into a language server, written in Rust and shipped as one binary:

yarn add --save-dev @tamagui/lsp

@tamagui/lsp and its eight platform binaries are on npm. The VS Code extension has not reached the Marketplace yet, so for now point any editor with a generic LSP client at the tamagui-lsp binary the package installs.

It gives completions filtered as you type, diagnostics with a “did you mean” for a misspelled value, hover that resolves a token per theme, and inline color swatches. Completing a clause replaces only that clause, so bg="background hover:back" finishes without eating the background you already wrote.

It speaks LSP instead of plugging into tsserver, so Neovim, Helix, Zed, Emacs, Sublime, and JetBrains get the same engine from a few lines of config. The VS Code extension is a thin wrapper that spawns the same binary.

It picks up config changes as soon as the compiler writes them, with no editor restart. It also keeps working while you type. A file mid-edit is usually syntactically invalid, so it parses with tree-sitter, which keeps the sites you already wrote instead of discarding the whole file on the first error.

Style precedence is authored order

Tamagui props, style, className, and Tailwind utilities all go through one compiler, so they resolve under one rule. Every contribution joins a single forward pass, and later output wins per property.

Base styled() values come first, then variants, then call-site props. With the Tailwind frontend, className utilities sit in the same tier as call-site props, so className is not a privileged tier that always wins. The style prop comes last and wins over both, as it does in React.

Attribute order matters. Reordering authored props can change the result, including in the Tailwind class pipeline.

// bg-blue-500 wins: it is authored after backgroundColor
<View backgroundColor="red-9" className="bg-blue-500" />
// red-9 wins: it is authored after the class
<View className="bg-blue-500" backgroundColor="red-9" />

That rule decides which prop wins and stops at the prop boundary. Inside one flat value, clause order does not matter. Clauses rank by platform first, then by how many conditions they carry, then by category (media, container, theme, group, state). These two are identical, and hover: wins in both because state outranks media:

<View bg="red hover:blue sm:green" />
<View bg="red sm:green hover:blue" />

Moving a clause to the end of the string does not make it win. Write a more specific clause instead, such as hover:sm:.

Single-function variants and component resolvers

In v2, variant machinery was the largest source of runtime complexity and TypeScript compilation overhead in styled(). Spread keys like '...size', resolver names like Size, and type-key matching created heavy type computations. Inline variant functions also ran before the component’s complete prop types existed, which forced workarounds for variants that needed to inspect sibling props.

Version 3 replaces that with style fragments in three shapes:

import { SizeTokens, View, style, styled } from 'tamagui'
// 1. Zero inputs: a style piece compiled at definition time
const cardActive = style({ backgroundColor: 'background-press' })
// 2. Value to style piece: branded dynamic variant
const CardBase = styled(View, {
variants: {
// bare declaration: typed and consumed, styled by .resolve
tone: styled.dynamic<'neutral' | 'critical'>(),
// function form: invoked per clause payload so responsive values work
size: styled.dynamic<SizeTokens | number>((val, { tokens }) => {
const value = tokens.size[val] ?? val
return { width: value, height: value }
}),
} as const,
})
// 3. Props to style piece: component resolver
export const Card = CardBase.resolve((props, env) => ({
backgroundColor: props.tone === 'critical' ? env.theme['red-10'] : undefined,
opacity: props.disabled ? 0.5 : undefined,
}))

Precedence tiers

Styles resolve through flat precedence tiers:

0 base styles < 1 variants (definition order) < 2 component resolvers < 3 callsite style props < 4 style prop

Within each tier, later definitions win. Resolver chains run parent-first, so a child component’s resolver overrides its parent’s resolver for conflicting properties. Setting a property to undefined in a resolver removes it, so lower-tier styles show through.

Compiler path

Today the compiler evaluates styled.dynamic callbacks and .resolve chains in its evaluation sandbox wherever a callsite’s props are statically known. A zero-runtime path planned for v4 will extract bodies with static keys and no spreads to static CSS classes, with CSS variables for dynamic values.

Removed deprecated APIs

v3 removes APIs that already had clearer replacements.

// before
<View focusable fullscreen />
<Button themeInverse />
<Theme inverse>
<Card />
</Theme>
// after
<View tabIndex={0} position="absolute" inset={0} />
<Button theme="inverse" />
<Theme name="inverse">
<Card />
</Theme>

The removed set includes focusable, fullscreen, the styleable static, forwardRef component wrappers, inlineWhenUnflattened, deprecated UI kit aliases, old platform style keys, and true token keys.

The runtime-flattening hooks useProps and usePropsAndStyle are also gone, and useStyle now only resolves a style piece. V2 could spread one style across its base prop, pseudo-style objects, media objects, and platform objects, so those hooks had to gather the pieces. V3 keeps the base value and every conditional clause on the style’s one property, such as opacity="1 hover:0.7 sm:0.8". Keep that property on a styled Tamagui component and read or forward only the authored prop behavior needs. Wrappers that must partition authored props can use:

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

Its optional filter map or callback can select a subset, while rejected style props remain in the second object. Use getExpandedShorthand(key, props) for a single shorthand-aware read. Neither helper resolves tokens or active clauses. Use useMedia() or useTheme() when behavior itself needs active responsive or theme state.

If your code still uses focusable, move to tabIndex. If it uses fullscreen, write the layout explicitly with position and inset. If it relies on Tamagui forwarding through wrapper statics, move to ordinary React composition and refs.

.styleable() is the one case that is a rename rather than a removal. It moved off the component and became a standalone factory with the same behavior, so a wrapper still keeps its staticConfig and still accepts pseudo styles, media props, and parent variants through styled():

// before
const MyInput = StyledInput.styleable<Props>((props, ref) => ...)
// after
const MyInput = createStyledHOC(StyledInput, (props: Props, ref) => ...)

An earlier standalone form was curried. V3 removes that intermediate signature, so pass the render function as the second argument:

// before
const MyInput = createStyledHOC(StyledInput)<Props>((props, ref) => ...)
// after
const MyInput = createStyledHOC(StyledInput, (props: Props, ref) => ...)

True tokens are gone

The old $true token aliases have been removed from v3 configs. Component defaults still resolve to the default size, which is 4.

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

Boolean size shorthands in component variants still resolve through the default size path, but authored token keys should use real token names.

Token stepping is removed

@tamagui/get-token no longer sorts a token scale at runtime to find a nearby token. getSize, getSpace, and getRadius now resolve the same key you pass in, and stepTokenUpOrDown / getTokenRelative are gone.

// before
const padding = getSize(size, { shift: -2 })
const radius = getRadius(radiusToken, { shift: 1 })
// after
import { getVariableValue } from 'tamagui'
const padding = getVariableValue(getSize(size)) * 0.6
const radius = getVariableValue(getRadius(radiusToken)) * 1.2

This makes custom configs more predictable. Components that previously stepped tokens now multiply the resolved numeric value with tuned constants.

Exact pixel font values

Font sizes now distinguish numeric values from exact pixel strings. A raw number keeps the platform-default line-height path. A string like "17px" means the literal pixel value.

// platform-default line height behavior
<Paragraph fontSize={17} />
// exact CSS/native pixel value
<Paragraph fontSize="17px" />

The v5 config font size and lineHeight scales are pinned to px strings so v3 keeps v2 visual output. Future config versions can redefine numeric font scales without changing what exact px strings mean.

Inverse themes are SSR-safe

inverse is now a built-in sub-theme. The old themeInverse path is gone.

// before
<Button themeInverse>Save</Button>
<Theme inverse>
<Card />
</Theme>
// after
<Button theme="inverse">Save</Button>
<Theme name="inverse">
<Card />
</Theme>

The new path works with SSR and drops the forced nested light/dark inversion that made theme output hard to predict.

Sheet anatomy is explicit

Sheet.Frame has split into Sheet.Container and Sheet.Background. Sheet.Container owns layout. Sheet.Background owns the visual surface. Sheet.Overlay must be a direct child of Sheet.

// 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>

The flat-values codemod does the mechanical part: every Sheet.Frame becomes a Sheet.Container with a Sheet.Background first child carrying the surface props, and spreads and styled(Sheet.Frame, …) targets are reported for review.

Adapt handoff is unified

Dialog, Popover, and Select now share the same Adapt handoff model. Open state lives above the Adapt parent, adapted Sheet content remains mounted through the slide-out, and parts own their presence animation lifecycles.

<Popover>
<Popover.Trigger />
<Popover.Content forceMount>
<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>

Media flips no longer tear content out mid-exit, overlay pointer locks release correctly, and animation drivers report transition completion per part through the typed onTransition lifecycle.

A first-class animation API

v3 stops shipping baked-in fades and other framework-owned animation. The animation surface that lived inside the drivers is now public, so you can drive those effects yourself.

The same animated-number primitives the drivers use are exported from tamagui:

import {
useAnimationDriver,
useAnimatedNumber,
useAnimatedNumberStyle,
useAnimatedNumbersStyle,
useAnimatedNumberReaction,
} from 'tamagui'

Each is a thin delegation to the configured driver, so your code exercises the same surface first-party components do. On the CSS driver, animated numbers now run on a requestAnimationFrame ticker with real completion callbacks instead of an estimated-duration timer, so completion is accurate on the web.

There is also a typed transition lifecycle on every animated component. It replaces the untyped, enter-only onDidAnimate:

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

Sheet publishes its live position through the same hooks, so drag-linked effects are ordinary user code:

import { Sheet, useAnimatedNumberStyle } from 'tamagui'
function Backdrop() {
const { value, screenSize } = Sheet.useAnimatedPosition()
const style = useAnimatedNumberStyle(value, (y) => {
'worklet'
return { opacity: Math.max(0, 0.5 * (1 - y / screenSize)) }
})
return <Sheet.Overlay style={style} />
}

See the animations docs for the full hook family and the Sheet docs for both overlay fade patterns.

FocusScope has a zero-focus mode

FocusScope now renders a display: contents wrapper and no longer supports function-as-children. Pass children directly.

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

The new noFocus mode makes the active scope focus-inert. Anything that receives focus is immediately blurred, which is useful during handoffs and transitions where focus should be temporarily impossible.

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

More consistent component state

v3 tightens component parity across Dialog, Popover, Select, and Sheet:

  • data-state="open" | "closed" is available on more trigger/content parts.
  • Select.Trigger and the web Select viewport expose data-state.
  • Select.Content accepts onEscapeKeyDown and onInteractOutside.
  • Select.Separator is available for grouped option lists.
  • Popover.Content supports forceMount with Dialog-style semantics.
  • Non-modal Dialog content no longer enables RemoveScroll while open.
  • Dialog.Content no longer accepts the no-op size variant.
<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>

Select multiple across web and native

Select now accepts multiple, with an ordered string[] value on every custom and native path. Selecting an option appends it, selecting it again removes it, and the remaining order stays intact. Custom lists remain open while values are toggled, including Select content adapted into a Sheet.

const [fruit, setFruit] = useState<string[]>([])
<Select multiple name="fruit" value={fruit} onValueChange={setFruit} renderValue={(values) => values.join(' + ')} >
{/* trigger, content, and registered items */}
</Select>

On web, custom Selects submit one repeated form entry per selected value. form associates them with an external form even when the visible list moves through Adapt.Contents. native="web" uses a real <select multiple> and keeps the browser’s own interaction and submission behavior. Plain React Native content uses checkbox-style selected accessibility state. An adapted Sheet owns its dismissal through the overlay, drag-to-bottom, platform back, or controlled state. Select does not inject a completion row.

Select.Value uses registered item labels and separates them with , by default. renderValue remains the escape hatch for SSR, lazy mounting, chips, localized separators, or count summaries. Select.Item no longer needs an index. The old prop is accepted and ignored during the v3 beta.

Field and Form handle validation together

v3 adds a behavior-only Field primitive and upgrades Form into a cross-platform field coordinator. Labels, descriptions, errors, and controls associate automatically. Validation can be a function or a Standard Schema from Zod 4, Valibot, or ArkType.

<Form onSubmit={(values, details) => save(values)}>
<Field name="email" validationMode="onBlur" validate={emailSchema}>
<Field.Label>Email</Field.Label>
<EmailInput required />
<Field.Description>Used for account recovery.</Field.Description>
<Field.Error />
</Field>
<Form.Trigger asChild>
<Button>Continue</Button>
</Form.Trigger>
</Form>

Field validity starts at null, so pristine controls are not announced or styled as valid or invalid. Async checks never delay submit, and stale results cannot replace validation for a newer value. Form collects named values, accepts server or React Hook Form errors, and focuses the first invalid control on web and native.

The package ships no visual styles. Field state is available through data attributes on web and styled context on every platform, so copied skins can respond to invalid, dirty, filled, focused, and the other field flags.

Forms and accessibility fixes

A few smaller components caught up with their documented behavior:

  • Tabs now default activationMode to automatic, matching the docs and the ARIA authoring guidance. If you relied on the old manual-by-default behavior, set activationMode="manual" explicitly. Automatic activation is web-only; native always activates on press.
  • Slider now participates in forms: passing name renders one hidden input per thumb, so a range slider submits repeated entries readable via FormData.getAll(name).
  • Separator renders role="separator" with the correct aria-orientation on web.
  • Avatar.Fallback takes delay, replacing delayMs.

Icons size like text

Themed icons now resolve token sizes through the current font size scale. This means <Icon size="4" /> aligns with 4 text instead of using the space/size token scale.

// before: size="4" followed the size token scale
<Button icon={<Search size="4" />}>Search</Button>
// after: size="4" follows the current font.size.4 scale
<Button icon={<Search size="4" />}>Search</Button>

Raw numeric icon sizes are unchanged. Media and pseudo props are no longer accepted directly on themed icons because icons no longer run full Tamagui style resolution. Wrap the icon when you need responsive or pseudo styling:

const ResponsiveIcon = styled(View, {
opacity: '1 sm:0.6',
scale: '1 hover:1.05',
})
<ResponsiveIcon>
<Search size="4" color="color" />
</ResponsiveIcon>

v3 also drops the bundled @tamagui/lucide-icons-2 package entirely: most apps use a few dozen of its ~1700 icons. Generate only the icons you use with npx @tamagui/cli@beta icons add, which also covers Phosphor, Heroicons, and your own svg files. See Lucide Icons.

ScrollView no longer pulls in react-native on web

@tamagui/scroll-view now ships a clean web implementation instead of routing through react-native-web. It covers the ScrollView surface Tamagui uses: scrollTo, scrollToEnd, getScrollableNode, RN-shaped onScroll, contentContainerStyle, horizontal scrolling, and indicator props.

If you relied on unsupported web-only props through the old lite re-export, like momentum events, snapTo*, or keyboardDismissMode, replace them with direct web behavior or a custom wrapper.

Pick your render tradeoff with optimizeFor

Tamagui tracks exactly which theme and media values each component reads, so a theme or breakpoint change re-renders only the components that used the changed values. That granularity is the right default on web, but every component pays a small tracking cost on its first render.

v3 adds a setting that makes the tradeoff explicit:

export const config = createTamagui({
settings: {
// "updates": granular theme/media tracking, minimal re-renders on change
// "first-render": skip tracking for the fastest initial render
optimizeFor: 'first-render',
},
})

The defaults now differ per platform: web optimizes for updates, native optimizes for first-render. On native, initial render speed dominates the user experience and full-tree re-renders are cheaper without the DOM, so skipping per-key tracking is close to free. Theme and media changes still apply in both modes. Under first-render they re-render more of the tree.

Inline theme values

Themes are tables keyed by name. <ThemeUpdate> adjusts values for one subtree:

import { ThemeUpdate } from 'tamagui'
<ThemeUpdate background-hover="blue-4 dark:blue-2">
<SettingsPanel />
</ThemeUpdate>

Listed keys override and everything else inherits. The theme name, scheme, and sub-theme resolution below are untouched. dark: is the same modifier the style grammar uses everywhere, so any of your theme names works (blue: applies under dark_blue too), as do platform modifiers like ios:. Modifiers that describe an element instead of a subtree, such as hover: and sm:, warn and drop.

Changing values leaves unrelated descendants alone. Tamagui tracks which theme keys each component reads, so on both platforms only the components that read a changed key do any work. On web, styled components skip even that. The values compile to CSS custom properties on the node the update owns, so they restyle through the cascade with no JavaScript. useTheme() and the animation drivers still see the patched values.

You can also declare your own variables in config:

createTamagui({
variables: {
focusRingColor: 'blue-10',
focusRingWidth: 2,
disabledOpacity: 0.5,
pressScale: 0.97,
},
})

These behave like theme keys in style props, useTheme(), and <ThemeUpdate>. Define your focus ring once, read focusRingColor in the components you own, and redefine it per section when a screen wants a different accent. Along with the component changes above, variables replace createTamagui({ defaultProps }), which is removed in v3.

See the Theme inline values documentation for references, units, nesting, and platform behavior.

Migrating

If a coding agent is doing the migration, give it the brief the CLI prints instead of a docs link. The CLI prints the brief for the version you installed:

yarn dlx @tamagui/cli@beta setup # a project that has never had Tamagui npx @tamagui/cli@beta migrate --from v2 # a project already on v2 (or --from v1)

Copy the agent prompt to make the agent stop at the codemod report instead of writing straight through it:

If you are doing it by hand, start with the v3 upgrade guide. It has the breaking change checklist, codemod commands, and verification steps.

Tooling now installs as one package. @tamagui/cli exports the bundler integrations behind subpaths, so @tamagui/cli/vite and @tamagui/cli/metro give you exactly what @tamagui/vite-plugin and @tamagui/metro-plugin do, with one dependency to keep in sync between the CLI and your bundler plugin. The standalone packages are still published and stay the smaller install for a project that only ever uses one bundler.

The short version:

yarn dlx tamagui check npx @tamagui/codemod-flat-values --write \ --report flat-values-report.md \ ./src

Then search for removed APIs:

Terminal

rg 'focusable|fullscreen|themeInverse|<Theme inverse|Sheet\.Frame|styleable\(|inlineWhenUnflattened|\$true|getTokenRelative|stepTokenUpOrDown|getExpandedShorthands|usePropsAndStyle|useProps|useStyle'

What a clean typecheck will not tell you

The first large app through this migration reached zero type errors while several things were broken. A flat value is a string, so the type checker cannot verify most of the grammar. Budget time to look at the running app.

Search for these:

Code that detects tokens by their sigil. Anything shaped like value[0] !== '$' was a correct token test in v2 and is wrong now, because a v3 token has no sigil. One helper doing this passed every token through as a literal color and blanked every icon in the app, with no type error and no warning. Ask the theme whether it knows the name instead of inspecting the string.

Terminal

rg "\[0\] [!=]== '\\\$'|startsWith\('\\\$'\)"

Conditions that the codemod cannot see. The codemod converts style objects it can see at the call site. A hoverStyle returned from a helper or spread in conditionally survives the pass, and v3 forwards the unknown hoverStyle prop instead of applying it, so the interaction stops happening.

Terminal

rg "hoverStyle|pressStyle|focusStyle|enterStyle|exitStyle"

Variants that look like style props. On one element the codemod converted color="$color12" to color="color12" and left size="$2" untouched beside it. size="$2" still typechecks and resolves to nothing, since font sizes are spelled 1 through 14 with no sigil.

Terminal

rg '\b(size|fontSize)="\$'

Half steps are renamed. $0.5 becomes 0-5, $1.5 becomes 1-5, and the values are identical across both packs, so this is a spelling change with no visual effect. Teaching the codemod this moved one app from 1007 clean sites to 1212 and dropped its flagged sites from 279 to 72.

SafeAreaProvider has to wrap TamaguiProvider. The v3 provider mounts a tracker that reads safe-area context to publish the inset tokens, so the old nesting throws NO_INSETS_ERROR and the whole app renders an error screen at startup. The message names SafeAreaProvider, which sends you looking in the wrong place.

Validate at the layer you changed. Typecheck, build, run the web app, and exercise Dialog, Popover, Select, and Sheet on the breakpoints where you use Adapt.