Style DSL
This is the Tasty style language reference — the value syntax, state mappings, tokens, units, extending semantics, and special declarations that apply to both runtime tasty() and build-time tastyStatic().
For the runtime React API (tasty(), hooks, component props), see React API. For all enhanced style properties, see Style Properties. For global configuration, see Configuration.
Dictionary
Style Mapping
Object where keys represent states and values are the styles to apply:
fill: { '': '#white', hovered: '#gray.05', 'theme=danger': '#red' }
State Key Types
| 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] |
Operator precedence (highest to lowest): ! (NOT) > ^ (XOR) > | (OR) > & (AND). Use parentheses to override: hovered & (pressed ^ 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
}
Default State Ordering
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.
Sub-element
Element styled using a capitalized key. Identified by data-element attribute:
styles: { Title: { preset: 'h3' } }
// Targets: <div data-element="Title">
Selector Affix ($)
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: {
$: '>@:last-child',
border: 'none',
},
},
});
// → .t0 > [data-element="Item"]:last-child { border: none }
Color Token
Named color prefixed with # that maps to CSS custom properties. Supports opacity with .N suffix:
fill: '#purple.5' // → var(--purple-color) with 50% opacity
Modifier
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 Runtime — Mod Props.
Color Tokens & Opacity
color: '#purple', // Full opacity
color: '#purple.5', // 50% opacity
color: '#purple.05', // 5% opacity
fill: '#current', // → currentcolor
fill: '#current.5', // → color-mix(in oklab, currentcolor 50%, transparent)
color: '(#primary, #secondary)', // Fallback syntax
Built-in Units
| Unit | Description | Example | CSS Output |
|---|---|---|---|
x | Gap multiplier | 2x | calc(var(--gap) * 2) |
r | Border radius | 1r | var(--radius) |
cr | Card border radius | 1cr | var(--card-radius) |
bw | Border width | 2bw | calc(var(--border-width) * 2) |
ow | Outline width | 1ow | var(--outline-width) |
sf | Stable fraction | 1sf | minmax(0, 1fr) |
You can register additional custom units via configure().
Replace Tokens
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',
},
});
Recipes
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',
},
});
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',
},
});
Extending vs. Replacing State Maps
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):
fill: { pressed: null } // removes pressed from the result
fill: { disabled: false } // tombstone — no CSS for disabled, blocks recipe too
Resetting Properties with null and false
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 |
Advanced States (@ prefix)
| 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(...) — Media Queries
Media queries support dimension shorthands and custom unit expansion:
| 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(prefers-color-scheme: dark) | @media (prefers-color-scheme: dark) |
@media(prefers-reduced-motion) | @media (prefers-reduced-motion) |
Custom units work inside media queries: @media(w < 40x) → @media (width < calc(var(--gap) * 40)).
In practice, define state aliases via configure({ states }) and use @mobile instead of writing the full query in every component.
@(...) — Container Queries
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").
@supports(...) — Feature Queries
Feature queries test CSS property support. 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(display: grid) | @supports (not (display: grid)) |
display: {
'': 'flex',
'@supports(display: grid)': 'grid',
}
_ — Fallback Floor
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 ...; }
@container scroll-state(scrolled: block-end) {
@supports (container-type: scroll-state) {
.t0.t0 { inset: -80px ...; }
}
}
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() below).
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.
_ vs the '' default
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(...) — Root Element States
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: :root[data-schema="dark"] .t0.t0 { ... }.
@own(...) — Sub-element's Own State
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.
@starting — Entry Animation
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 0.3s, translate 0.3s',
},
});
| Tasty syntax | CSS output |
|---|---|
@starting | @starting-style { .t0.t0 { ... } } |
@parent(...) — Parent Element States
Style based on ancestor element attributes. Uses :is([selector] *) / :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) | :is([data-hovered] *) |
!@parent(hovered) | :not([data-hovered] *) |
@parent(hovered, >) | :is([data-hovered] > *) (direct parent) |
@parent(.active) | :is(.active *) |
@parent(hovered & focused) | :is([data-hovered][data-focused] *) (same ancestor) |
@parent(hovered) & @parent(focused) | :is([data-hovered] *):is([data-focused] *) (independent ancestors) |
@parent(hovered | focused) | :is([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:is([data-hovered] *) [data-element="Label"]
:is(), :has() — CSS Structural Pseudo-classes
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 *.
: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) | Structural match |
: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 present
Nesting 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.
Keyframes
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)' },
},
},
},
});
Properties (@property)
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%)',
background: 'linear-gradient($gradient-angle, #theme, transparent)',
transition: '$$gradient-angle 0.3s, ##theme 0.3s',
},
});
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 @properties when you need non-default settings like inherits: false:
'@properties': {
'$gradient-angle': { syntax: '<angle>', inherits: false, initialValue: '0deg' },
},
Font Face (@fontFace)
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: {
'@fontFace': {
'Brand Sans': {
src: 'url("/fonts/brand-sans.woff2") format("woff2")',
fontDisplay: 'swap',
},
},
fontFamily: '"Brand Sans", sans-serif',
},
});
Multiple weights
Supply an array to register several variants of the same family:
'@fontFace': {
'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' },
],
}
Supported descriptors
| 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.
Counter Style (@counterStyle)
Define custom list markers via the CSS @counter-style at-rule. Keys are counter-style names, values are descriptor objects.
const EmojiList = tasty({
tag: 'ol',
styles: {
'@counterStyle': {
thumbs: {
system: 'cyclic',
symbols: '"👍"',
suffix: '" "',
},
},
listStyleType: 'thumbs',
},
});
Supported descriptors
| 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.
Style Properties
For a complete reference of all enhanced style properties — syntax, values, modifiers, and recommendations — see Style Properties Reference.
Learn more
- 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
- Zero Runtime (tastyStatic) — Build-time static styling with Babel plugin