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:
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.
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.
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
npm
bun
pnpm
@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.
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:
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:
Precedence tiers
Styles resolve through flat precedence tiers:
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.
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:
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():
An earlier standalone form was curried. V3 removes that intermediate signature, so pass the render function as the second argument:
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.
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.
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.
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.
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.
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.
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:
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:
Sheet publishes its live position through the same hooks, so drag-linked effects are ordinary user code:
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.
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.
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.Triggerand the web Select viewport exposedata-state.Select.ContentacceptsonEscapeKeyDownandonInteractOutside.Select.Separatoris available for grouped option lists.Popover.ContentsupportsforceMountwith Dialog-style semantics.- Non-modal Dialog content no longer enables RemoveScroll while open.
Dialog.Contentno longer accepts the no-opsizevariant.
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.
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.
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
activationModetoautomatic, matching the docs and the ARIA authoring guidance. If you relied on the old manual-by-default behavior, setactivationMode="manual"explicitly. Automatic activation is web-only; native always activates on press. - Slider now participates in forms: passing
namerenders one hidden input per thumb, so a range slider submits repeated entries readable viaFormData.getAll(name). - Separator renders
role="separator"with the correctaria-orientationon web. - Avatar.Fallback takes
delay, replacingdelayMs.
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.
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:
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:
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:
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:
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
npm
bun
pnpm
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
npm
bun
pnpm
Then search for removed APIs:
Terminal
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
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
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
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.