Tasty logoTasty
Playground

Zero Runtime

tastyStatic is a build-time utility for generating CSS with zero runtime overhead. It's designed for static sites, no-JS websites, and performance-critical applications where you want to eliminate all runtime styling code. For the broader docs map, see the Docs Hub.


When to Use

Requirements

The zero-runtime mode is part of the main @tenphi/tasty package but requires additional peer dependencies depending on your setup:

DependencyVersionRequired for
@babel/core>= 7.24Babel plugin (@tenphi/tasty/babel-plugin)
@babel/helper-plugin-utils>= 7.24Babel plugin
@babel/types>= 7.24Babel plugin
jiti>= 2.6Next.js wrapper (@tenphi/tasty/next) when using configFile option

All of these are declared as optional peer dependencies of @tenphi/tasty. Install only what your setup requires:

# For any Babel-based setup (Vite, custom Babel config, etc.)
pnpm add -D @babel/core @babel/helper-plugin-utils @babel/types

# For Next.js with TypeScript config file
pnpm add -D @babel/core @babel/helper-plugin-utils @babel/types jiti

Quick Start

Basic Usage

import { tastyStatic } from '@tenphi/tasty/static';

// Define styles - returns StaticStyle object
const button = tastyStatic({
  display: 'inline-flex',
  padding: '2x 4x',
  fill: '#purple',
  color: '#white',
  radius: '1r',
});

// Use in JSX - works via toString() coercion
<button className={button}>Click me</button>

// Or access className explicitly
<button className={button.className}>Click me</button>

API Reference

tastyStatic(styles)

Creates a StaticStyle object from a styles definition.

const card = tastyStatic({
  padding: '4x',
  fill: '#white',
  border: true,
  radius: true,
});

tastyStatic(base, styles)

Extends an existing StaticStyle with additional styles. Uses mergeStyles internally for proper nested selector handling.

const button = tastyStatic({
  padding: '2x 4x',
  fill: '#blue',
  Icon: { color: '#white' },
});

const primaryButton = tastyStatic(button, {
  fill: '#purple',
  Icon: { opacity: 0.8 },
});

tastyStatic(selector, styles)

Generates global styles for a CSS selector. The call is removed from the bundle after transformation.

tastyStatic('body', {
  fill: '#surface',
  color: '#text',
  preset: 't3',
});

StaticStyle Object

PropertyTypeDescription
classNamestringSpace-separated class names for use in JSX
stylesStylesThe original (or merged) styles object
toString()() => stringReturns className for string coercion

Babel Plugin Configuration

Basic Configuration

// babel.config.js
module.exports = {
  plugins: [
    ['@tenphi/tasty/babel-plugin', {
      output: 'public/tasty.css',
    }]
  ]
};

These examples use data-schema="dark" as the root-state convention. If your app already uses a different root attribute such as data-theme, keep the same alias pattern and swap the attribute name consistently in your zero-runtime config.

With Configuration

module.exports = {
  plugins: [
    ['@tenphi/tasty/babel-plugin', {
      output: 'public/tasty.css',
      config: {
        states: {
          '@mobile': '@media(w < 768px)',
          '@tablet': '@media(w < 1024px)',
          '@dark': '@root(schema=dark)',
        },
        devMode: true,
      },
    }]
  ]
};

Plugin Options

OptionTypeDefaultDescription
outputstring'tasty.css'Path for generated CSS file
mode'file' | 'inject''file''file' writes CSS to disk; 'inject' embeds CSS inline in JS (see Inject Mode)
configFilestringAbsolute path to a TS/JS module that default-exports a TastyZeroConfig object. JSON-serializable alternative to config — required for Turbopack.
configTastyZeroConfig | () => TastyZeroConfig{}Inline config object or factory function. Takes precedence over configFile.
configDepsstring[][]Absolute file paths that affect config (for cache invalidation)
config.statesRecord<string, string>{}Predefined state aliases
config.devModebooleanfalseAdd source comments to CSS
config.recipesRecord<string, RecipeStyles>{}Predefined style recipes
config.fontFaceRecord<string, FontFaceInput>Global @font-face definitions
config.counterStyleRecord<string, CounterStyleDescriptors>Global @counter-style definitions

