How CSS Modules Integration Works in Dioxus: A Deep Dive into the Manganis Asset System

Dioxus implements CSS Modules through the Manganis asset system, using the #[css_module] attribute macro to generate type-safe class constants and the CLI to hash and transform selectors at build time.

CSS Modules provide scoped styling that eliminates class name collisions in component-based applications. In the Dioxus ecosystem, this integration relies on a sophisticated pipeline that transforms your stylesheets at compile time and injects them at runtime. The implementation spans the Manganis macro system, the Dioxus CLI asset pipeline, and generated runtime code that ensures stylesheets load exactly once.

The Three-Layer Architecture of CSS Modules in Dioxus

The CSS Modules integration operates across three distinct layers: the attribute macro that parses your Rust code, the CLI that processes the actual CSS files, and the runtime linking mechanism that connects everything in the browser.

The #[css_module] Attribute Macro

Located in packages/manganis/manganis-macro/src/css_module.rs, the #[css_module] attribute macro serves as the entry point for developers. When you apply this macro to a struct definition, it parses the CSS file path and optional AssetOptions from the attribute arguments.

The macro generates a unit-struct expansion containing a hidden module. This module includes:

  • A OnceLock guard that manages the <link> element injection
  • Constants representing each transformed class name from your CSS file
  • The __CssIdent wrapper type that implements Deref<Target=str> and IntoAttributeValue

Build-Time Asset Processing

The heavy lifting occurs in packages/cli/src/opt/css.rs during the build process. The CLI invokes process_css_module and transform_css to handle the physical CSS transformation.

This pipeline executes several critical operations:

  1. Content hashing: Generates a unique hash for the module using create_module_hash
  2. Selector transformation: Rewrites every class selector to a unique hash-based name via get_class_mappings
  3. Minification: Optionally minifies the output when configured through AssetOptions
  4. File generation: Writes the transformed CSS to the build output directory with the hashed filename

The result is a stylesheet where .container becomes something like .container-a3f7b2, ensuring complete isolation between components.

Runtime Stylesheet Injection and Class Resolution

At runtime, the macro-generated code in the hidden module handles lazy loading. The implementation uses a std::sync::OnceLock to ensure the stylesheet <link> element is injected exactly once per module, using document().create_link(...) to append it to the DOM.

Each class constant is a __CssIdent wrapper that implements Deref. When you use Styles::container in your RSX, the deref implementation returns the hashed class name string and triggers the stylesheet injection on first access. This approach guarantees that styles are available when needed without manual link management.

How the #[css_module] Macro Expands

Understanding the macro expansion clarifies how the type-safe API works. When you write:

#[css_module("/styles/app.css")]
struct Styles;

The macro expands this into a unit struct with an accompanying hidden module. The hidden module contains:

  • An internal LINK static using OnceLock to manage the DOM element
  • Constants for each CSS class (e.g., pub const container: __CssIdent = ...)
  • The __CssIdent struct definition implementing Deref<Target = str> and IntoAttributeValue

The __CssIdent type is crucial. It wraps the hashed class name string and implements Deref to return that string. When used as a class attribute in RSX, it automatically converts into the appropriate attribute value while ensuring the stylesheet is loaded via the OnceLock initialization logic found in lines 18-35 of css_module.rs.

Build-Time CSS Transformation Pipeline

The Dioxus CLI handles CSS Modules differently than standard CSS files. In packages/cli/src/opt/css.rs, the process_css_module function orchestrates the transformation.

The pipeline follows this sequence:

  1. Parse: Reads the source CSS file content
  2. Hash: Generates a content hash using create_module_hash to create a unique module identifier
  3. Map: Creates class-to-hash mappings using get_class_mappings, transforming selectors like .button into .button-4d2e1a
  4. Transform: Rewrites the entire CSS AST with the new hashed selectors
  5. Optimize: Applies minification if AssetOptions specifies with_minify(true)
  6. Emit: Writes the final CSS to the output directory with the hash in the filename

This transformation ensures that the constants generated by the macro match the actual class names in the deployed stylesheet.

Practical Usage with AssetOptions

The CSS Modules API supports configuration through the AssetOptions builder pattern. Here is a complete example demonstrating both basic usage and advanced configuration:

use dioxus::prelude::*;

fn app() -> Element {
    // Basic usage - simple file path
    #[css_module("/examples/assets/css_module1.css")]
    struct Styles;
    
    // Advanced configuration with AssetOptions
    #[css_module(
        "/examples/assets/css_module2.css",
        AssetOptions::css_module()
            .with_minify(true)    // Enable minification
            .with_preload(false)  // Disable preloading
    )]
    struct OtherStyles;
    
    rsx! {
        div { 
            class: Styles::container,
            div { class: OtherStyles::test, "Hello, world!" }
            div { class: OtherStyles::highlight, "Highlighted content" }
            // Global selectors bypass hashing
            div { class: Styles::global_class, "Global styling" }
        }
    }
}

Global selectors defined with the :global() wrapper (e.g., :global(.global_class)) are emitted unchanged in the transformed CSS. This allows you to reference global utility classes or third-party framework styles without the hashing transformation.

Summary

  • Dioxus CSS Modules rely on the Manganis asset system, combining compile-time transformation with runtime injection.
  • The #[css_module] macro in manganis-macro/src/css_module.rs generates type-safe constants and manages stylesheet linking via OnceLock.
  • The CLI processing pipeline in packages/cli/src/opt/css.rs handles hashing (create_module_hash), class mapping (get_class_mappings), and optional minification.
  • The __CssIdent wrapper implements Deref<Target=str> to provide seamless string conversion while triggering lazy stylesheet injection.
  • Global selectors using :global() syntax bypass the hashing mechanism for framework-wide styles.

Frequently Asked Questions

How do I configure minification for CSS Modules in Dioxus?

Pass AssetOptions::css_module().with_minify(true) as the second argument to the #[css_module] attribute. This option is processed by the CLI in packages/cli/src/opt/css.rs during the transform_css phase, reducing the final stylesheet size before deployment.

What is the __CssIdent wrapper and why is it used?

__CssIdent is a generated struct that wraps hashed class name strings. It implements Deref<Target=str> to allow transparent use as string slices and IntoAttributeValue for RSX compatibility. According to the macro implementation in css_module.rs, this wrapper ensures the stylesheet <link> is injected via OnceLock the first time any class from that module is dereferenced.

Can I use global CSS selectors with Dioxus CSS Modules?

Yes. Wrap selectors in :global(...) syntax within your CSS files. The parser in manganis-core/src/css_module_parser.rs recognizes these patterns and emits them without the hash suffix, allowing you to reference global utility classes or external framework styles while keeping component-specific classes scoped.

How does Dioxus prevent class name collisions across components?

The CLI generates unique hashes for each CSS module using create_module_hash based on file content. Every class selector is rewritten with this hash via get_class_mappings, ensuring that .button in nav.css and .button in card.css become distinct class names (e.g., .button-a1b2c3 and .button-x9y8z7) in the final build output.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →