plans/dom-tailwind-flat-values.md
Design plan, July 2026. This consolidates the current direction for:
@tamagui/tailwind frontend;tamagui/dom entry;The design is intentionally compiler-led. Existing Tamagui remains compatible, while new DOM and Tailwind authoring get strict package and compiler boundaries.
tamagui and @tamagui/core remain regular Tamagui.@tamagui/inline package.@tamagui/tailwind.styleMode.tamagui/dom and
@tamagui/core/dom.style(), not css.create().style(definition) uses the same style-definition grammar as
styled(Component, definition), with the component argument removed.tailwind-merge is removed from Tamagui.$ sigil.import { View, Text, html, styled } from 'tamagui'
const Card = styled(View, {
p: '$4',
bg: '$surface :hover($surfaceHover)',
})
export function Example() {
return (
<html.main p="$4">
<html.h1 color="$color">Account</html.h1>
<Card>Content</Card>
</html.main>
)
}
The lower-level equivalent remains:
import { View, Text, html, styled } from '@tamagui/core'
Regular Tamagui retains:
styled();View, Text, and component behavior;The v4 style direction changes how conditions are written, while the package and component model remain familiar.
import { View, Text, html, styled } from '@tamagui/tailwind'
export function Example() {
return (
<html.main className="p-4 bg-surface">
<html.h1 className="text-color">Account</html.h1>
<View className="p-4 hover:bg-surface-hover">Content</View>
</html.main>
)
}
@tamagui/tailwind owns:
className authoring;styled() types;It shares the renderer, config, tokens, themes, events, refs, accessibility,
animations, and normalized style output with core. It does not import the
@tamagui/core root or its inline-style frontend.
import { html, style } from 'tamagui/dom'
// lower-level alias: @tamagui/core/dom
const root = style({
display: 'flex',
padding: 16,
backgroundColor: '$surface :hover($surfaceHover)',
})
const heading = style({
color: '$color',
fontSize: 24,
})
export function Example() {
return (
<html.main style={root}>
<html.h1 style={heading}>Account</html.h1>
</html.main>
)
}
style() creates one style handle per call. It does not return a namespace
containing named sub-objects.
Conceptually:
styled(Component, definition)
style(definition)
Both consume the same style-definition grammar. styled() binds the
definition to a component, while style() returns an opaque compiled handle
for the DOM style prop.
The standalone entry contains:
html.*;style();It contains neither regular Tamagui style props nor the Tailwind parser.
The public name is Tamagui DOM or DOM mode. The word strict describes the
contract but is not part of the entrypoint or product name.
regular Tamagui props ──────┐
│
Tailwind class candidates ──┼─> shared property/condition/value IR
│ │
DOM style() definitions ────┘ ├─> web CSS and semantic tags
└─> native style records and primitives
The shared runtime owns:
The frontends own only authoring syntax, types, parsing, and compiler lowering.
Each strict DOM component receives exactly one styling language based on its import source.
| Import | DOM styling language |
|---|---|
tamagui, @tamagui/core | regular Tamagui style props |
@tamagui/tailwind | Tailwind className |
tamagui/dom, @tamagui/core/dom | style() handles |
Examples:
// regular Tamagui
<html.div p="$4" bg="$surface :hover($surfaceHover)" />
// Tailwind
<html.div className="p-4 bg-surface hover:bg-surface-hover" />
// standalone DOM
<html.div style={root} />
The strict inline DOM types reject Tailwind className. The strict Tailwind
DOM types reject Tamagui style props. Standalone DOM accepts compiled style
handles rather than either frontend.
Ordinary Tamagui components may retain raw className and style where needed
for existing web and React Native interoperability. Core never interprets a
raw className as Tamagui Tailwind syntax.
All conditional styling belongs to the individual property value. Top-level style definitions stay flat.
The new form replaces:
hoverStyle, pressStyle, focusStyle, and other pseudo-style objects;$theme-* sub-objects;$platform-* sub-objects;Canonical form:
bg="$red :hover($green) :light($color1/50) :dark:hover($color2/20)"
Compact input may be accepted:
bg="$red:hover($green):dark:hover($blue)"
Documentation and formatting always use one space before each conditional clause:
bg="$red :hover($green) :dark:hover($blue)"
This is a property-centered conditional program. It reaches a similar intermediate representation to StyleX and React Strict DOM conditional value objects, with a surface that composes naturally through JSX props.
program = base-value? clause*
clause = condition+ "(" property-value ")"
condition = ":" registered-condition
Example parse:
[
{ when: [], value: '$red' },
{ when: ['hover'], value: '$green' },
{ when: ['light'], value: '$color1/50' },
{ when: ['dark', 'hover'], value: '$color2/20' },
]
A program may omit its base:
bg=":hover($green)"
Empty clauses are invalid:
// compile error
bg="$red :hover()"
Property values with defined semantics handle clearing. Examples include
none, transparent, initial, or unset where supported. Empty
parentheses do not mean deletion.
The parser cannot split on colons, whitespace, commas, slashes, or parentheses. All appear in valid CSS values:
background="linear-gradient(to right, red 0%, blue 100%)"
shadow="0 2px 8px rgb(0 0 0 / 20%) :hover(0 4px 16px rgb(0 0 0 / 30%))"
backgroundImage="url(https://example.com/a:b)"
width="calc(100% - var(--sidebar-width))"
fontFamily="'IBM Plex Sans', sans-serif"
Parsing follows CSS component-value rules:
:<registered-condition>( before interpreting text as a clause.The compiler and runtime parser share one implementation and one test corpus.
Conditions form one registry assembled from built-ins and Tamagui config.
Initial categories:
hover, press, active, focus, focusVisible,
focusWithin, disabled, checked, selected, open, invalid;light, dark, and configured theme conditions;web, native, ios, and android;Simple conditions use bare registered names:
opacity="1 :disabled(0.5)"
display="flex :sm(grid)"
bg="$surface :dark($surfaceDark)"
Conditions compose as an AND chain:
bg="$surface :dark:hover($surfaceDarkHover)"
Parameterized group or container conditions need one canonical form. The current proposed spelling is:
bg="$surface :group[card]:hover($surfaceHover)"
There is one global condition namespace. Duplicate configured names are reported when the config is created. The implementation must not silently choose between a theme, media, platform, or state condition with the same name.
Exact theme matching semantics must reuse Tamagui's established theme inheritance rules. The flat syntax must not invent a second definition of whether a parent theme condition matches a child theme.
Within one property program, clauses are evaluated left to right. The last matching clause wins:
bg="$red :hover($green) :dark($gray) :dark:hover($blue)"
When dark mode and hover are active, $blue wins.
A later property contribution replaces the entire earlier property program. It does not recursively merge clauses:
const Card = styled(View, {
bg: '$surface :hover($surfaceHover)',
variants: {
danger: {
true: {
bg: '$red',
},
},
},
})
The danger variant removes the earlier hover behavior. Preserving a
conditional behavior requires restating the complete property program:
bg="$red :hover($redHover)"
This atomic property replacement is intentional. It makes component props, variants, spreads, and overrides predictable without recursive object merging.
Ordinary independent atomic classes cannot represent authored clause order. The order of class names in HTML does not control the CSS cascade, and selector specificity can override source order.
The compiler therefore lowers a complete property program together. One possible output is a hash shared by identical property programs:
._bg_ab12 {
background: var(--red);
}
._bg_ab12:where(:hover) {
background: var(--green);
}
:where(.dark) ._bg_ab12 {
background: var(--gray);
}
:where(.dark) ._bg_ab12:where(:hover) {
background: var(--blue);
}
Requirements:
The exact CSS encoding may differ, but these semantics are fixed.
Tamagui tokens keep $:
bg="$green"
Real CSS custom properties retain CSS syntax:
bg="var(--green)"
Bare --green is not the token syntax. In CSS, --green names a custom
property declaration and is not itself a value. Keeping $ makes the
cross-platform Tamagui token distinct from a web-only CSS variable.
Color-token opacity uses:
bg="$green/50"
Initial rule:
Unconditional values keep their natural representation:
opacity={0.5}
zIndex={2}
Conditional values use a string program:
opacity="0.5 :hover(1)"
zIndex="2 :focus(3)"
The v4 direction prefers CSS-shaped strings for structured styles:
transform="scale(1) :hover(scale(1.05))"
boxShadow="0 2px 8px #0003 :hover(0 4px 16px #0004)"
React Native object and array values that lack a faithful CSS-shaped replacement require an explicit migration table. Legacy structured values must not be converted by guesswork.
Literal programs compile completely:
bg="$surface :hover($surfaceHover)"
Dynamic programs require the shared runtime parser unless the surrounding entrypoint is compile-only:
bg={`${base} :hover(${hover})`}
Runtime parsing is cached by property, program, and config. Dynamic values receive the same validation and injection protections as existing dynamic styles.
Standalone DOM is compile-only and reports dynamic structures that cannot be lowered safely. Existing regular Tamagui components remain runtime-correct during the v3 migration. Whether ordinary v4 components require compilation is a separate release-level decision.
The full grammar must not be modeled as a recursive TypeScript template literal union. That would recreate the styled type-performance problem.
The public property type remains broad:
type ConditionalStyleValue<T> = T | (string & {})
Validation belongs in:
TypeScript may provide lightweight token or condition suggestions, but type performance takes priority over exhaustive validation.
The inline conditional language and Tailwind lower into the same ordered IR:
// regular Tamagui
bg="$red :hover($green) :dark:hover($blue)"
// Tailwind Tamagui
className="bg-red hover:bg-green dark:hover:bg-blue"
Both become:
[
{ property: 'backgroundColor', when: [], value: '$red' },
{ property: 'backgroundColor', when: ['hover'], value: '$green' },
{ property: 'backgroundColor', when: ['dark', 'hover'], value: '$blue' },
]
Shared internals include:
The two public syntaxes do not import one another.
tailwind-merge is removed completely.
For Tamagui-owned candidates:
<View className="p-2 p-4" />
The Tailwind frontend emits ordered contributions. Tamagui's resolver makes the later padding contribution win.
Unknown classes are preserved for official Tailwind on web:
<View className="grid-cols-2 grid-cols-3" />
Tamagui does not attempt to merge candidates it does not own. Applications
that dynamically compose unknown Tailwind utilities can install and call
tailwind-merge themselves. It is never a dependency of core or
@tamagui/tailwind.
@tamagui/tailwind/vite owns:
Wrapping official React Strict DOM would combine two component and style runtimes.
Official RSD currently owns:
Tamagui already owns equivalents for much of this. A wrapper would also have to reconcile:
style contract;className;Official RSD remains useful as:
It is not a production dependency.
The first complete target is the same 49 html.* elements exposed by RSD,
with explicit element-specific prop interfaces.
The strict contract targets:
data-*;RSD deliberately excludes capture-phase JSX event props. DOM mode follows that strict contract. Capture listeners require the underlying event target API where the target supports it.
Ordinary Tamagui components may expose a broader web-only prop surface. Those
props carry clear @platform web JSDoc and do not weaken strict DOM mode.
The initial web-only prop gap found in regular Tamagui included:
onAuxClick;onFocusIn and onFocusOut;onFullscreenChange and onFullscreenError;onGotPointerCapture and onLostPointerCapture;onPointerEnter and onPointerLeave;onPointerOut and onPointerOver;onKeyDownCapture and
onWheelCapture.The regular component expansion should use explicit generated or curated
interfaces. Intersecting every Tamagui component with React's complete
HTMLAttributes is rejected until a type-performance benchmark proves it
safe.
The July 2026 comparison found:
| Surface | Concrete style keys |
|---|---|
| Official RSD | 148 |
| Tamagui View on web | 244 |
| Tamagui Text on web | 263 |
| Tamagui View on native | 187 |
| Tamagui Text on native | 202 |
At that snapshot, Tamagui covered 139 of RSD's 148 style names on web and 131 on native. Some apparent gaps were API-shape differences:
transition;pointerEvents as a style while Tamagui exposes it as a prop.The flat conditional-value syntax brings Tamagui closer to RSD's property-centered model without adopting StyleX handles as the regular Tamagui authoring syntax.
Native cannot promise every CSS behavior. Known difficult areas include:
calc() and clamp() where React Native cannot evaluate them;Unsupported behavior gets a compile diagnostic. DOM mode does not silently invent an approximation.
Tags are classified at compile time as View-backed, Text-backed, interactive, media, input, or unsupported.
The compiler:
html.tag bindings using module provenance.jsx/jsxs, and createElement forms through the
shared element IR.The runtime does not scan resolved children to rescue dynamic strings. Dynamic children retain normal React Native constraints and errors.
Native primitives own only behavior that cannot be erased:
DOM-shaped refs and events exceed React Native host capabilities in places. The contract is the proven subset, documented per tag and platform.
V1 does not promise:
HTMLElement implementation;Compatibility expands only through concrete fixtures and conformance tests.
Import provenance selects the frontend:
| Binding source | Compiler interpretation |
|---|---|
tamagui, @tamagui/core | DOM plus regular Tamagui props |
@tamagui/tailwind | DOM plus Tailwind candidates |
tamagui/dom, @tamagui/core/dom | DOM plus style() handles |
All lower to:
typed html element
→ validate tag, props, styles, and nesting
→ classify native backing primitive
→ normalize ordered property programs
→ emit target output
Example:
<html.main p="$4">Hello</html.main>
Approximate web output:
<main className="_p4">Hello</main>
Approximate native output:
<DOMView style={styles.p4}>
<DOMText>Hello</DOMText>
</DOMView>
Rules:
html.* usage requires the DOM compiler;View and Text retain their established runtime path.@tamagui/tailwind is also compiler-led. It does not ship the Tailwind parser
inside every rendered component. Dynamic class values require a statically
bounded compiler representation or a documented Tailwind-package runtime
cost. Core never pays that cost.
Conceptual exports:
{
"tamagui": {
".": "regular Tamagui plus html",
"./dom": "standalone DOM plus style()"
},
"@tamagui/core": {
".": "regular Tamagui plus html",
"./dom": "standalone DOM plus style()",
"./internal-runtime": "private shared implementation boundary"
},
"@tamagui/tailwind": {
".": "Tailwind Tamagui plus html",
"./vite": "Tailwind build integration"
}
}
@tamagui/core/internal-runtime is implementation plumbing, not another user
package. Its exact name is not public API unless package tooling requires an
export.
Barrels must not reconnect the frontend graphs. Runtime exports and type exports require separate bundle and declaration checks.
Core keeps its regular component prop graph and removes global styling-mode branches.
Flat conditional values add a broad string alternative to each style property. They do not create recursive condition object types or exhaustive template literal unions.
Tailwind components expose normal component behavior plus:
type TailwindStyleProps = {
className?: string
}
Their style types do not instantiate the regular Tamagui shorthand, pseudo, media, theme, and variant prop graph.
type DOMProps = StrictHTMLProps & {
style?: CompiledStyle | readonly ConditionalCompiledStyle[]
}
CSS property checking happens at the style() call. It is not intersected
into every JSX tag.
Generate explicit interfaces from a checked-in semantic table. The generated surface must:
data-* support;HTMLAttributes into every component.Type performance is an acceptance criterion, not a post-implementation cleanup.
The July 2026 probes used tiny minified production bundles with React and
React Native externalized. Browser export conditions were used for web, and
the module graph was checked for .native files.
Corrected View results:
| Configuration | Web gzip |
|---|---|
main @tamagui/web | 24.3 KB |
| v3-beta as shipped | 43.9 KB |
v3-beta with tailwind-merge externalized | 35.6 KB |
v3-beta with tailwind-merge and style grammar externalized | 31.5 KB |
The v3-beta browser bundle contained zero .native modules. It did contain
the Tailwind pipeline even when Tailwind mode was disabled at runtime.
Measured contributors:
tailwind-merge: about 8.3 KB gzip;getSplitStyles;The expected v3-beta result after fully removing Tailwind from core was approximately 25 to 30 KB gzip.
The same probe, with StyleX externalized, measured official RSD html.div at
about 3.4 KB gzip on web and 15.8 KB on native. Those numbers describe RSD
glue rather than the complete StyleX requirement.
Current styledHtml added about 1 KB gzip to an existing Tamagui graph.
Layering official RSD on Tamagui added roughly 3.3 KB on web and 15.4 KB on
native in that probe, while also duplicating runtime responsibilities.
These measurements are comparison evidence, not permanent budgets. The final implementation gets fresh repeatable probes.
Required gates:
View contains zero Tailwind grammar, parser, merger, or build modules;View contains zero regular inline-style frontend modules;.native modules enter a browser-conditioned probe;Initial targets:
Package imports define the mode:
import { View as InlineView } from '@tamagui/core'
import { View as TailwindView } from '@tamagui/tailwind'
Both use the same provider and config:
const config = createTamagui({
tokens,
themes,
media,
})
Migration:
@tamagui/tailwind.@tamagui/tailwind.There is no global switch and no combined mode.
V3 introduces an explicit opt-in. The opt-in applies coherently to a package or compiler configuration so a component does not have two competing condition systems.
Examples:
// existing
<View
bg="$surface"
hoverStyle={{ bg: '$surfaceHover' }}
$theme-dark={{ bg: '$surfaceDark' }}
/>
// flat values
<View
bg="$surface :hover($surfaceHover) :dark($surfaceDark)"
/>
The codemod can convert statically local cases. It must report cases where:
V3 documentation marks conditional objects as legacy after the new syntax is proven. V4 plans to make flat values the canonical syntax and remove the old condition object path on a release schedule with a codemod and diagnostics.
Existing View and Text code does not need to migrate.
DOM adoption is explicit:
import { html } from 'tamagui'
Libraries that publish html.* source either publish compiled output or
declare that consumers must compile the dependency.
styleMode from public types and runtime branches.getSplitStyles to regular Tamagui responsibilities.@tamagui/tailwind.tailwind-merge.@tamagui/tailwind/vite.html.div.jsx/jsxs, and createElement normalization.html from core.html from @tamagui/tailwind.tamagui/dom and @tamagui/core/dom.style() on the same style grammar as styled().html objects;One shared source fixture covers:
Compare supported behavior with a pinned official RSD fixture. Differences are recorded in the compatibility table.
data-* coverage;style();Repeat the established minified gzip probes for:
View;View;html.div;html.div;html.div plus one style() handle;Record module graphs with every number.
@tamagui/inline;tailwind-merge dependency;$token and --token syntax;strict-dom product or entrypoint name.These decisions need focused prototypes before implementation is considered locked:
style() conditionally composes multiple handles while preserving
whole-property replacement.Each prototype must end with one chosen path. The implementation must not ship multiple equivalent syntaxes or runtime fallback paths.
styledHtml introduction: commit ab8517c5e4