Recipes

Recipes work with tastyStatic the same way as with runtime tasty:

// babel.config.js
module.exports = {
  plugins: [
    ['@tenphi/tasty/babel-plugin', {
      output: 'public/tasty.css',
      config: {
        recipes: {
          card: { padding: '4x', fill: '#surface', radius: '1r', border: true },
          elevated: { shadow: '2x 2x 4x #shadow' },
        },
      },
    }]
  ]
};
import { tastyStatic } from '@tenphi/tasty/static';

const card = tastyStatic({
  recipe: 'card elevated',
  color: '#text',
});

<div className={card}>Styled card</div>

Next.js Integration

The withTastyZero wrapper configures both webpack and Turbopack automatically. No --webpack flag is needed — it works with whichever bundler Next.js uses.

// next.config.ts
import { withTastyZero } from '@tenphi/tasty/next';

export default withTastyZero({
  output: 'public/tasty.css',
  configFile: './app/tasty-zero.config.ts',
  configDeps: ['./app/theme.ts'],
})({
  reactStrictMode: true,
});

withTastyZero Options

OptionTypeDefaultDescription
outputstring'public/tasty.css'Output path for CSS relative to project root
mode'file' | 'inject''file''file' writes CSS to disk; 'inject' embeds CSS inline in JS
enabledbooleantrueEnable/disable the plugin
configFilestringPath to a TS/JS module that default-exports TastyZeroConfig. Recommended for Turbopack compatibility.
configTastyZeroConfigInline config object. For static configs that don't change during dev.
configDepsstring[][]Extra files the config depends on (for cache invalidation)

Turbopack Support

Starting with Next.js 16, Turbopack is the default bundler. withTastyZero supports it out of the box by injecting turbopack.rules with babel-loader and JSON-serializable options.

The configFile option is key for Turbopack — it passes a file path (JSON-serializable) instead of a function, and the Babel plugin loads the config internally via jiti.

Requirements: babel-loader must be installed in your project:

pnpm add babel-loader

CSS Injection

withTastyZero automatically injects the generated CSS into your app. Every file that imports from @tenphi/tasty/static gets its import replaced with an import of the output CSS file at build time. No manual CSS import is needed.

The generated CSS file (e.g. public/tasty.css) is created as an empty stub before the first build if it doesn't exist, so there's no chicken-and-egg problem with fresh clones or CI builds. Add it to .gitignore:

public/tasty.css

Vite Integration

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [
          ['@tenphi/tasty/babel-plugin', {
            output: 'public/tasty.css',
            config: {
              states: { '@mobile': '@media(w < 768px)' },
            },
          }],
        ],
      },
    }),
  ],
});

Build Transformation

Before (Source Code)

import { tastyStatic } from '@tenphi/tasty/static';

const button = tastyStatic({
  padding: '2x 4x',
  fill: '#purple',
  color: '#white',
});

tastyStatic('.heading', { preset: 'h1' });

export const Button = () => <button className={button}>Click</button>;

After (Production Build)

const button = {
  className: 'ts3f2a1b ts8c4d2e',
  styles: { padding: '2x 4x', fill: '#purple', color: '#white' },
  toString() { return this.className; }
};

export const Button = () => <button className={button}>Click</button>;

Generated CSS (tasty.css)

/* Generated by @tenphi/tasty/zero - DO NOT EDIT */

.ts3f2a1b.ts3f2a1b {
  padding: 16px 32px;
}

.ts8c4d2e.ts8c4d2e {
  background: #9370db;
  color: #fff;
}

.heading.heading {
  font-size: 2.5rem;
  font-weight: 700;
  line-height: 1.2;
}

