Skip to content

Latest commit

 

History

History
957 lines (736 loc) · 58.2 KB

configuration.md

File metadata and controls

957 lines (736 loc) · 58.2 KB

Configuration

Tasty works without upfront configuration. Use configure() when your app or design system is ready to define shared tokens, state aliases, units, recipes, presets, or extension points.

Call it before any styles are generated (before first render). For a guided setup, see Getting Started. For the higher-level docs map, see the Docs Hub.

import { configure } from '@tenphi/tasty';

configure({
  // CSP nonce for style elements
  nonce: 'abc123',

  // Global state aliases
  states: {
    '@mobile': '@media(w < 768px)',
    '@tablet': '@media(768px <= w < 1024px)',
    '@dark': '@root(schema=dark)',
  },

  // Parser configuration
  parserCacheSize: 2000, // LRU cache size (default: 1000)

  // Custom units (merged with built-in units)
  units: {
    gu: '4px', // `2gu` → `8px`
    cols: (n) => `calc(${n} * var(--col-width))`, // function-based unit
  },

  // Custom functions — a single map for both flavors, discriminated by value type:
  functions: {
    // Bare key + function value → parse-time function, called as `double(...)`
    double: (groups) => {
      const value = parseFloat(groups[0]?.output || '0');
      return `${value * 2}px`;
    },
    // `$$name` key + object value → declarative CSS @function, called as `$$negative(...)`
    $$negative: { args: ['$value'], result: '(-1 * $value)' },
  },
});

These docs use data-schema="dark" in examples. If your app already standardizes on a different attribute such as data-theme, keep the same pattern and swap the attribute name consistently everywhere you define root-state aliases.


Options

Option Type Default Description
nonce string - CSP nonce for style elements
maxRulesPerSheet number 8192 Maximum rules per injected stylesheet
forceTextInjection boolean false (true in test environments only while configure() has never been called) Force text-node CSS injection instead of constructable stylesheets. Set it explicitly in test setup
devMode boolean auto Enable development-mode features: performance metrics and debug info. Does not turn on dev warnings; those follow NODE_ENV (or localStorage.TASTY_DEBUG = 'true' in a browser)
states Record<string, string> - Global state aliases for advanced state mapping
parserCacheSize number 1000 Parser LRU cache size
units Record<string, string | UnitHandler> Built-in Custom units (merged with built-in). See built-in units
functions Record<string, FunctionDefinition | Function> - Custom functions (merged). Bare keys → parse functions; $$name keys → declarative CSS @function definitions
handlers Record<string, StyleHandlerDefinition> Built-in Custom style handlers (replace built-in). See Custom Style Handlers
propHandlers Record<string, PropHandlerDefinition> - Props middleware for every component — props in, props out. See Props Middleware
baseStyleProps readonly string[] - Style names exposed as props on every component. See Base Style Props
tokens Record<string, value | stateMap> - Design tokens injected as :root CSS custom properties
replaceTokens Record<string, string | number | boolean> { '#clear': 'transparent' } Parse-time token substitution (inline replacement). boolean is allowed for # color tokens
keyframes Record<string, KeyframesSteps> - Global keyframes for animations
properties Record<string, PropertyDefinition> - Global CSS @property definitions
fontFaces Record<string, FontFaceInput> - Global @font-face definitions
counterStyles Record<string, CounterStyleDescriptors> - Global @counter-style definitions
polyfills { functions?: boolean } {} Opt-in polyfills for not-yet-baseline features. functions: true inlines @function calls into plain CSS at parse time
autoPropertyTypes boolean true Auto-infer and register @property types from values
recipes Record<string, RecipeStyles> - Predefined style recipes (named style bundles)
presets Record<string, TypographyPreset> - Typography presets — shorthand for generateTypographyTokens()
globalStyles Record<string, Styles> - Global Tasty styles keyed by CSS selector
plugins TastyPlugin[] - Plugins that bundle functions, units, states, handlers, propHandlers, baseStyleProps, tokens, replaceTokens, recipes, presets, globalStyles, properties, keyframes, fontFaces, and counterStyles (processed in order; later override earlier, and direct config wins over all). See Plugins
gc GCConfig - Turns on automatic collection of unused styles: { touchInterval?, capacity?, grace? } (defaults 1000, 1000, 10000 ms). Without it nothing is collected automatically; call gc() yourself
batchInjection boolean | 'always' false Defer stylesheet writes and apply them in one batch. See Batched injection
colorSpace 'rgb' | 'hsl' | 'oklch' - Deprecated — no longer has any effect. See Color space
namePrefix string 't' (tasty) / 'ts' (build-time) Prefix prepended to every generated identifier (class, keyframe, counter-style names). Must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$. See Name prefix.

