Syed Umar AnisJavascriptTree-Shaking CSS in a Vite project
Syed Umar AnisJavascriptTree-Shaking CSS in a Vite project
JavascriptWeb

Tree-Shaking CSS in a Vite project

Optimizing code delivery is crucial when building modern web applications to ensure faster load times and better performance. One common optimization technique is tree-shaking, where unused code is eliminated from the final bundle. It is easier to achieve for code than for CSS styles. In this blog post, we’ll explore how tree-shaking of CSS works in a Vite using an example with two projects:

1. Library Project – A library containing TypeScript files, each importing its own CSS styles.

2. Demo Project – A project that consumes the library and selectively imports TypeScript modules, expecting only the necessary styles to be included.

We’ll analyze how CSS imports behave in both development and production builds and discuss the similarity with Svelte’s global styles.

Setting Up the Library Project

In our library project, we have five CSS files, each corresponding to a TypeScript file that provides some functionality. The structure looks like this:

/library
  ├── src/
  │   ├── featureA.ts   (imports stylesA.css)
  │   ├── featureB.ts   (imports stylesB.css)
  │   ├── featureC.ts   (imports stylesC.css)
  │   ├── featureD.ts   (imports stylesD.css)
  │   ├── featureE.ts   (imports stylesE.css)
  │   ├── index.ts      (exports all feature files)
  ├── package.json
  ├── tsconfig.json

Each feature file imports its own CSS file:

// featureA.ts
import "./stylesA.css";

export function featureA() {
  console.log("Feature A");
}
// featureB.ts
import "./stylesB.css";

export function featureB() {
  console.log("Feature B");
}

To make these available in the library, we export everything from index.ts:

// index.ts
export * from "./featureA";
export * from "./featureB";
export * from "./featureC";
export * from "./featureD";
export * from "./featureE";

The Demo Project: Importing Features

Now, in our demo project, we selectively import features:

import { featureA } from "my-library";
featureA();

Since featureA.ts imports stylesA.css, we expect only that style to be included in the final bundle. However, there’s a key difference between development and production behaviour.

Discrepancy Between Development and Production Builds

Development Behavior

In development mode (using tools like Vite), the entire index.ts file is evaluated. Since it references all feature files, all CSS imports get applied, even if only one feature is actually used.

• ✅ Only featureA is used.

• ❌ But all styles (stylesA.css, stylesB.css, etc.) are loaded.

Production Behavior

During a production build, tree-shaking kicks in. The bundler analyzes what’s actually used and removes unused code.

• ✅ Only featureA is used.

• ✅ Only stylesA.css is included in the final bundle.

This means in development mode, all styles are loaded, but in production, only necessary styles are included.

Fixing the Development Discrepancy

To ensure the development mode behaves more like production, avoid exporting everything from index.ts. Instead, structure the library like this:

// library/package.json
{
  "exports": {
    "./featureA": "./src/featureA.ts",
    "./featureB": "./src/featureB.ts",
    "./featureC": "./src/featureC.ts",
    "./featureD": "./src/featureD.ts",
    "./featureE": "./src/featureE.ts"
  }
}

Then, in the demo project, import only what’s needed:

import { featureA } from "my-library/featureA";
featureA();

Now, only the necessary styles are imported in both development and production.

Comparison with Svelte’s Global Styles

Interestingly, Svelte component with global styles behaves in the same way as a TypeScript file importing CSS.

• If a Svelte component has global styles and is exported via index.ts, those styles are applied in development even if the component is not used.

• However, in production, tree-shaking removes unused global styles.

• If a Svelte component only contains global styles and has no other functionality, it gets tree-shaken completely in production.

For example:

<!-- GlobalStyles.svelte -->
<style>
  :global(body) {
    background: blue;
  }
</style>

If GlobalStyles.svelte is exported in index.ts, the global styles are applied in development but removed in production even if explicitly imported.

Conclusion

By structuring our library exports correctly, we can ensure that both development and production behave consistently. This approach allows styles to be tree-shaken effectively, reducing unnecessary CSS bloat.

Would love to hear your thoughts! Have you faced similar issues with CSS tree-shaking? Let me know in the comments.

Hi, I’m Umar

Leave a Reply

Your email address will not be published. Required fields are marked *