This is the language behind Tasty’s predictable state resolution. Every property can use a state map; later branches declare higher priority, and Tasty compiles them so only one generated selector can match.
The same value syntax, state maps, tokens, units, extension semantics, and special declarations apply to React tasty()—whether rendered on the client or server—and build-time tastyStatic().
For the React API (tasty(), style functions, component props), see React API. For all enhanced style properties, see Style Properties. For global configuration, see Configuration.
To autocomplete your own token and preset names, see TypeScript autocomplete; to check state keys, tokens, units, and values at lint time, see ESLint plugin.
Object where keys represent states and values are the styles to apply:
fill: { '': '#white', hovered: '#gray.05', 'theme=danger': '#red' }Define an alias for one component by adding an '@name': '<state expression>' key to its styles. It works like a global alias from configure({ states }), scoped to that component:
styles: {
'@narrow': '@media(w < 600px)',
fill: { '': '#surface', '@narrow': '#surface-mobile' },
}| Syntax | Example | Generated CSS |
|---|---|---|
| Boolean modifier | hovered |
[data-hovered] |
| Value modifier | theme=danger |
[data-theme="danger"] |
| Pseudo-class | :hover |
:hover |
| Class selector | .active |
.active |
| Attribute selector | [aria-expanded="true"] |
[aria-expanded="true"] |
| Combined (AND) | hovered & .active |
[data-hovered].active |
| Combined (OR) | hovered | focused |
[data-hovered], [data-focused] |
| Negated (NOT) | !disabled |
:not([data-disabled]) |
| Exclusive (XOR) | hovered ^ focused |
[data-hovered]:not([data-focused]), :not([data-hovered])[data-focused] |
disabled, checked, and hidden are built-in automatic states. A Tasty component sets data-disabled, data-checked, and data-hidden from the native disabled, checked, and hidden props (or isDisabled, isChecked, isHidden), so each pair below expresses the same component state:
| Concise Tasty state | Compiles to | Explicit attribute selector |
|---|---|---|
disabled |
[data-disabled] |
[disabled] |
checked |
[data-checked] |
[checked] |
hidden |
[data-hidden] |
[hidden] |
Prefer the concise form when authoring Tasty component styles. Explicit attribute selectors remain valid and can be useful when targeting arbitrary attributes or when CSS-shaped syntax makes an introductory example easier to recognize.
Operator precedence (highest to lowest): ! (NOT) > ^ (XOR) > | (OR) > & (AND). Use parentheses to override: hovered & (pressed ^ focused).
A top-level comma is the same as |: 'hovered, focused' is 'hovered | focused'.
^ (XOR) means "exactly one of" — A ^ B expands to (A & !B) | (!A & B). This is useful for mutually exclusive states where exactly one should be active:
fill: {
'': '#surface',
'hovered ^ focused': '#accent', // active when hovered OR focused, but not both
}Modifier and class names start with a letter and may be a single letter (x → [data-x], .x). Tasty emits an INVALID_STATE_KEY warning for a key it cannot fully read and skips the unreadable parts. That covers characters outside the notation (1st, or @media (w < 600px) with a space before the parenthesis), an operator missing a state (hovered &), and two states with no operator between them (hovered focused). A partly read key can apply in states you did not mean, so fix the key rather than rely on what it produces.
Key order sets priority — later keys win and turn off earlier ones via negation. The bare default ('') is the lowest-priority state, so it must be the first key. If it appears after other states, Tasty moves it to the front and emits a dev warning (MISPLACED_DEFAULT_STATE); otherwise it would override every state above it.
// Correct — default first
color: { '': '#text', hovered: '#accent' }
// Auto-corrected with a warning — '' moved to the front
color: { hovered: '#accent', '': '#text' }The bare '' default still receives negation (it is turned off when a higher-priority state matches). For a value that must always apply as a guaranteed floor — even where a query is unknown — use the _ fallback floor instead. The two can coexist: '' is the negated default, _ is the always-on floor. If a map contains only _ and '' (no other states), the '' default is redundant — Tasty keeps the _ value and drops '' with a REDUNDANT_DEFAULT_STATE warning.
Element styled using a capitalized key. Identified by data-element attribute:
styles: {
Title: { preset: 'h3' },
}
// Targets: <div data-element="Title">Control how a sub-element selector attaches to the root selector using the $ property inside the sub-element's styles.
Examples below assume the sub-element key is Cell (i.e. [data-element="Cell"] in CSS):
| Pattern | Result | Description |
|---|---|---|
| (none) | [data-element="Cell"] |
Descendant (default) |
> |
> [data-element="Cell"] |
Direct child |
>Body> |
> [data-element="Body"] > [data-element="Cell"] |
Chained elements |
> Cell |
> [data-element="Cell"] |
Self-name shorthand — when the trailing element name matches the sub-element's own key, it acts as the placeholder (same as @); no duplication |
h1 |
h1 |
Tag selector (no key injection) |
h1 > |
h1 > [data-element="Cell"] |
Key is direct child of tag |
h1 * |
h1 * |
Any descendant of tag |
* |
* |
All descendants |
&::before |
::before |
Root pseudo (no key); & is required |
@::before |
[data-element="Cell"]::before |
Pseudo on the sub-element |
>@.active |
> [data-element="Cell"].active |
Class on the sub-element |
Rules for key injection ([data-element="..."]):
- Trailing combinator (
>,+,~) — key is injected after it - Uppercase element name (
Body,Row) — key is injected as descendant - HTML tag (
h1,a,span) — no key injection; the tag IS the selector - Universal selector (
*) — no key injection - Pseudo / class / attribute — no key injection
The @ placeholder marks exactly where [data-element="..."] is injected, allowing you to attach pseudo-classes, pseudo-elements, or class selectors directly to the sub-element instead of the root:
const List = tasty({
styles: {
Item: {
$: '>@::after',
content: '""',
},
},
});
// → .t1x29r0l.t1x29r0l > [data-element="Item"]::after { content: ""; }Keep conditions such as :last-child out of $ and write them as @own(:last-child) in a state map; tasty/no-state-in-selector warns otherwise. When $ ends in a pseudo-element, @own() describes the pseudo-element instead (see @own(...)), so a condition on the element it belongs to stays in $, before the pseudo-element: $: 'a[rel="next"]::before'.
Named color prefixed with # that maps to CSS custom properties. Supports opacity with .N suffix:
fill: '#purple.5'; // → color-mix(in oklab, var(--purple-color) 50%, transparent)State value via mods prop that generates data-* attributes:
mods={{ hovered: true, theme: 'danger' }}
// → data-hovered="" data-theme="danger"Modifiers can also be exposed as top-level component props via modProps — see React API — Mod Props.
color: '#purple', // Full opacity
color: '#purple.5', // 50% opacity
color: '#purple.05', // 5% opacity
color: '#purple.$fade', // Opacity from a custom property
fill: '#current', // → currentcolor
fill: '#surface+#dark.04', // #dark at 4% opacity over #surface, as one color
color: '(#primary, #secondary)', // Fallback syntax#white, #black, #border, and #outline are registered by default (see Default properties), and #clear is a built-in alias for transparent (see below), so they work before you define any tokens.
A name that is also a hex color is read as a token with the literal as its fallback, in every color style: color: '#ddd' emits var(--ddd-color, #ddd), and #ddd.5 emits color-mix(in oklab, var(--ddd-color, #ddd) 50%, transparent). A #ddd token, if you define one, wins over the literal.
The suffix fades the token's color by mixing it with transparent:
fill: '#purple.5';
// → color-mix(in oklab, var(--purple-color) 50%, transparent)The mix asks nothing of the color beyond being a color. That means the suffix works on every one of these:
- a token holding a
color-mix(), alight-dark(), or acolor()in a space Tasty cannot convert — none of which have channels to decompose #current, which resolves to the color the element inherits- a
--name-colorvariable declared in your own CSS, with no Tasty token definition behind it
color-mix() is Baseline widely available (Chrome 111, Firefox 113, Safari
16.2). Two properties follow from mixing rather than writing an alpha channel:
- Alpha composes. The mix is premultiplied, so a color that is already
translucent is faded further: a token holding
rgb(255 0 0 / .8)faded to.5is alpha.4. An opaque token — the usual case — ends at exactly the suffix. System colors are whatever the platform draws, and some are translucent: Chrome and Safari on macOS giveHighlightan alpha of.6, so a token holding it faded to.5ends at.3there. - A
$propalpha must be a number.#purple.$fadeemitscalc(var(--fade) * 100%), because a mix percentage cannot be a<number>, so$fadehas to hold.5, not50%.
#clear is a built-in replace token for transparent.
Opacity suffixes are ignored: #clear.5 and #clear.$fade both emit
transparent. The same rule applies to user-defined replace tokens resolving
to transparent, including aliases of #clear.
It does not read --clear-color or require an @property registration.
An explicit configure({ replaceTokens: { '#clear': ... } }) overrides the
default alias and uses the replacement's usual opacity behavior.
#current emits the currentcolor keyword, so it resolves against the element
that reads it. That is what lets it follow a color an ancestor faded — a ramp
that expresses its disabled state once, in color, fades everything painted from
#current below it with no further declarations.
The color style also publishes --current-color beside every color it sets,
for anything that needs the inherited color as a color rather than as the
keyword — hand-authored CSS, or a place the keyword will not do. Read it as
$current-color. It is registered with initial-value: currentcolor, so where
nothing published it, it still resolves against each element's own color:
fill: '$current-color'; // → var(--current-color)A color that already reads the color it inherits — #current, a #current fade
— is not published into it: resolving such a value a second time at a descendant
would fade it twice.
currentcolor is the color an element inherits, which an ancestor may
already have faded, so #current.4 means "40% of what reaches me":
fill: '#current.4';
// → color-mix(in oklab, currentcolor 40%, transparent)A #current fade nested inside another one composes: a label at #current.4
with a fill of #current.18 under it lands at .072. Color ramps built on
#current depend on that.
Fades always mix in oklab: it is unbounded, so a wide-gamut color is not
clamped, and rectangular, so there is no hue to interpolate against
transparent. Nothing configures it — see
Color space.
Join color tokens with + to paint each one, at its own opacity, over the
colors before it. The result is one color, so it works anywhere a token does:
fill: '#surface+#dark.04',
// → color-mix(in srgb, var(--dark-color) 4%, var(--surface-color))
border: '1bw #border+#dark.1',
color: 'light-dark(#primary+#black.2, #primary+#white.2)',
'#hover-surface': '#surface-2+#surface-text.03',Read it left to right, bottom layer first: #surface+#accent.1+#dark.04 paints
#accent at 10% over #surface, then #dark at 4% over that. Every operand is
an ordinary token — #current, #clear, a hex color, a replace token — and
takes its opacity suffix as it does anywhere: #surface+#dark.$hover reads the
opacity from $hover, and a faded base such as #surface.5+#dark.04 is layered
as it is.
The layers mix in sRGB, the space browsers composite in, so the color is exactly
what stacking them as layers would show — but it is one color, which contrast
checkers can read and transitions interpolate. A faded base is exact too:
#surface.5+#dark.04 is as translucent as the two layers would be.
Every layer's token has to be opaque. The mix counts a layer's own alpha as well
as its opacity suffix, and lowers the alpha of the whole color, so a layer token
that holds a translucent color, or turns transparent in some state, leaves the
result see-through: with '#overlay': { '': '#clear', hovered: '#dark' },
'#surface+#overlay.04' is 96% opaque at rest. Vary the opacity instead
('#surface+#dark.$hover'), or give each state its own color
(fill: { '': '#surface', hovered: '#surface+#dark.04' }).
- Write it as one word:
#surface + #dark.04is three separate values, and Tasty warns in development. - A layer without an opacity covers everything before it:
#surface+#darkis#dark, with a development warning. - A layer Tasty can see is transparent —
#clear, a fade to.0, a color with a zero alpha — is left out:#surface+#clearis#surface. - Over a transparent base, a layer is just its fade:
#clear+#dark.04compiles to#dark.04. - A
+joining anything but color tokens (#surface+1x) is not a compound: the token is dropped, with a development warning, and the style renders as if that color were not there.
A + joins tokens only. To fade a whole compound, give it a token
('#hover-surface': '#surface+#dark.04') and fade that (#hover-surface.5). For
a mix that is not a layer over another color, such as an even blend in OKLCH,
write color-mix() itself.
Every CSS color function is recognized as a color, so it lands in the color slot of whichever style property you use it in — and tokens inside it are expanded:
fill: 'color-mix(in oklab, #primary 50%, #surface)',
color: 'light-dark(#dark, #light)',
color: 'contrast-color(#primary)',
fill: 'color(display-p3 1 .5 0)',
border: '1bw solid oklch(from #primary l c h / 50%)', // relative color syntax
shadow: '0 0 1x color-mix(in oklab, #dark 20%, transparent)',Tasty also ships okhsl() and okhst(), which compile to rgb() at parse
time: fill: 'okhsl(280 80% 50%)' → rgb(40% 38.5% 88.2%).
light-dark() is the one exception to "always a color": CSS lets it pick between
values of any type, so it is treated as a color only when its arguments are
colors. padding: 'light-dark(1x, 2x)' still reaches the padding slot.
A color token defined as one of these functions takes the opacity suffix like any other:
const Card = tasty({
styles: {
'#brand': 'color-mix(in oklab, #primary 50%, #surface)',
fill: '#brand.5',
},
});A replace token is substituted while parsing, so its color is right there to fade in place. A channel function takes the alpha after a slash; a derived function has no alpha channel, so it gets wrapped:
configure({
replaceTokens: {
'#solid': 'hsl(220 90% 50%)',
'#adaptive': 'light-dark(#dark, #light)',
},
});
fill: '#solid.5'; // → hsl(220 90% 50% / .5)
fill: '#adaptive.5'; // → color-mix(in oklab, light-dark(…) 50%, transparent)CSS named colors (rebeccapurple) and system colors (Canvas, CanvasText,
Highlight, ButtonText, GrayText, LinkText, …) are colors too: they land
in the color slot of any style and can be a color token's value. System colors
are what forced-colors styles are written in:
const Checkbox = tasty({
styles: {
'#forced-border': 'CanvasText',
border: {
'': '1bw #border',
'@media(forced-colors: active)': '1bw #forced-border',
},
outline: { '': false, focused: '2px Highlight' },
},
});A custom property whose fallback is a single color is read as a color, so it
also fills the color slot: border: '1bw var(--accent, Highlight)', as does
($accent, Highlight). Keywords are case-insensitive: a color token keeps the
case it was written in, and a property value such as border or fill emits
them lowercase.
The deprecated system colors (Menu, Window, ButtonHighlight, …) are not
recognized.
| Unit | Description | Default | Example | CSS output |
|---|---|---|---|---|
x |
Spacing step | 8px |
2x |
16px |
r |
Border radius | 6px |
1r |
6px |
cr |
Card border radius | 10px |
1cr |
10px |
bw |
Border width | 1px |
2bw |
2px |
ow |
Outline width | 3px |
1ow |
3px |
sf |
Stable fraction | minmax(0, Nfr) |
1sf |
minmax(0, 1fr) |
Units compile to plain values. Change the scale with configure({ units: { x: '4px' } }), or point a unit at a custom property when it must change at runtime: with units: { x: 'var(--gap)' }, 2x compiles to calc(2 * var(--gap)). Setting a $gap or $radius token does not change the units. Register additional units the same way via configure().
Tokens defined via configure({ replaceTokens }) are replaced at parse time and baked into the generated CSS:
const Card = tasty({
styles: {
padding: '$card-padding',
fill: '#surface',
border: '1bw solid #accent',
},
});Apply predefined style bundles (defined via configure({ recipes })) using the recipe style property:
const Card = tasty({
styles: {
recipe: 'card',
color: '#text',
},
});
// Compose multiple recipes
const ElevatedCard = tasty({
styles: {
recipe: 'card elevated',
color: '#text',
},
});Where recipes resolve:
The recipe property is resolved on the component root and independently inside each sub-element's styles:
const CardWithPanel = tasty({
styles: {
recipe: 'card',
Panel: {
recipe: 'card elevated',
padding: '2x', // Overrides the panel's base recipe padding
},
},
});At each level, base recipes are collected in the order listed (later recipes replace earlier values for the same property), then local styles are merged, then post recipes are merged. The merges with local styles and post recipes follow the state-map extension and replacement rules.
Recipe definitions themselves cannot contain a recipe reference to another recipe: there is no recursive expansion or recipe inheritance. Compose names in the root or sub-element recipe property instead. See Configuration — Recipes for an unsupported nesting example.
Post-merge recipes (/ separator):
Recipes listed after / are applied after component styles using mergeStyles:
const Input = tasty({
styles: {
recipe: 'reset input / input-autofill',
preset: 't3',
},
});Use none to skip base recipes and apply only post recipes:
const Custom = tasty({
styles: {
recipe: 'none / disabled',
padding: '2x',
},
});When a style property uses a state map, the merge behavior depends on whether the child provides a '' (default) key:
- No
''key — extend mode: parent states are preserved, child adds/overrides - Has
''key — replace mode: child defines everything from scratch
// Parent has: fill: { '': '#white', hovered: '#blue', disabled: '#gray' }
// Extend — no '' key, parent states preserved
const MyButton = tasty(Button, {
styles: {
fill: {
loading: '#yellow', // append new state
disabled: '#gray.20', // override existing state in place
},
},
});
// Replace — has '' key, parent states dropped
const MyButton = tasty(Button, {
styles: {
fill: {
'': '#red',
hovered: '#blue',
},
},
});Use '@inherit' to pull a parent state value. In extend mode it repositions the state; in replace mode it cherry-picks it:
// Extend mode: reposition disabled to end (highest CSS priority)
fill: {
'loading': '#yellow',
disabled: '@inherit',
}
// Replace mode: cherry-pick disabled from parent
fill: {
'': '#red',
disabled: '@inherit',
}Use null inside a state map to remove a state, or false to block it entirely (tombstone):
const MyButton = tasty(Button, {
styles: {
fill: {
hovered: null, // removes the parent's hovered state
disabled: false, // tombstone — no CSS for disabled, blocks recipe too
},
},
});const SimpleButton = tasty(Button, {
styles: {
fill: null, // discard parent's fill, let recipe fill in
border: false, // no border at all (tombstone — blocks recipe too)
},
});| Value | Meaning | Recipe fills in? |
|---|---|---|
undefined |
Not provided — parent preserved | N/A |
null |
Intentional unset — parent discarded | Yes |
false |
Tombstone — blocks everything | No |
| Prefix | Purpose | Example |
|---|---|---|
@media |
Media queries | @media(w < 768px) |
@(...) |
Container queries | @(panel, w >= 300px) |
@supports |
Feature/selector support | @supports(display: grid) |
@root |
Root element states | @root(schema=dark) |
@parent |
Parent/ancestor element states | @parent(hovered) |
@own |
Sub-element's own state | @own(hovered) |
@starting |
Entry animation | @starting |
_ |
Fallback floor (always-on, never negated) | _ |
:is() |
CSS :is() structural pseudo-class |
:is(fieldset > label) |
:has() |
CSS :has() relational pseudo-class |
:has(> Icon) |
:not() |
CSS :not() negation (prefer !:is()) |
:not(:first-child) |
:where() |
CSS :where() (zero specificity) |
:where(Section) |
Specificity. All state selectors Tasty generates (modifiers, pseudo-classes,
:is()/:not()groups, and@root/@parentcontext) are wrapped in:where(...)so they carry zero specificity. The only specificity anchors are the doubled component class (.t0.t0) and sub-element[data-element]attributes. This means overlapping rules (e.g. a_fallback floor and the states layered over it) resolve purely by source order — Tasty emits lower-priority states first and higher-priority states last so the cascade produces the intended winner.
Media queries support dimension shorthands:
| Shorthand | Expands to |
|---|---|
w |
width |
h |
height |
fill: {
'': '#surface',
'@media(w < 768px)': '#surface-mobile',
'@media(600px <= w < 1200px)': '#surface-tablet',
'@media(prefers-color-scheme: dark)': '#surface-dark',
}| Tasty syntax | CSS output |
|---|---|
@media(w < 768px) |
@media (width < 768px) |
@media(600px <= w < 1200px) |
@media (600px <= width < 1200px) |
@media:print |
@media print |
@media:screen |
@media screen |
@media:all |
@media all |
@media:speech |
@media speech |
@media(prefers-color-scheme: dark) |
@media (prefers-color-scheme: dark) |
@media(prefers-reduced-motion) |
@media (prefers-reduced-motion) |
@media(aspect-ratio > 16/9) |
@media (aspect-ratio > 16/9) |
Tasty units resolve to their literal length inside query conditions, built-in and configured units alike: @media(w < 40x) emits @media (width < 320px). A query condition cannot read a custom property, so a unit configured as one (units: { x: 'var(--gap)' }) cannot be used there; write that length in px, em, or rem.
Units are only converted in features that take a length: width, height, inline-size, and block-size, with or without min-/max- (@media(min-width: 40x) converts too). Every other feature keeps its value as written, so @media(min-resolution: 2x) and @media(resolution >= 2x) reach the browser unchanged: there x is CSS's own resolution unit, an alias of dppx.
When it combines or orders conditions, Tasty compares bounds only within one unit. @media(w >= 600px) & @media(w < 40em) keeps both bounds, because a query em follows the user's font size, so 600px and 40em cannot be ordered at build time.
In practice, define state aliases via configure({ states }) and use @mobile instead of writing the full query in every component.
Container queries use the syntax @(name, condition) for named containers or @(condition) for the nearest ancestor container. Dimension shorthands (w, h, is, bs) are expanded the same way as @media.
| Shorthand | Expands to |
|---|---|
w |
width |
h |
height |
is |
inline-size |
bs |
block-size |
const Panel = tasty({
styles: {
flow: {
'': 'column',
'@(layout, w >= 600px)': 'row',
},
},
});| Tasty syntax | CSS output |
|---|---|
@(layout, w < 600px) |
@container layout (width < 600px) |
@(w < 600px) |
@container (width < 600px) |
@(layout, $variant=danger) |
@container layout style(--variant: danger) |
@(layout, $compact) |
@container layout style(--compact) |
@(scroll-state(stuck: top)) |
@container scroll-state(stuck: top) |
@(nav, scroll-state(stuck: top)) |
@container nav scroll-state(stuck: top) |
Container style queries use $prop (boolean) or $prop=value syntax, which maps to CSS style(--prop) or style(--prop: value). The value is emitted unquoted, so it matches a custom property set to the identifier danger, not the string "danger".
Other size features pass through as written: @(aspect-ratio > 1) → @container (aspect-ratio > 1), @(orientation: portrait) → @container (orientation: portrait). They need a container with containerType: 'size', since an inline-size container has no block size to compare against.
Feature queries test CSS property support and accept CSS support functions such
as at-rule(). Use $ as the first argument to test selector support:
| Tasty syntax | CSS output |
|---|---|
@supports(display: grid) |
@supports (display: grid) |
@supports($, :has(*)) |
@supports selector(:has(*)) |
@supports(at-rule(@scope)) |
@supports (at-rule(@scope)) |
!@supports(display: grid) |
@supports (not (display: grid)) |
!@supports(at-rule(@scope)) |
@supports (not (at-rule(@scope))) |
display: {
'': 'flex',
'@supports(display: grid)': 'grid',
}at-rule() tests whether the browser supports the named at-rule. Its surrounding
parentheses in Tasty's CSS output are valid CSS. Negation and combinations with
other states use the same syntax as property queries:
color: {
_: 'red',
'@supports(at-rule(@scope)) & @supports(display: grid)': 'blue',
}Use _ to keep a base value when a browser cannot evaluate a query. A support
query only detects a browser feature; it does not add an API for authoring the
queried at-rule in a Tasty style object. For conditional stylesheet imports such
as @import url("scoped.css") supports(at-rule(@scope));, use an ordinary CSS
stylesheet or document-level useRawCSS(). Put imports before other rules in
that stylesheet. Imports cannot be used in constructable stylesheets, including
Tasty's adopted-sheet mode for Shadow DOM.
By default Tasty makes states mutually exclusive: a higher-priority state
turns the lower-priority ones off via negation, so exactly one branch applies.
This relies on A | !A always being true. CSS feature/container queries break
that assumption: @supports(...) and @(...) queries can be unknown (not
just true/false), and not(unknown) is also unknown — so a negated default
branch silently never applies. The classic case is scroll-state: a browser can
support container-type: scroll-state while a specific scroll-state(...) query
is unknown, leaving no branch active.
The _ fallback floor solves this. Use _ as a standalone key and its value
always applies as a guaranteed floor: it never receives negation, and
higher-priority states simply layer over it via the cascade.
inset: {
_: '0 top',
'@supports(container-type: scroll-state) & @(scroll-state(scrolled: block-end))':
'-80x top',
}.t0.t0 {
inset: 0 auto auto auto;
}
@supports (container-type: scroll-state) {
@container scroll-state(scrolled: block-end) {
.t0.t0 {
inset: -640px auto auto auto;
}
}
}The floor is emitted as a bare rule (no negated @supports / container branches),
so it always applies. When the override matches it wins because it is emitted
later and all state selectors share the same specificity (see the note on
:where() above).
Notes:
_is a standalone key only — it defines a single map-wide floor and cannot be combined with state logic. Keys like_ & hoveredor_ | focusedare ignored with anINVALID_FALLBACK_KEYdev warning._can coexist with the bare''default when other states exist:''is the negated default,_is the always-on floor. With only_and''(no other states) the''default is redundant — Tasty keeps the_value and drops''with aREDUNDANT_DEFAULT_STATEwarning._is position-independent: it is always placed at the lowest priority regardless of where it appears in the map.
In simple maps where every other state is a plain modifier (always cleanly
on/off), _ and '' produce the same visible result — the base value shows
whenever no other state wins. They diverge in three situations, and the root
cause is always the same: '' is mutually exclusive (turned off by negation),
while _ is always on (layered underneath).
-
Unknown / invalid branches. If a higher-priority branch sits behind a query that can be unknown (
@supports, container,scroll-state) or uses a selector the browser drops, the negated''default disappears along with it (not(unknown) = unknown), leaving the property with no rule. The_floor survives because it is an unconditional bare rule. This is the case_exists for. -
Empty / "unset" states. A state can intentionally produce no output (an empty value) to leave a property unset while that state is active. With
''this works — the default is negated away and nothing replaces it, so the property unsets. With_the floor can never be turned off, so its value keeps applying and the "unset on this state" intent is lost.// '' : color unsets while loading. '_' : color stays #text while loading. color: { '': '#text', loading: '' }
-
States with different output shapes. Because
_layers additively, when different states emit different sets of declarations, the floor's declarations bleed through wherever the winning state does not override the same property — which can produce inconsistent combinations. A''default is swapped out cleanly by its mutually-exclusive condition.
Rule of thumb: use '' for the normal default (clean mutual exclusivity,
supports unsetting), and reach for _ only when a value must survive an
unknown higher-priority branch.
Root states generate selectors on the :root element. They are useful for theme modes, feature flags, and other page-level conditions:
These docs use data-schema in examples. If your app standardizes on a different root attribute, keep the same pattern and swap the attribute name consistently in your aliases and selectors.
color: {
'': '#text',
'@root(schema=dark)': '#text-on-dark',
'@root(.premium-user)': '#gold',
}| Tasty syntax | CSS selector |
|---|---|
@root(schema=dark) |
:root[data-schema="dark"] |
@root(hovered) |
:root[data-hovered] |
@root(.premium-user) |
:root.premium-user |
@root([lang="en"]) |
:root[lang="en"] |
!@root(schema=dark) |
:root:not([data-schema="dark"]) |
Root conditions are prepended to the component selector inside :where(): :where(:root[data-schema="dark"]) .t0.t0 { ... }.
By default, state keys in sub-element styles refer to the root component's state context. Use @own(...) when the sub-element should react to its own state:
const Nav = tasty({
styles: {
NavItem: {
color: {
'': '#text',
'@own(:hover)': '#primary',
'@own(:focus-visible)': '#primary',
selected: '#primary', // root-level modifier
},
},
},
elements: { NavItem: 'a' },
});| Tasty syntax (inside sub-element) | CSS output |
|---|---|
@own(:hover) |
:hover on the sub-element selector |
@own(hovered) |
[data-hovered] on the sub-element selector |
@own(theme=dark) |
[data-theme="dark"] on the sub-element selector |
@own() is only valid inside sub-element styles. Using it on root styles emits a warning and is treated as a regular modifier.
When $ ends in a pseudo-element, @own() is the pseudo-element's own state and goes after it:
Label: {
$: '@::part(label)',
color: { '': '#text', '@own(:focus)': '#primary' },
},
// → .t0.t0 [data-element="Label"]::part(label):where(:focus) { … }A pseudo-element has no attributes, classes, or place in the DOM tree. So a state that tests a modifier (@own(hovered)), an attribute (@own([open])), a class, or a tree position (@own(:first-child), @own(:has(…))) can never match it. Tasty ignores such a state and warns. To condition on the element the pseudo-element belongs to, write it in $ before the pseudo-element ($: 'a[rel="next"]::before'), or use @parent() for its ancestors.
This covers every pseudo-element form: ::before, ::marker, ::backdrop, vendor-prefixed ones such as ::-webkit-details-marker, functional ones such as ::part(label), and the legacy :before, :after, :first-line, :first-letter. Each item of a $ list is judged on its own. Tasty emits pseudo-class states regardless of browser support. Only some pseudo-elements, such as ::part() and ::-webkit-scrollbar-thumb, match states like :hover or :focus today.
Wraps the rule in @starting-style, enabling CSS entry animations for elements as they appear in the DOM:
const FadeIn = tasty({
styles: {
opacity: { '': '1', '@starting': '0' },
transform: { '': 'scale(1)', '@starting': 'scale(0.95)' },
transition: 'opacity, translate',
},
});| Tasty syntax | CSS output |
|---|---|
@starting |
.t0.t0 { @starting-style { ... } } |
Style based on ancestor element attributes. Uses :where([selector] *) / :where(:not([selector] *)) for symmetric, composable parent checks. Boolean logic (&, |, !, ^) is supported inside @parent().
const Highlight = tasty({
styles: {
fill: {
'': '#white',
'@parent(hovered)': '#gray.05', // Any ancestor has [data-hovered]
'@parent(theme=dark, >)': '#dark-02', // Direct parent has [data-theme="dark"]
},
},
});| Syntax | CSS Output |
|---|---|
@parent(hovered) |
:where([data-hovered] *) |
!@parent(hovered) |
:where(:not([data-hovered] *)) |
@parent(hovered, >) |
:where([data-hovered] > *) (direct parent) |
@parent(.active) |
:where(.active *) |
@parent(hovered & focused) |
:where([data-hovered][data-focused] *) (same ancestor) |
@parent(hovered) & @parent(focused) |
:where([data-hovered] *):where([data-focused] *) (independent ancestors) |
@parent(hovered | focused) |
:where([data-hovered] *, [data-focused] *) (OR inside single wrapper) |
For sub-elements, the parent check applies to the root element's ancestors:
const Card = tasty({
styles: {
Label: {
color: {
'': '#text',
'@parent(hovered)': '#primary',
},
},
},
});
// → .t0.t0:where([data-hovered] *) [data-element="Label"]Use CSS structural pseudo-classes directly in state keys. Capitalized words become [data-element="..."] selectors; lowercase words are HTML tags. A trailing combinator (>, +, ~) is auto-completed with *. Leading combinators (> Icon, + Icon) are relative selectors and are valid only inside :has().
:where() and :not() are also supported but rarely needed — use :is() and ! negation instead.
Performance warning: CSS structural pseudo-classes — especially
:has()— can be costly for the browser to evaluate because they require inspecting the DOM tree beyond the matched element. Tasty already provides a rich, purpose-built state system (@parent(),@own(), modifiers, boolean logic) that covers the vast majority of use cases without the performance trade-off. Prefer Tasty's built-in mechanisms and treat:has()/:is()as a last resort for conditions that cannot be expressed any other way.
const Card = tasty({
styles: {
display: {
'': 'block',
':has(> Icon)': 'grid', // has Icon as direct child
':has(+ Icon)': 'grid', // immediately followed by an Icon sibling
':has(~ Icon)': 'grid', // has an Icon sibling somewhere after
':has(Icon +)': 'grid', // immediately preceded by an Icon sibling (auto-completes to `Icon + *`)
':has(Icon ~)': 'grid', // has an Icon sibling somewhere before (auto-completes to `Icon ~ *`)
':is(fieldset > label)': 'inline', // is a label inside a fieldset (HTML tags)
'!:has(> Icon)': 'flex', // negation: no Icon child
},
},
});| Syntax | CSS Output | Meaning |
|---|---|---|
:has(> Icon) |
:has(> [data-element="Icon"]) |
Has Icon as direct child |
:has(+ Icon) |
:has(+ [data-element="Icon"]) |
Immediately followed by an Icon sibling |
:has(~ Icon) |
:has(~ [data-element="Icon"]) |
Has an Icon sibling somewhere after |
:has(Icon +) |
:has([data-element="Icon"] + *) |
Immediately preceded by an Icon sibling |
:has(Icon ~) |
:has([data-element="Icon"] ~ *) |
Has an Icon sibling somewhere before |
:has(>) |
:has(> *) |
Has any direct child |
:is(Field + input) |
:is([data-element="Field"] + input) |
Is an input right after a Field sibling |
:has(button) |
:has(button) |
HTML tag (lowercase, unchanged) |
!:has(> Icon) |
:not(:has(> [data-element="Icon"])) |
Negation (use !) |
!:is(Panel) |
:not([data-element="Panel"]) |
Negation (use !:is) |
Combine with other states using boolean logic (&, |, !, ^):
':has(> Icon) & hovered'; // AND: structural + data attribute
'@parent(hovered) & :has(> Icon)'; // AND: parent check + structural
':has(> Icon) | :has(> Button)'; // OR: either sub-element present
':has(> Icon) ^ :has(> Button)'; // XOR: exactly one presentNesting limit: The state key parser supports up to 2 levels of nested parentheses inside
:is(),:has(),:not(), and:where()— e.g.:has(Input:not(:disabled))works, but 3+ levels like:has(:is(:not(:hover)))will not be tokenized correctly. This covers virtually all practical use cases.
Define animations inline using the @keyframes key in styles:
const Pulse = tasty({
styles: {
animation: 'pulse 2s infinite',
'@keyframes': {
pulse: {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.05)' },
},
},
},
});Keyframes names are case-sensitive and keep their authored case in animation and animationName (fadeIn, not fadein), so they match @keyframes defined here, in configure({ keyframes }), or in plain CSS. Everything else in the value — durations, timing functions, keywords — is parsed as usual.
CSS cannot transition or animate custom properties unless the browser knows their type. Tasty solves this automatically — when you assign a concrete value to a custom property, the type is inferred and a CSS @property rule is registered behind the scenes:
const AnimatedGradient = tasty({
styles: {
'$gradient-angle': '0deg',
'#theme': 'okhst(280 80% 50%)',
image: 'linear-gradient($gradient-angle, #theme, #clear)',
transition: '$$gradient-angle, ##theme',
},
});Here $gradient-angle: '0deg' is detected as <angle> and #theme as <color> (via the #name naming convention), so both transitions work without any manual @property declarations. Numeric types (<number>, <length>, <percentage>, <angle>, <time>) are inferred from values; <color> is inferred from #name tokens.
Use explicit @property when you need non-default settings like inherits: false:
'@property': {
'$gradient-angle': { syntax: '<angle>', inherits: false, initialValue: '0deg' },
},Tasty registers these properties itself. Style handlers read their defaults from them rather than repeating the values inline, so styles work before you define any tokens, and defining a token replaces the initial value.
| Token | Initial value | Read by |
|---|---|---|
#current |
currentcolor |
anything reading $current-color, which color sets |
#white, #black |
rgb(255 255 255), rgb(0 0 0) |
your styles |
#border |
currentcolor |
border with no color |
#outline |
currentcolor |
outline with no color |
$gap, $radius, $border-width, $outline-width |
8px, 6px, 1px, 3px |
units you point at them, as in units: { x: 'var(--gap)' } |
$transition |
80ms |
transition with no duration |
$sharp-radius |
0px |
the sharp corners of radius: 'leaf' and radius: 'backleaf' |
$bold-font-weight |
700 |
preset: 'name / strong' |
$font-sans-fallback, $font-mono-fallback |
a system-ui and a ui-monospace stack |
font and presets, after $font-sans / $font-mono |
Every rendering path emits them: the client injector, the SSR collector (TastyRegistry, tastyIntegration(), runWithCollector()), the withTastyNext() shared stylesheet, and the Babel plugin. CSS written with the programmatic API needs extractConfigProperties(), once per stylesheet. Server rendering without a collector, such as Astro without tastyIntegration(), emits none of them.
The initial values need @property support: Chrome and Edge 85+, Firefox 128+, Safari 16.4+. In older engines a handler that reads one of these tokens resolves to nothing unless your tokens define it: a border with no color draws no border, and a transition with no duration does not animate. To support those engines, define the tokens your styles rely on in configure({ tokens }).
Register custom fonts directly inside a styles object. Keys are font-family names, values are descriptor objects (or arrays of them for multiple weights/styles).
const Heading = tasty({
styles: {
'@font-face': {
'Brand Sans': {
src: 'url("/fonts/brand-sans.woff2") format("woff2")',
fontDisplay: 'swap',
},
},
font: '"Brand Sans", sans-serif',
},
});Supply an array to register several variants of the same family:
'@font-face': {
'Brand Sans': [
{ src: 'url("/fonts/brand-regular.woff2") format("woff2")', fontWeight: 400, fontDisplay: 'swap' },
{ src: 'url("/fonts/brand-bold.woff2") format("woff2")', fontWeight: 700, fontDisplay: 'swap' },
],
}| Descriptor | CSS property | Type |
|---|---|---|
src (required) |
src |
string |
fontWeight |
font-weight |
string | number |
fontStyle |
font-style |
string |
fontStretch |
font-stretch |
string |
fontDisplay |
font-display |
'auto' | 'block' | 'swap' | 'fallback' | 'optional' |
unicodeRange |
unicode-range |
string |
ascentOverride |
ascent-override |
string |
descentOverride |
descent-override |
string |
lineGapOverride |
line-gap-override |
string |
sizeAdjust |
size-adjust |
string |
fontFeatureSettings |
font-feature-settings |
string |
fontVariationSettings |
font-variation-settings |
string |
Font-face rules are permanent — they are injected once and never cleaned up, matching how browsers handle
@font-face.
Define custom list markers via the CSS @counter-style at-rule. Keys are counter-style names, values are descriptor objects.
const EmojiList = tasty({
as: 'ol',
styles: {
'@counter-style': {
thumbs: {
system: 'cyclic',
symbols: '"👍"',
suffix: '" "',
},
},
listStyleType: 'thumbs',
},
});| Descriptor | CSS property | Type |
|---|---|---|
system (required) |
system |
'cyclic' | 'numeric' | 'alphabetic' | 'symbolic' | 'additive' | 'fixed' | string |
symbols |
symbols |
string |
additiveSymbols |
additive-symbols |
string |
prefix |
prefix |
string |
suffix |
suffix |
string |
negative |
negative |
string |
range |
range |
string |
pad |
pad |
string |
fallback |
fallback |
string |
speakAs |
speak-as |
string |
Counter-style rules are permanent — they are injected once and never cleaned up, matching how browsers handle
@counter-style.
Define reusable, parameterized CSS custom functions via the CSS @function at-rule. A custom function takes parameters and returns a single value — like a dynamic custom property.
The token convention follows Tasty's $ vs $$ distinction:
$$name— the literal callable name (--name). Used for the definition key and for invocation$$name(...), mirroring the existing$$gradient-angle→--gradient-anglesugar. The definition key matches the call site.$name— a custom-property declaration/reference (var(--name)). Used for parameters and local variables, exactly like declaring custom props in a styles object.- Local variables are declared directly as
$namekeys on the descriptor — the body reads like a mini styles object. result, local-variable values, and parameterdefaultvalues flow through the full Tasty DSL, so units (2x), color tokens (#theme), auto-calc ((-1 * $value)→calc(-1 * var(--value))), and fallbacks (($shadow-color, black)→var(--shadow-color, black)) all work.
const Box = tasty({
styles: {
'@function': {
// simplest: one bare param + result (auto-calc, no explicit calc())
$$negative: { args: ['$value'], result: '(-1 * $value)' },
// typed param + default + return type + local variable
$$shadow: {
args: { '$shadow-color': { syntax: '<color>', default: 'inherit' } },
returns: '<color>',
$offset: '2px', // local variable
result: '$offset $offset ($shadow-color, black)',
},
},
margin: '$$negative(10px) top', // → margin: --negative(10px) 0 0 0
shadow: '$$shadow(#accent)', // → box-shadow: --shadow(var(--accent-color))
},
});Generated CSS:
@function --negative(--value) {
result: calc(-1 * var(--value));
}
@function --shadow(--shadow-color <color>: inherit) returns <color> {
--offset: 2px;
result: var(--offset) var(--offset) var(--shadow-color, black);
}
.t0 {
margin: --negative(10px) 0 0 0;
box-shadow: --shadow(var(--accent-color));
}| Field | Type | Notes |
|---|---|---|
result (required) |
string |
The result: value, parsed through the Tasty DSL. |
args |
string[] | Record<string, FunctionParameter> |
Ordered parameters. Array form lists bare names; object form maps names to type/default. |
returns |
string |
Optional return type, e.g. '<color>'. |
$name keys |
string | number |
Any $name key declares a local variable --name (value parsed). |
A FunctionParameter is one of: true (bare param), a string CSS type shorthand ('<length>'), or { syntax?: string; default?: string | number }.
Functions can also be registered with the useFunction style function or globally via configure({ functions }) (the same map also accepts bare-key parse functions).
@functionis permanent — injected once and never cleaned up, like@counter-style. It is an experimental CSS feature (Chrome 139+); unsupported browsers safely ignore the native rule, so use it as progressive enhancement — or enable theconfigure({ polyfills: { functions: true } })inline polyfill to expand$$name(...)calls into plain CSS for full cross-browser support.
For a complete reference of all enhanced style properties — syntax, values, modifiers, and recommendations — see Style Properties Reference.
- React API —
tasty()factory, component props, variants, sub-elements, style functions - Methodology — Recommended patterns: root + sub-elements, styleProps, tokens, wrapping
- Configuration — Tokens, recipes, custom units, style handlers, TypeScript extensions
- Style Properties — Complete reference for all enhanced style properties
- Build-Time Extraction (
tastyStatic) — Static styling with the Babel plugin