Batched Injection

Every insertRule() on a live stylesheet invalidates style for that sheet's scope. Components inject during React's render phase, and if anything else reads layout in the same pass, the two interleave:

inject -> read (forced style recalc) -> inject -> read (forced recalc) -> ...

A UI kit that measures during render — popovers, autosizing inputs, virtualized lists — can turn one mount into a dozen full-tree recalculations. batchInjection queues the writes instead and applies them together, so the tree is invalidated once per flush.

import { configure, TastyBatchProvider } from '@tenphi/tasty';

configure({ batchInjection: true });

createRoot(el).render(
  <TastyBatchProvider>
    <App />
  </TastyBatchProvider>,
);

Why the provider is required

Deferring a write past React's layout phase would let a useLayoutEffect measure an element whose rules are not in the sheet yet and read its unstyled box — a wrong number, not a stale one, so it never self-corrects.

TastyBatchProvider closes that hole. It opens a batch window during its render and closes it — flushing — in useInsertionEffect, which React runs in the mutation phase, before any layout effect:

provider renders          -> window OPEN
  children render         -> injections queued
provider insertionEffect  -> FLUSH, window CLOSED
layout effects run        -> rules are in the sheet

With batchInjection: true a write is only ever queued inside such a window, so turning the flag on can make injection cheaper but can never make a measurement wrong. Injections outside a window are written straight through, exactly as with batching off:

  • a deep update that did not re-render the provider
  • an injection from a layout effect, an event handler or an async callback
  • SSR and RSC, where there is no live sheet at all

Mount the provider as high in the tree as you can: batching applies to the commits it takes part in.

Modes

Value Behaviour
false (default) One insertRule() per component, synchronously.
true Batch inside a provider window only. Cannot affect measurement. Without the provider nothing is batched, and dev mode says so once.
'always' Batch every injection, flushing on a microtask when no window is open. Covers more commits; a useLayoutEffect measuring a freshly mounted element can read its unstyled box. Paint is unaffected — microtasks always drain before the browser paints.

Ordering

All writes share one FIFO queue — component rules, global rules, raw CSS, @property, @keyframes, @font-face, @counter-style and @function. Draining it in insertion order keeps the sheet byte-identical to unbatched output, which matters because equal-specificity rules resolve by document order.

Client rendering, server rendering, and build-time extraction

Server render — nothing to batch, safe to leave enabled. SSR and RSC collect CSS as text through ServerStyleCollector; the runtime injector never runs there, so batchInjection changes nothing either way. <TastyBatchProvider> renders its children and does nothing else: the window it opens is a no-op without a document, and the useInsertionEffect that would close it never fires on the server. Configure the flag once in shared code — no environment branching needed.

Hydration — the better your SSR coverage, the less there is to batch. hydrateTastyClasses() (wired up for you by @tenphi/tasty/ssr/next and the Astro integration) pre-populates the injector from window.__TASTY__, so hydrating a server-rendered class is a cache hit that produces no sheet write at all. Batching pays off on what SSR could not cover: client-only routes, styles that first appear after hydration (a styles prop that changes on interaction, a modal or popover mounting), and dynamic tokens.

Astro islands — one provider per island, or 'always'. Every island is its own React root, so a provider inside one island does not open a window for another. With batchInjection: true, wrap each island root that renders enough tasty components to be worth it. 'always' needs no provider and covers every island, at the cost of the measurement hazard above. Either way, an island that only re-hydrates server-rendered classes has nothing to batch.

Build-time extraction (tastyStatic) — unaffected. The Babel plugin never touches the injector: the babel plugin emits either a CSS file import or an injectCSS() call from @tenphi/tasty/static/inject, which appends text to a single <style data-tasty-static> element. batchInjection only defers CSSOM writes made by the browser injector, so extracted styles are unchanged. In an app that mixes both, it still applies to the tasty() half.

flushStyles()

Applies every pending write immediately. Every injector read API calls it for you — getCSSText, getCSSTextForClasses, getCSSTextForNode, getRawCSSText, isPropertyDefined, getMetrics, cleanup, gc and destroy — so a read never observes a partial sheet. Call it directly before measuring in code that runs outside a batch window under 'always':

import { flushStyles } from '@tenphi/tasty';

useLayoutEffect(() => {
  flushStyles();
  const { width } = ref.current.getBoundingClientRect();
}, []);

hasPendingStyleWrites() reports whether anything is queued, and resetStyleBatch() drops the queue without applying it (tests only).


Color Space

Deprecated. configure({ colorSpace }) no longer has any effect and will be removed in the next major. Setting it warns in development.

A #name token's value used to be rewritten into a configured color space, so #brand: '#ff8800' declared --brand-color: oklch(0.75 0.16 55). That existed to serve the opacity suffix, which needed numeric channels to write an alpha into. Opacity now mixes the token with transparent — color-mix(in oklab, var(--brand-color) 50%, transparent) — which the browser resolves against any color, so there is nothing left for the setting to decide.

A color is emitted exactly as authored:

configure({ tokens: { '#brand': '#ff8800' } });
// → --brand-color: #ff8800

That holds for every form: a hex literal, a native color function, a bare CSS color name, a color-mix(), a light-dark(), a fallback chain. A #token reference still resolves to its var() chain, and a plugin color function such as okhsl() is still resolved by the parser to the color it denotes.

To address a token's channels, use relative color syntax against the token:

background: oklch(from var(--brand-color) calc(l * 1.2) c h);

This works on any <color>, including the ones no build-time conversion could have evaluated, and oklch is unbounded, so a wide-gamut color survives a round trip a narrower space would clamp. Relative color syntax is Baseline 2024 (Chrome 125, Firefox 128, Safari 18), so the opacity suffix uses the widely available color-mix() instead.

Migrating off colorSpace

Drop the option. If you relied on the uniform output format, nothing in Tasty needs it — author your tokens in the space you want them emitted in, since the value now passes through untouched:

// before: any input, normalized to the configured space on the way out
configure({ colorSpace: 'oklch', tokens: { '#brand': '#ff8800' } });

// after: author it in the space you want
configure({ tokens: { '#brand': 'oklch(0.75 0.16 55)' } });

Migrating off the channel companions

A #name token used to declare a second variable beside --name-color holding its channels decomposed, suffixed with the configured space — --brand-color-oklch: 0.75 0.16 55 — and a color style emitted --current-color-{space} beside --current-color. Both are gone, for the same reason: they existed so an opacity suffix had channels to write an alpha into.

These were never part of the public API, but they were visible in the emitted CSS, so hand-authored CSS may reference one. Address the token itself instead:

/* before */
color: oklch(var(--brand-color-oklch) / 0.5);
background: oklch(var(--brand-color-oklch));

/* after */
color: color-mix(in oklab, var(--brand-color) 50%, transparent);
background: var(--brand-color);

The replacement is strictly more capable — the companion could only be decomposed for a color the engine could evaluate at build time, while color-mix() works on every <color>, including a color-mix(), a light-dark(), and a variable Tasty never defined. It is also what #brand.5 emits.


Name Prefix

Every identifier Tasty generates — class names, keyframe names, counter-style names — starts with a configurable prefix. The tasty() client, SSR, and RSC paths default to 't'; the build-time extraction path (tastyStatic via the Babel plugin) defaults to 'ts' so extracted classes can never collide with tasty() classes when both are loaded on the same page.

configure({
  namePrefix: 'mb',
});

The prefix is prepended verbatim to the hash, so include any separator inside the prefix string itself:

Setting Class Keyframe Counter-style
't' (tasty default) t1a2b3 tk1a2b3 tc1a2b3
'ts' (build-time default) ts1a2b3 tsk1a2b3 tsc1a2b3
'mb' mb1a2b3 mbk1a2b3 mbc1a2b3
'myapp-' myapp-1a2b3 myapp-k1a2b3 myapp-c1a2b3

The single-letter discriminators (k for keyframes, c for counter-styles) keep the three kinds visually distinct in devtools — they are not required for correctness because CSS keeps these in separate namespaces.

Rules

  • Must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$. Examples that pass: 't', 'ts', 'app', 'myapp-', '_foo'. Examples that fail: '', '1foo', 'my app'.
  • Validated at configure() time; an invalid prefix throws synchronously rather than silently producing broken hydration.
  • Locked once styles have been generated, like all other config.

Coexistence with build-time extraction

The tasty() and tastyStatic() paths must use different prefixes when both are loaded on the same page. Defaults already guarantee this; if you customize one, customize the other accordingly:

// app config (client / SSR / RSC)
configure({ namePrefix: 'mb' });

// tasty-zero.config.ts (Babel plugin)
export default { namePrefix: 'mbs' };

If you only use one of the two builds, you only need to set namePrefix on that path.


Design Tokens

Design tokens define CSS custom properties on :root. They are injected automatically when the first style is rendered. Values are parsed through the Tasty DSL, so you can use units, color syntax, and other DSL features.

Tokens support state maps for responsive or theme-aware values:

configure({
  tokens: {
    '$sidebar-width': '280px',
    '#primary': {
      '': '#purple',
      '@dark': '#light-purple',
    },
    '$font-size': {
      '': '14px',
      '@mobile': '12px',
    },
  },
});
  • $name keys become --name CSS custom properties
  • #name keys become --name-color custom properties
  • Tokens do not change units: 2x stays 16px whatever $gap is. To drive a unit from a token, point the unit at it: units: { x: 'var(--gap)' } makes 2x compile to calc(2 * var(--gap))
  • Names keep their case, since CSS custom properties are case-sensitive: $myVar is --myVar and is referenced as $myVar. A leading capital is not supported and folds — $Foo and #Purple become --foo and --purple-color — so start names lowercase. Kebab-case ($my-var) remains the convention.

Tokens are emitted by the client injector, by the SSR collector (TastyRegistry, tastyIntegration(), runWithCollector()), by the withTastyNext() shared stylesheet, and by the Babel plugin. The inline React Server Component path without a collector does not emit them — see Server-Side Rendering.


Replace Tokens (Parse-Time Substitution)

Replace tokens are substituted inline at parse time — they are baked into the generated CSS, not resolved via CSS custom properties at runtime. This makes them ideal for value aliases and shorthand references.

Use $name for value tokens and #name for color token aliases:

configure({
  replaceTokens: {
    $spacing: '2x',
    '$card-padding': '4x',
    '#accent': '#purple',
    '#surface-hover': '#gray.05',
  },
});

When a component uses padding: '$card-padding', the parser replaces it with '4x' before generating CSS. When a component uses fill: '#accent', it is replaced with '#purple', which in turn resolves to var(--purple-color).

Tasty includes '#clear': 'transparent' as a default replace token, available without calling configure(). It survives resetConfig(), and explicit replaceTokens entries can override it. A replace token resolving to the literal transparent ignores numeric and custom-property opacity suffixes: #clear, #clear.5, and #clear.$opacity all emit transparent.

#clear no longer reads --clear-color, and Tasty no longer registers that property by default. If you previously customized #clear through tokens, local token definitions, or CSS, use a different color token or explicitly override replaceTokens['#clear'] instead.

See Replace Tokens in the Style DSL reference.


Font Face

Register custom fonts globally so every component can reference them by family name. Values are descriptor objects or arrays (for multiple weights/styles). Rules are injected eagerly when styles are first generated.

configure({
  fontFaces: {
    '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',
      },
    ],
    Icons: {
      src: 'url("/fonts/icons.woff2") format("woff2")',
      fontDisplay: 'block',
    },
  },
});

Now any component can use font: '"Brand Sans", sans-serif' and the browser will already have the @font-face rules in the stylesheet.

See Font Face (@font-face) for inline usage inside component styles and the full list of supported descriptors.


Counter Style

Define custom list-marker algorithms globally. Rules are injected eagerly when styles are first generated.

configure({
  counterStyles: {
    thumbs: {
      system: 'cyclic',
      symbols: '"👍"',
      suffix: '" "',
    },
    'lower-roman-parens': {
      system: 'extends lower-roman',
      suffix: '") "',
    },
  },
});

Components can then reference listStyleType: 'thumbs' directly.

See Counter Style (@counter-style) for inline usage inside component styles and the full list of supported descriptors.


Functions

The single functions map holds both kinds of custom functions, discriminated by the value type:

  • Parse functions — a bare key with a function value (groups) => string. Runs at parse time and is called as name(...). Use these for computed/derived CSS that JavaScript produces (e.g. color-space conversions).
  • CSS @function definitions — a $$name key with an object value (a FunctionDefinition). Injected eagerly as a native @function rule and called as $$name(...) (→ --name(...)).
configure({
  functions: {
    // Parse function — bare key, function value
    double: (groups) => `calc(2 * ${groups[0]?.output ?? '0'})`,

    // CSS @function — `$$` key, object value
    $$negative: { args: ['$value'], result: '(-1 * $value)' },
    $$shadow: {
      args: { '$shadow-color': { syntax: '<color>', default: 'inherit' } },
      returns: '<color>',
      $offset: '2px',
      result: '$offset $offset ($shadow-color, black)',
    },
  },
});

Components then invoke parse functions as double(...) and CSS functions with the $$name(...) sugar, e.g. margin: '$$negative(10px) top'.

A key whose prefix doesn't match its value type (an object under a bare key, or a function under a $$ key) is ignored with a dev-mode warning.

See Functions (@function) for inline usage inside component styles, the full descriptor shape, and token conventions. @function is an experimental CSS feature — unsupported browsers safely ignore the native rule (see the polyfill below).

Custom color functions

A parse function whose output is an already-supported color (rgb, hsl, #…, oklch, …) is treated as a color function: it works everywhere a color is accepted — style values, parseColor, and the #token.alpha opacity suffix, which calls the function with a / alpha part appended (read groups[0].parts[1], as below) — with no extra registration. This is the same mechanism the built-in okhsl/okhst plugins use; they are ordinary plugins registered by default.

import { configure, createColorFunc } from '@tenphi/tasty';

// A custom color space is just a `functions` entry.
const myColorPlugin = () => ({
  name: 'mycolor',
  functions: {
    // Hand-written parse function:
    mycolor: (groups) => {
      // `mycolor(255 0 0 / .5)` and `#brand.5` both arrive as two parts
      const [main, alpha] = groups[0].parts;
      const [r, g, b] = main.all;
      return alpha?.all.length
        ? `rgb(${r} ${g} ${b} / ${alpha.output})`
        : `rgb(${r} ${g} ${b})`;
    },
  },
});

configure({ plugins: [myColorPlugin()] });

// Now `mycolor(...)` is a color in every context:
//   fill: 'mycolor(255 0 0)'
//   fill: '#brand.5'   (with replaceTokens: { '#brand': 'mycolor(255 0 0)' })

For HSL-style color spaces (a hue angle plus two percentages), the exported createColorFunc(name, convert, label?) helper handles angle/percentage parsing, clamping, alpha, and caching. convert returns sRGB [r, g, b] in 0-1; label is an optional string used only in dev warnings (e.g. 'H S L') and has no effect on output. This is exactly how okhslPlugin/okhstPlugin are implemented.


Polyfills

@function only ships natively in Chromium 139+. To use CSS functions in browsers that don't support the at-rule yet (Firefox, Safari), enable the functions polyfill, which expands every $$name(...) call into plain CSS (calc()/var()/color-mix()) at parse time instead of emitting the native @function rule:

configure({
  polyfills: {
    functions: true, // default: false
  },
  functions: {
    $$negative: { args: ['$value'], result: '(-1 * $value)' },
  },
});

// Now `margin: '$$negative(10px) top'` renders `margin: calc(-1 * 10px) 0 0 0`
// — no native @function rule is emitted.

This works across all rendering modes (client, SSR/RSC, and tastyStatic) because expansion happens in the parser. Note polyfills.functions (the feature toggle) is distinct from the top-level functions (the definitions map).

Decided limitations when the polyfill is on:

  • No native fallback — functions are always inlined; we do exactly what is configured.
  • Conditional results (@media/@supports/if() inside result) are inlined verbatim; the conditional nuance is not resolved per-element.
  • Typed params / returns are dropped (inlining is purely lexical substitution).
  • Param name collisions are avoided by fully inlining argument values (no function-internal custom properties are emitted) and by namespacing the function's own variables.
  • Recursion — self/mutually-recursive functions are not expanded; the cycle guard bails and leaves the call untouched.

Recipes

Recipes are predefined, named style bundles. Define them globally via configure():

configure({
  recipes: {
    card: {
      padding: '4x',
      fill: '#surface',
      radius: '1r',
      border: true,
    },
    elevated: {
      shadow: '2x 2x 4x #shadow',
    },
  },
});

Recipe values are flat tasty styles (no sub-element keys). They may contain base styles, tokens, local states, @keyframes, and @property.

Recipes cannot reference other recipes. A recipe key inside a configured recipe's value is not recursively resolved:

// Unsupported: applying 'elevatedCard' does not expand 'card elevated'.
configure({
  recipes: {
    elevatedCard: {
      recipe: 'card elevated',
      padding: '6x',
    },
  },
});

Instead, compose the names where you apply the styles: styles: { recipe: 'card elevated', padding: '6x' }. Recipe references are resolved on the component root and independently inside each sub-element's styles (for example, styles: { Panel: { recipe: 'card elevated' } }). This sub-element support does not allow one recipe definition to inherit another. Tasty warns in development when a recipe definition contains a recipe key.

For how to apply, compose, and override recipes in components, see Recipes in the Style DSL reference.


Typography Presets

Typography presets are a shorthand for generateTypographyTokens(). Instead of calling the function manually and spreading the result into tokens, pass the presets directly:

configure({
  presets: {
    h1: { fontSize: '32px', lineHeight: '1.2', fontWeight: '700' },
    t2: { fontSize: '16px', lineHeight: '1.5', fontWeight: '400' },
    tag: {
      fontSize: '10px',
      lineHeight: '1.4',
      letterSpacing: '0.04em',
      fontWeight: '600',
    },
  },
});

Each preset generates $name-font-size, $name-line-height, $name-letter-spacing, $name-font-weight, and optional $name-bold-font-weight, $name-icon-size, $name-text-transform, $name-font-family, $name-font-style tokens.

Preset values support state maps for responsive or theme-aware typography:

configure({
  presets: {
    t2: {
      fontSize: '16px',
      lineHeight: '1.5',
      fontWeight: { '': '400', '@dark': '300' },
    },
  },
});

The generated tokens are merged under explicit tokens — if both presets and tokens define $t2-font-weight, the tokens value wins. Plugins can also provide presets; plugin presets are merged first, then config presets, then explicit tokens on top.


Global Styles

Apply Tasty styles to any selector via configuration, without needing useGlobalStyles(selector, ...) at runtime:

configure({
  globalStyles: {
    body: {
      fill: '#surface',
      color: '#text',
      preset: 't2',
      margin: 0,
      font: 'system-ui, sans-serif',
    },
    html: {
      overflow: 'hidden',
    },
  },
});

Each key is a CSS selector (a list such as '.uni-checkbox, uni-checkbox' styles every item), and each value is a Tasty Styles object supporting the full style syntax including style properties, tokens, state maps, @keyframes, and selector-based sub-styling (e.g. $: '> .app' for elements outside React scope). Global styles are injected alongside :root tokens when the first style is rendered.

Global styles are automatically emitted in all delivery modes: client rendering, server rendering, and build-time extraction with the Babel plugin. Plugins can also provide globalStyles; they are merged per selector with config global styles (config wins on conflict).


Auto Property Types

CSS cannot transition or animate custom properties unless their type is declared via @property. Tasty handles this automatically — when a custom property is assigned a concrete value (e.g. '$scale': 1, '$gap': '10px', '#accent': 'purple'), the type is inferred and a @property rule is registered.

This works across all declaration contexts: component styles, @keyframes, global config, and build-time extraction with the Babel plugin. It also resolves var() chains — if $a references var(--b), the type propagates once --b is resolved.

Supported types:

Detection Inferred syntax
1, 0.5, -3 (bare numbers) <number>
10px, 2rem, 100vw (length units) <length>
50% <percentage>
45deg, 0.5turn (angle units) <angle>
300ms, 1s (time units) <time>
#name tokens (by naming convention) <color>

Auto-inferred properties use inherits: true (the CSS default). Use explicit @property when you need different settings:

// In component styles
styles: {
  '@property': {
    '$scale': { syntax: '<number>', inherits: false, initialValue: 1 },
  },
}

// Or globally
configure({
  properties: {
    '$scale': { syntax: '<number>', inherits: false, initialValue: 1 },
  },
});

To disable auto-inference entirely (only explicit @property will be used):

configure({ autoPropertyTypes: false });

Custom Style Handlers

Override or extend the built-in style property handlers. A handler definition can take three forms:

Form Syntax Description
Function only handler Triggered by its key name; receives only that property
Single dep ['styleName', handler] Triggered by the specified style property
Multi dep [['dep1', 'dep2', ...], handler] Triggered by any of the listed properties; receives all of them

The multi-dep form is useful when output depends on several style properties together (e.g., gap needs to know display and flow to decide the CSS strategy). A handler whose dependencies include a built-in style takes over that style's whole group — see Replacing a built-in handler. Use defineHandler for it and the dependency types are inferred from the dependency list, so a typo in the destructure is a type error instead of a silent undefined.

import {
  configure,
  defineHandler,
  parseColor,
  parseStyle,
  styleHandlers,
} from '@tenphi/tasty';

configure({
  handlers: {
    // Function only — new single-prop handler
    elevation: ({ elevation }) => {
      const level = parseInt(elevation) || 1;
      return {
        'box-shadow': `0 ${level * 2}px ${level * 4}px rgba(0,0,0,0.1)`,
        'z-index': String(level * 100),
      };
    },

    // Overriding a built-in: declare every style the built-in handled and pass
    // them all through, or the ones you leave out stop working (see below).
    fill: defineHandler(
      [
        'fill',
        'backgroundColor',
        'image',
        'backgroundImage',
        'backgroundPosition',
        'backgroundSize',
        'backgroundRepeat',
        'backgroundAttachment',
        'backgroundOrigin',
        'backgroundClip',
        'background',
      ],
      (props) => {
        if (
          typeof props.fill === 'string' &&
          props.fill.startsWith('gradient:')
        ) {
          return { background: props.fill.slice(9) };
        }
        return styleHandlers.fill(props);
      },
    ),

    // Multi dep — triggered by either name, receives both
    stripe: defineHandler(
      ['stripe', 'stripeColor'],
      ({ stripe, stripeColor }) => {
        if (!stripe) return;
        const width = parseStyle(String(stripe)).output;
        const color = stripeColor
          ? parseColor(String(stripeColor)).color
          : 'currentcolor';
        return { 'border-left': `${width} solid ${color}` };
      },
    ),
  },
});

Return shape

A handler returns a CSSMap, an array of them, or nothing:

  • Keys are kebab-case CSS property names ('background-color', not backgroundColor) or --custom-property names. A camelCase key warns in development and is emitted verbatim, which the browser ignores.
  • Values are stringified, so numbers are fine ({ '-webkit-line-clamp': 3 }).
  • An array value emits one declaration per entry, in order, so the browser keeps the last one it supports: { width: ['-webkit-fill-available', '-moz-available', 'stretch'] } emits three width declarations.
  • The reserved $ key is a selector suffix: { $: '& > *:not(:last-child)', 'margin-right': gap } applies the declarations to a nested selector. It accepts an array to fan out over several.
  • Return an array of maps to emit several declaration sets, each with its own $.

Values arrive state-resolved but unparsed — the raw authored DSL string ('2x', '#purple.5', true, 4). Call the exported parseStyle() / parseColor() yourself; nothing parses them for you.

Replacing a built-in handler

Built-in handlers are shared across several style names. Registering a handler for one of those names unregisters the built-in from all of them, and the displaced names then fall back to auto-generated CSS aliases — so hide: true starts emitting a literal hide: true declaration. A development-mode warning lists exactly what was displaced.

The shared groups worth knowing:

Registering a handler for Also takes over
fill backgroundColor, image, backgroundImage, backgroundPosition, backgroundSize, backgroundRepeat, backgroundAttachment, backgroundOrigin, backgroundClip, background
display hide, whiteSpace, textOverflow, flow, gap
flow gap
preset font, fontFamily, fontSize, fontWeight, fontStyle, lineHeight, letterSpacing, textTransform
padding / margin their Top/Right/Bottom/Left longhands (paddingTop, marginLeft, …)
inset top, right, bottom, left
width / height minWidth/maxWidth, minHeight/maxHeight

A multi-dep handler takes over every group its dependencies touch: defineHandler(['display', 'flow', 'gap'], …) takes over display, flow, and gap together, so it must emit display and flex-flow itself.

Either declare the whole group and delegate the rest to styleHandlers.*, or pick a name that isn't shared.

Chunk membership

Tasty renders and caches CSS in independent chunks, and a chunk's cache key covers only its own style values. All of a handler's dependencies must therefore live in one chunk. Custom style names are pulled into their handler's chunk automatically at registration; a handler whose dependencies span two built-in chunks (say fill and padding) warns, because it would be invoked once per chunk with a subset of its inputs.

configure({ handlers }) must run before the first render, like every other config option.


Props Middleware

propHandlers are middleware over a component's props — props in, props out. Where a style handler turns a style property into CSS declarations, a prop handler turns a component prop into other props, including styles. It is the extension point for props whose value isn't a style value.

import { configure, mergeStyles } from '@tenphi/tasty';

configure({
  propHandlers: {
    glaze: (props) => {
      const { glaze, ...rest } = props;
      if (!glaze) return rest;

      return { ...rest, styles: mergeStyles(glazeStyles(glaze), rest.styles) };
    },
  },
});

<Element glaze="purple" />;

The map key is the handler's name and, by default, the prop that triggers it, so an absent prop costs one property check rather than a call. A tuple overrides that: ['glaze', fn], [['glaze', 'tint'], fn], or ['*', fn] for unconditional.

Handlers run at the very top of every component's render, before any prop is destructured, so one can rewrite styles, mods, tokens, variant, as, element, and qa. They run in registration order — plugins first, then direct config — each receiving the previous one's output.

Handlers must be pure and must not mutate their input, and should memoize the styles they build per input value: style values are cached by object identity, so mutating one in place yields stale CSS, and a reference-stable object avoids re-serializing on every render.

Injected styles occupy the styles slot, so they beat a component's own default styles and lose to a style prop at the call site. Prop handlers do not apply to sub-elements or to build-time tastyStatic(), which has no props.

See Plugins → Props middleware for the full contract and a worked example.


Base Style Props

baseStyleProps exposes style properties as props on every tasty() component, on top of the built-in base styles (display, font, preset, hide, whiteSpace, opacity, transition):

configure({ baseStyleProps: ['radius', 'shadow'] });

<Card radius="1r" shadow />;

configure() may run after your components are defined — each factory resolves its prop list lazily.

Each name costs one property check per render of every component, and the effect is app-global, so keep the list short and use a factory's own styleProps for anything narrower. Avoid names that collide with real DOM or component props (width, size, color), since every component will then swallow them as styles.


TypeScript

Component props are typed from the tasty() definition with no setup. Module augmentation adds the names your design system defines and the extensions your plugins add.

Component props

In tasty({ … }) Adds the props
styleProps: ['gap', 'padding'] as const gap, padding, each typed like the style: a value or a state map
modProps: { isLoading: Boolean, size: ['sm', 'md', 'lg'] as const } isLoading?: boolean, size?: 'sm' | 'md' | 'lg' (String gives string, Number gives number)
modProps: ['isLoading'] as const isLoading?: ModValue
tokenProps: ['progress', 'accentColor'] as const progress?: TokenValue, accentColor?: TokenValue
variants: { default: {…}, danger: {…} } variant?: 'default' | 'danger'
elements: { Title: 'h3' } Card.Title, with the h3 props plus qa, qaVal, mods, tokens, isDisabled, isHidden, isChecked, and a ref typed as HTMLHeadingElement; no styles
as: SomeComponent SomeComponent's own props, required ones still required

Write the array forms with as const. Without it, modProps: ['isLoading'] widens to string[] and every prop on the component is typed ModValue, so an onClick handler no longer type-checks. A modProps value list without as const widens to string.

tasty(Base, { … }) keeps Base's prop types and sub-elements, and adds the props and sub-elements its own options above declare. Its variant accepts both its own variant names and Base's. See Extend an existing component.

Registering your names and extensions

// tasty.d.ts — keep the import: it makes this file a module, so the block
// below extends '@tenphi/tasty' instead of replacing it.
import '@tenphi/tasty';

declare module '@tenphi/tasty' {
  interface TastyNamedColors {
    surface: true;
    text: true;
    primary: true;
  }
  interface TastyPresetNames {
    h1: true;
    t2: true;
  }
  interface TastyThemeNames {
    danger: true;
    success: true;
  }
  interface StylesInterface {
    stripe?: string;
  }
  interface TastyCustomProps {
    glaze: 'soft' | 'strong' | { tone: string; intensity?: number };
  }
  interface TastyBaseStylePropNames {
    radius: true;
    stripe: true;
  }
}
Interface Register What it types
TastyNamedColors Color token names from tokens and replaceTokens, without the # Suggestions for #name and #name.50 in fill, color, and svgFill
TastyPresetNames presets names The preset style and prop
TastyThemeNames Theme names The theme prop (rendered as data-theme)
StylesInterface Style properties added with handlers Their value type in styles, styleProps, and base style props
TastyCustomProps Props added with propHandlers An optional prop on every tasty() component (not on sub-elements)
TastyBaseStylePropNames Names passed to baseStyleProps A prop on every component, typed like the style it names
  • The first three add autocomplete, not checking: any other string still type-checks (preset: 'h9' compiles). The ESLint plugin is what reports unknown names.
  • In StylesInterface, declare the plain value type (stripe?: string). Tasty adds state-map support itself, as it does for built-in styles.
  • A custom style name in styles type-checks without augmentation, but styleProps and TastyBaseStylePropNames accept only names in StylesInterface. A base style prop name missing from it adds no prop.
  • TastyCustomProps types the component prop, not the handler. A prop handler receives Record<string, unknown>, so cast inside it (rest.styles as Styles).
  • State keys, $ value tokens, units, and recipe names have no TypeScript types. The ESLint plugin checks them against tasty.config.ts.

tasty.config.ts

TypeScript augmentation does not reach the linter or the editor. @tenphi/eslint-plugin-tasty and the Tasty VS Code extension read a tasty.config.ts (or .js, .json) at the project root instead. It only lists names: it is not passed to configure(), and it is unrelated to the Babel plugin's configFile (such as tasty-zero.config.ts).

// tasty.config.ts
import type { TastyExtensionConfig } from '@tenphi/tasty';
import type { TastyValidationConfig } from '@tenphi/eslint-plugin-tasty';

export default {
  tokens: ['#surface', '#text', '#primary', '$sidebar-width'],
  units: ['cols'],
  states: ['@mobile', '@dark'],
  presets: ['h1', 't2'],
  recipes: ['card', 'elevated'],
  styles: ['stripe'],
  importSources: ['@my-org/ui'],
  presetDescriptions: { h1: 'Heading 1 (32px, bold)' },
} satisfies TastyExtensionConfig & TastyValidationConfig;

TastyExtensionConfig from @tenphi/tasty types the fields the VS Code extension reads: extends, tokens, units, functions, states, presets, styles, presetDescriptions, and stateDescriptions. It has no recipes, importSources, ownedSources, or styleFunctions, so annotating the file with it alone rejects those ESLint fields; intersect it with the plugin's TastyValidationConfig, as above. An omitted list is not checked; tokens: false, units: false, or functions: false turns a check off even when an extends parent sets it.

Keep it in step with configure(): token names with their # or $, custom units, parse-function names, state aliases, presets, recipes, and custom style names.

See Style DSL for state maps, tokens, units, and extending semantics, and React API for tasty(), style functions, and component props.