Skip to content

CSS Support

CSS support in tsdown is still an experimental feature. While it covers the core use cases, the API and behavior may change in future releases.

Experimental Feature

CSS support is experimental. Please test thoroughly and report any issues you encounter. The API and behavior may change as the feature matures.

Getting Started

All CSS support in tsdown is provided by the @tsdown/css package. Install it to enable CSS handling:

bash
npm install -D @tsdown/css

When @tsdown/css is installed, CSS processing is automatically enabled.

CSS Import

Importing .css files from your TypeScript or JavaScript entry points is supported. The CSS content is extracted and emitted as a separate .css asset file:

ts
// src/index.ts
import './style.css'

export function greet() {
  return 'Hello'
}

This produces both index.mjs and index.css in the output directory.

@import Inlining

CSS @import statements are automatically resolved and inlined into the output. This means you can use @import to organize your CSS across multiple files without producing separate output files:

css
/* style.css */
@import './reset.css';
@import './theme.css';

.main {
  color: red;
}

All imported CSS is bundled into a single output file with @import statements removed.

Inline CSS (?inline)

Appending ?inline to a CSS import returns the fully processed CSS as a JavaScript string instead of emitting a separate .css file. This aligns with Vite's ?inline behavior:

ts
import css from './theme.css?inline' // Returns processed CSS as a string
import './style.css' // Extracted to a .css file
console.log(css) // ".theme { color: red; }\n"

The ?inline CSS goes through the full processing pipeline — preprocessors, @import inlining, syntax lowering, and minification — just like regular CSS. The only difference is the output format: a JavaScript string export instead of a CSS asset file.

This also works with preprocessors:

ts
import css from './theme.scss?inline'

When ?inline is used, the CSS is not included in the emitted .css files and the import is tree-shakeable (moduleSideEffects: false).

CSS Pre-processors

tsdown provides built-in support for .scss, .sass, .less, .styl, and .stylus files. The corresponding pre-processor must be installed as a dev dependency:

sh
# Either sass-embedded (recommended, faster) or sass
npm install -D sass-embedded
# or
npm install -D sass
sh
npm install -D less
sh
npm install -D stylus

Once installed, you can import preprocessor files directly:

ts
import './style.scss'
import './theme.less'
import './global.styl'

Preprocessor Options

You can pass options to each preprocessor via css.preprocessorOptions:

ts
export default defineConfig({
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `$brand-color: #ff7e17;`,
      },
      less: {
        math: 'always',
      },
    },
  },
})

additionalData

Each preprocessor supports an additionalData option to inject extra code at the beginning of every processed file. This is useful for global variables or mixins:

ts
export default defineConfig({
  css: {
    preprocessorOptions: {
      scss: {
        // String — prepended to every .scss file
        additionalData: `@use "src/styles/variables" as *;`,
      },
    },
  },
})

You can also use a function for dynamic injection:

ts
export default defineConfig({
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: (source, filename) => {
          if (filename.includes('theme')) return source
          return `@use "src/styles/variables" as *;\n${source}`
        },
      },
    },
  },
})

CSS Minification

Enable CSS minification via css.minify:

ts
export default defineConfig({
  css: {
    minify: true,
  },
})

Minification is powered by Lightning CSS.

CSS Target

By default, CSS syntax lowering uses the top-level target option. You can override this specifically for CSS with css.target:

ts
export default defineConfig({
  target: 'node18',
  css: {
    target: 'chrome90', // CSS-specific target
  },
})

Set css.target: false to disable CSS syntax lowering entirely, even when a top-level target is set:

ts
export default defineConfig({
  target: 'chrome90',
  css: {
    target: false, // Preserve modern CSS syntax
  },
})

CSS Transformer

The css.transformer option controls how CSS is processed. PostCSS and Lightning CSS are mutually exclusive processing paths:

  • 'lightningcss' (default): @import is resolved by Lightning CSS's bundleAsync(), and PostCSS is not used at all.
  • 'postcss': @import is resolved by postcss-import, PostCSS plugins are applied, then Lightning CSS is used only for final syntax lowering and minification.
ts
export default defineConfig({
  css: {
    transformer: 'postcss', // Use PostCSS for @import and plugins
  },
})

When using the 'postcss' transformer, install postcss and optionally postcss-import for @import resolution:

bash
npm install -D postcss postcss-import

PostCSS Options

Configure PostCSS inline or point to a config file:

ts
export default defineConfig({
  css: {
    transformer: 'postcss',
    postcss: {
      plugins: [require('autoprefixer')],
    },
  },
})

Or specify a directory path to search for a PostCSS config file (postcss.config.js, etc.):

ts
export default defineConfig({
  css: {
    transformer: 'postcss',
    postcss: './config', // Search for postcss.config.js in ./config/
  },
})

When css.postcss is omitted and transformer is 'postcss', tsdown auto-detects PostCSS config from the project root.

Lightning CSS

tsdown uses Lightning CSS for CSS syntax lowering — transforming modern CSS features into syntax compatible with older browsers based on your target setting.

To enable CSS syntax lowering, install lightningcss:

sh
npm install -D lightningcss
sh
pnpm add -D lightningcss
sh
yarn add -D lightningcss
sh
bun add -D lightningcss

