Vite CSS Modules and Preprocessors: SCSS, Less, PostCSS

The most surprising thing about CSS Modules and preprocessors in Vite is how seamlessly they integrate, making complex styling manageable without sacrificing performance.

Let’s see this in action. Imagine a React component that uses SCSS and CSS Modules for styling.

src/components/Button/Button.jsx

import React from 'react';
import styles from './Button.module.scss'; // Importing SCSS module

function Button({ children, onClick, type = 'button' }) {
  const buttonClass = `${styles.button} ${styles[type] || ''}`; // Applying module classes

  return (
    <button className={buttonClass} onClick={onClick}>
      {children}
    </button>
  );
}

export default Button;

src/components/Button/Button.module.scss

/* Base button styles */
.button {
  display: inline-block;
  padding: 10px 20px;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 16px;
  transition: background-color 0.3s ease;
}

/* Modifier classes for different button types */
.primary {
  background-color: #007bff;
  color: white;

  &:hover {
    background-color: #0056b3;
  }
}

.secondary {
  background-color: #6c757d;
  color: white;

  &:hover {
    background-color: #545b62;
  }
}

.danger {
  background-color: #dc3545;
  color: white;

  &:hover {
    background-color: #a71d2a;
  }
}

When Vite builds this, it first processes Button.module.scss using the SCSS preprocessor. It then applies CSS Modules, generating unique, scoped class names for each selector. For example, .button might become Button_button__aBcDeF and .primary might become Button_primary__gHiJkL. The styles object in Button.jsx will map these original class names to their generated, unique versions.

src/App.jsx

import React from 'react';
import Button from './components/Button/Button';
import './App.css'; // Global styles

function App() {
  return (
    <div className="App">
      <h1>Vite Styling Demo</h1>
      <Button type="primary">Primary Action</Button>
      <Button type="secondary">Secondary Action</Button>
      <Button type="danger">Delete Item</Button>
    </div>
  );
}

export default App;

src/App.css

.App {
  font-family: sans-serif;
  text-align: center;
  padding: 20px;
}

h1 {
  color: #333;
}

Vite’s magic lies in its development server. When you import a .scss or .less file, Vite uses esbuild (or a dedicated plugin if needed) to transform it into standard CSS on-the-fly. For CSS Modules, it automatically applies the scoping mechanism. This means you get the benefits of preprocessors like nesting, variables, and mixins, plus the isolation of CSS Modules, all without manual configuration for common setups.

The core problem this solves is the global nature of CSS. Without Modules, styles can clash easily, especially in large applications or when using third-party libraries. Preprocessors help manage complexity by allowing for more organized and maintainable CSS, but they don’t inherently solve the naming collision issue. CSS Modules, by generating unique class names, provide local scope for styles. Vite brings these two worlds together effortlessly.

To use SCSS, you’ll typically install it: npm install -D sass. For Less, it’s npm install -D less. Vite detects these dependencies and automatically configures the necessary transformations. PostCSS is also built-in, allowing you to use plugins like Autoprefixer for vendor prefixes or Tailwind CSS for utility-first styling.

The vite.config.js file can be used for more advanced configurations, but for basic usage, Vite is often zero-config. For instance, if you wanted to customize PostCSS plugins, you might have a postcss.config.js file or configure it within vite.config.js:

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

export default defineConfig({
  plugins: [react()],
  css: {
    postcss: {
      plugins: [
        autoprefixer({}) // Example: adding autoprefixer
      ]
    }
  }
});

The css.modules configuration in vite.config.js allows fine-grained control over how CSS Modules are handled, including the naming convention of the generated class names. The default is [name]__[local]___[hash:base64:5], which is often sufficient. You can customize this to something like [hash:base64:5]__[local] if you prefer the hash to come first, or [name]__[local] for simpler, though less robust, names.

When working with CSS Modules, the styles object you import is a JavaScript object where keys are your original class names (e.g., button, primary) and values are the generated, unique CSS class names. This allows you to dynamically apply classes in your JSX, like className={${styles.button} ${styles.primary}``, ensuring that only the intended styles are applied to an element. This mechanism is crucial for component-based architectures, as it prevents styles from leaking out of a component and affecting others.

A common misconception is that CSS Modules replace preprocessors. They don’t; they complement them. You can write SCSS or Less within a CSS Modules file (e.g., Button.module.scss), and Vite will process both the preprocessor syntax and the module scoping. This means you get the full power of SASS/LESS features like variables, mixins, and functions, all while ensuring your styles are locally scoped to the component they belong to.

The next thing you’ll likely explore is how to handle global styles, custom CSS variables across modules, or integrating more complex PostCSS setups for advanced transformations.

Want structured learning?

Take the full Vite course →