Tasty logoTasty
Playground

Style DSL

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.


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

SyntaxExampleGenerated CSS
Boolean modifierhovered[data-hovered]
Value modifiertheme=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]

Automatic Native States

disabled and checked are built-in automatic states. On a Tasty component, they track the corresponding native prop or attribute, so these pairs express the same component state:

Concise Tasty stateExplicit attribute selector
disabled[disabled]
checked[checked]

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).

^ (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.

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):

PatternResultDescription
(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
h1h1Tag selector (no key injection)
h1 >h1 > [data-element="Cell"]Key is direct child of tag
h1 *h1 *Any descendant of tag
**All descendants
&::before::beforeRoot pseudo (no key); & is required
@::before[data-element="Cell"]::beforePseudo on the sub-element
>@.active> [data-element="Cell"].activeClass on the sub-element

Rules for key injection ([data-element="..."]):

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'; // → oklch(from var(--purple-color) l c h / .5)

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 React API — Mod Props.


Color Tokens & Opacity

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
color: '(#primary, #secondary)',  // Fallback syntax

The suffix sets the alpha on the token's color with CSS relative color syntax:

fill: '#purple.5';
// → oklch(from var(--purple-color) l c h / .5)

The channels are copied over and the alpha slot is written, which asks nothing of the color beyond being a color. That means the suffix works on every one of these:

Two properties follow from writing the alpha slot rather than compositing:

#current is the keyword

#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.

One case needs the wider floor to be considered. A token defined as #current and then faded gives relative color syntax a currentcolor origin, which Safari supports only from 18:

{ '#ink': '#current', fill: '#ink.5' }
// --ink-color: currentcolor
// fill: oklch(from currentcolor l c h / .5)  → Chrome, Firefox, Safari 18+

Put the fade in the token instead — { '#ink': '#current.5' } — and it goes through color-mix(), which works from Safari 16.2 and composes like every other #current fade.

#current composes instead

#current is the one exception, and the difference is deliberate. A token names a color, so fading it sets its alpha. currentcolor is the color an element inherits, which an ancestor may already have faded — #current.4 means "40% of what reaches me":

fill: '#current.4';
// → color-mix(in oklab, currentcolor 40%, transparent)

So 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 — replacing would double the opacity of every nested step. Because a color-mix() percentage cannot be a <number>, an opacity custom property used as #current.$fade must hold a unitless number; a token accepts either form.

The space is always oklch: it is unbounded, so a wide-gamut color survives a round trip that a gamut-limited space would clamp. Nothing configures this — see Color space.


CSS Color Functions

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)',

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'; // → oklch(from light-dark(…) l c h / .5)

Built-in Units

UnitDescriptionExampleCSS Output
xGap multiplier2xcalc(var(--gap) * 2)
rBorder radius1rvar(--radius)
crCard border radius1crvar(--card-radius)
bwBorder width2bwcalc(var(--border-width) * 2)
owOutline width1owvar(--outline-width)
sfStable fraction1sfminmax(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',
  },
});

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 name order (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',
  },
});

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:

// 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)
  },
});
ValueMeaningRecipe fills in?
undefinedNot provided — parent preservedN/A
nullIntentional unset — parent discardedYes
falseTombstone — blocks everythingNo

Advanced States (@ prefix)

PrefixPurposeExample
@mediaMedia queries@media(w < 768px)
@(...)Container queries@(panel, w >= 300px)
@supportsFeature/selector support@supports(display: grid)
@rootRoot element states@root(schema=dark)
@parentParent/ancestor element states@parent(hovered)
@ownSub-element's own state@own(hovered)
@startingEntry 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 / @parent context) 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:

ShorthandExpands to
wwidth
hheight
fill: {
  '': '#surface',
  '@media(w < 768px)': '#surface-mobile',
  '@media(600px <= w < 1200px)': '#surface-tablet',
  '@media(prefers-color-scheme: dark)': '#surface-dark',
}
Tasty syntaxCSS 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.

ShorthandExpands to
wwidth
hheight
isinline-size
bsblock-size
const Panel = tasty({
  styles: {
    flow: {
      '': 'column',
      '@(layout, w >= 600px)': 'row',
    },
  },
});
Tasty syntaxCSS 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 and accept CSS support functions such as at-rule(). Use $ as the first argument to test selector support:

Tasty syntaxCSS 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.

_ — 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:

_ 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).

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 syntaxCSS 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, translate',
  },
});
Tasty syntaxCSS 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"]
    },
  },
});
SyntaxCSS 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
    },
  },
});
SyntaxCSS OutputMeaning
: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%)',
    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' },
},

Font Face (@font-face)

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',
  },
});

Multiple weights

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' },
  ],
}

Supported descriptors

DescriptorCSS propertyType
src (required)srcstring
fontWeightfont-weightstring | number
fontStylefont-stylestring
fontStretchfont-stretchstring
fontDisplayfont-display'auto' | 'block' | 'swap' | 'fallback' | 'optional'
unicodeRangeunicode-rangestring
ascentOverrideascent-overridestring
descentOverridedescent-overridestring
lineGapOverrideline-gap-overridestring
sizeAdjustsize-adjuststring
fontFeatureSettingsfont-feature-settingsstring
fontVariationSettingsfont-variation-settingsstring

Font-face rules are permanent — they are injected once and never cleaned up, matching how browsers handle @font-face.


Counter Style (@counter-style)

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: {
    '@counter-style': {
      thumbs: {
        system: 'cyclic',
        symbols: '"👍"',
        suffix: '" "',
      },
    },
    listStyleType: 'thumbs',
  },
});

Supported descriptors

DescriptorCSS propertyType
system (required)system'cyclic' | 'numeric' | 'alphabetic' | 'symbolic' | 'additive' | 'fixed' | string
symbolssymbolsstring
additiveSymbolsadditive-symbolsstring
prefixprefixstring
suffixsuffixstring
negativenegativestring
rangerangestring
padpadstring
fallbackfallbackstring
speakAsspeak-asstring

Counter-style rules are permanent — they are injected once and never cleaned up, matching how browsers handle @counter-style.


Functions (@function)

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:

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));
}

Descriptor shape

FieldTypeNotes
result (required)stringThe result: value, parsed through the Tasty DSL.
argsstring[] | Record<string, FunctionParameter>Ordered parameters. Array form lists bare names; object form maps names to type/default.
returnsstringOptional return type, e.g. '<color>'.
$name keysstring | numberAny $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 hook or globally via configure({ functions }) (the same map also accepts bare-key parse functions).

@function is 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 the configure({ polyfills: { functions: true } }) inline polyfill to expand $$name(...) calls into plain CSS for full cross-browser support.


Style Properties

For a complete reference of all enhanced style properties — syntax, values, modifiers, and recommendations — see Style Properties Reference.


Learn more