Once installed, CSS lowering is enabled automatically when a target is set. For example, with target: 'chrome108', CSS nesting & selectors will be flattened:

css
/* Input */
.foo {
  & .bar {
    color: red;
  }
}

/* Output (chrome108) */
.foo .bar {
  color: red;
}

Lightning CSS Options

You can pass additional options to Lightning CSS via css.lightningcss:

ts
import { Features } from 'lightningcss'

export default defineConfig({
  css: {
    lightningcss: {
      // Override browser targets directly (instead of using `target`)
      targets: { chrome: 100 << 16 },
      // Include/exclude specific features
      include: Features.Nesting,
    },
  },
})

TIP

When css.lightningcss.targets is set, it takes precedence over both the top-level target and css.target options for CSS transformations.

For more information on available options, refer to the Lightning CSS documentation.

Preserving CSS Imports (css.inject)

By default, CSS import statements are removed from JS output after extracting the CSS into separate files. When css.inject is enabled, the JS output preserves import statements pointing to the emitted CSS files, so consumers of your library will automatically import the CSS alongside the JS:

ts
export default defineConfig({
  css: {
    inject: true,
  },
})

With css.inject: true, the output JS will contain:

js
// dist/index.mjs
import './style.css'

export function greet() {
  return 'Hello'
}

This is useful for component libraries where you want CSS to be automatically included when users import your components.

CSS Modules

Files with the .module.css extension (and preprocessor variants like .module.scss, .module.less, etc.) are treated as CSS modules. Class names are automatically scoped and exported as a JavaScript object:

ts
// src/index.ts
import styles from './app.module.css'

console.log(styles.title) // "scoped_title_hash"
css
/* app.module.css */
.title {
  color: red;
}
.content {
  font-size: 14px;
}

The CSS is emitted with scoped class names, and the JS output exports the mapping from original to scoped names.

Configuration

Configure CSS modules behavior via css.modules:

ts
export default defineConfig({
  css: {
    modules: {
      // Scoping behavior: 'local' (default) or 'global'
      scopeBehaviour: 'local',

      // Pattern for scoped class names (Lightning CSS pattern syntax)
      generateScopedName: '[hash]_[local]',

      // Transform class name convention in JS exports
      localsConvention: 'camelCase',
    },
  },
})

Set css.modules: false to disable CSS modules entirely — .module.css files will be treated as regular CSS.

localsConvention

Controls how class names are exported in JavaScript:

ValueInputExports
(not set)foo-barfoo-bar
'camelCase'foo-barfoo-bar, fooBar
'camelCaseOnly'foo-barfooBar
'dashes'foo-barfoo-bar, fooBar
'dashesOnly'foo-barfooBar

generateScopedName

When using transformer: 'lightningcss' (default), this accepts a Lightning CSS pattern string (e.g., '[hash]_[local]').

When using transformer: 'postcss', this also accepts a function:

ts
export default defineConfig({
  css: {
    transformer: 'postcss',
    modules: {
      generateScopedName: (name, filename, css) => {
        return `my-lib_${name}`
      },
    },
  },
})

NOTE

Function-form generateScopedName is only supported with transformer: 'postcss'. The Lightning CSS transformer only supports string patterns.

Optional Dependencies

When using transformer: 'postcss' with CSS modules, install postcss-modules:

bash
npm install -D postcss postcss-modules

CSS Code Splitting

Merged Mode (Default)

By default, all CSS is merged into a single file (default: style.css):

dist/
  index.mjs
  style.css  ← all CSS merged

Custom File Name

You can customize the merged CSS file name:

ts
export default defineConfig({
  css: {
    fileName: 'my-library.css',
  },
})

Splitting Mode

To split CSS per chunk — so each JavaScript chunk that imports CSS has a corresponding .css file — enable splitting:

ts
export default defineConfig({
  css: {
    splitting: true,
  },
})
dist/
  index.mjs
  index.css        ← CSS from index.ts
  async-abc123.mjs
  async-abc123.css ← CSS from async chunk

PostCSS Optional Peer Dependencies

When using transformer: 'postcss', the following packages may need to be installed depending on the features you use:

PackagePurposeRequired When
postcssCore PostCSS engineAlways (with transformer: 'postcss')
postcss-importResolve and inline @import statementsCSS files use @import
postcss-modulesCSS modules support (scoped class names)Using .module.css files
bash
npm install -D postcss postcss-import postcss-modules

All three are declared as optional peer dependencies of @tsdown/css and only loaded when needed.

Options Reference

OptionTypeDefaultDescription
css.transformer'postcss' | 'lightningcss''lightningcss'CSS processing pipeline
css.splittingbooleanfalseEnable CSS code splitting per chunk
css.fileNamestring'style.css'File name for the merged CSS file (when splitting: false)
css.minifybooleanfalseEnable CSS minification
css.modulesobject | false{}CSS modules configuration, or false to disable
css.targetstring | string[] | falsefrom targetCSS-specific syntax lowering target
css.postcssstring | objectPostCSS config path or inline options
css.preprocessorOptionsobjectOptions for CSS preprocessors
css.injectbooleanfalsePreserve CSS import statements in JS output
css.lightningcssobjectOptions passed to Lightning CSS for syntax lowering

Released under the MIT License.