Inject Mode

By default the Babel plugin writes CSS to a file (mode: 'file'). Inject mode (mode: 'inject') embeds CSS inline in your JavaScript and injects it at runtime via a tiny injector. No CSS file is produced.

This is ideal for reusable components, extensions, and libraries where consumers shouldn't need to manage an external CSS file.

How It Works

  1. The Babel plugin extracts CSS at build time (same pipeline as file mode).
  2. Instead of writing to a .css file, the CSS is embedded as string literals in the JS output.
  3. The @tenphi/tasty/static import is rewritten to @tenphi/tasty/static/inject.
  4. Each tastyStatic call becomes a self-contained expression that injects its CSS and evaluates to a StaticStyle object.

Configuration

// babel.config.js
module.exports = {
  plugins: [
    ['@tenphi/tasty/babel-plugin', {
      mode: 'inject',
      config: {
        states: { '@mobile': '@media(w < 768px)' },
      },
    }]
  ]
};

With Next.js:

// next.config.ts
import { withTastyZero } from '@tenphi/tasty/next';

export default withTastyZero({
  mode: 'inject',
  configFile: './app/tasty-zero.config.ts',
})({
  reactStrictMode: true,
});

When mode is 'inject', the output and injectImport options are ignored.

Build Transformation (inject mode)

Before:

import { tastyStatic } from '@tenphi/tasty/static';

const button = tastyStatic({
  padding: '2x 4x',
  fill: '#purple',
});

tastyStatic('.heading', { preset: 'h1' });

After:

import { injectCSS as _$i } from '@tenphi/tasty/static/inject';

const button = (_$i("ts3f2a1b ts8c4d2e", ".ts3f2a1b.ts3f2a1b{padding:16px 32px}\n.ts8c4d2e.ts8c4d2e{background:#9370db}"), {
  className: 'ts3f2a1b ts8c4d2e',
  styles: { padding: '2x 4x', fill: '#purple' },
  toString() { return this.className; }
});

_$i(".heading", ".heading{font-size:2.5rem;font-weight:700;line-height:1.2}");

Dev Mode / HMR

Class names are content-hashed (ts + MD5). When styles change, a new hash produces a new _$i call that injects fresh CSS. The injector deduplicates by id, so unchanged styles are skipped. Old CSS stays in the DOM but is harmless since no elements reference those class names.

Limitations (inject mode)


Style Extension

// Base button
const button = tastyStatic({
  display: 'inline-flex',
  padding: '2x 4x',
  radius: '1r',
  fill: '#gray.20',
  color: '#text',
  transition: 'fill 0.15s',
});

// Variants
const primaryButton = tastyStatic(button, {
  fill: '#purple',
  color: '#white',
});

const dangerButton = tastyStatic(button, {
  fill: '#danger',
  color: '#white',
});

State-based Styling

const card = tastyStatic({
  padding: {
    '': '4x',
    '@mobile': '2x',
  },
  display: {
    '': 'flex',
    '@mobile': 'block',
  },
});

Extending Style Types (TypeScript)

If you add custom style properties, use module augmentation so tastyStatic recognizes them too. See Extending Style Types in the configuration docs.


Limitations

  1. Static values only — All style values must be known at build time
  2. No runtime props — Cannot use styleProps or dynamic styles prop
  3. No mods at runtime — Modifiers must be defined statically
  4. Build-time transformation required — Babel plugin must process files

Workarounds

For dynamic styling needs, combine with regular CSS or CSS variables:

const card = tastyStatic({
  padding: '4x',
  fill: 'var(--card-bg, #white)',
});

<div
  className={card}
  style={{ '--card-bg': isActive ? '#purple' : '#white' }}
/>

Best Practices

  1. Define base styles for common patterns, then extend for variants
  2. Use selector mode for global/body styles
  3. Enable devMode in development for easier debugging
  4. Configure states for consistent responsive breakpoints

Common Issues