CSS Without Frameworks

CSS frameworks and preprocessors exist to solve problems that the web platform now handles natively. CSS nesting, container queries, :has(), @layer, and custom properties eliminate the need for Sass, Less, or utility-class frameworks. This section covers writing plain CSS for an HDA application, processing it with the lightningcss crate, and co-locating styles alongside Maud components using the inventory crate.

The result is a single processed stylesheet, built at startup from a base CSS file and component-scoped fragments, minified and vendor-prefixed, served from memory with cache-busting.

Plain CSS in 2026

Native CSS now provides the features that historically required preprocessors:

  • Nesting replaces Sass/Less nesting syntax. Write .card { .title { ... } } directly.
  • Custom properties (--color-primary: #1a1a2e;) replace preprocessor variables, with the advantage of being runtime-configurable and inheritable through the DOM.
  • @layer controls cascade priority without specificity hacks.
  • Container queries let components respond to their container’s size rather than the viewport.
  • :has() selects elements based on their children, replacing many patterns that previously required JavaScript.

The Web Platform Has Caught Up section covers these features in detail. This section focuses on the tooling pipeline: how to write, process, and serve CSS in a Rust HDA application.

CSS organisation with RSCSS

RSCSS (Reasonable System for CSS Stylesheet Structure) provides a lightweight naming convention that works well with component-based architectures. It imposes just enough structure to keep styles maintainable without the ceremony of BEM or the magic of CSS Modules.

The core rules:

  • Components are named with at least two words, separated by dashes: .search-form, .article-card, .user-profile.
  • Elements within a component use a single word: .title, .body, .avatar. Multi-word elements are concatenated: .firstname, .submitbutton. Use the child selector (>) to prevent styles bleeding into nested components.
  • Variants modify a component or element. RSCSS normally prefixes variants with a dash (.search-form.-compact), but dashes at the start of a class name are awkward in Maud templates. Use a double underscore prefix instead: .search-form.__compact. The double underscore distinguishes variants from helpers at a glance.
  • Helpers are global utility classes prefixed with a single underscore: ._hidden, ._center. Keep these minimal.

In practice:

.article-card {
    border: 1px solid var(--border);
    border-radius: 0.5rem;
    padding: 1rem;

    > .title {
        font-size: 1.25rem;
        font-weight: 600;
    }

    > .meta {
        color: var(--text-muted);
        font-size: 0.875rem;
    }

    &.__featured {
        border-color: var(--accent);
    }
}

And the corresponding Maud component:

fn article_card(article: &Article) -> Markup {
    html! {
        div.article-card.__featured[article.featured] {
            h2.title { (article.title) }
            p.meta { "By " (article.author) }
        }
    }
}

The two-word component rule means component classes never collide with single-word element classes. The double-underscore variant prefix is visually distinct from both element classes and helper utilities, and works cleanly in Maud’s class syntax.

lightningcss

lightningcss is a CSS parser, transformer, and minifier written in Rust by the Parcel team. It processes over 2.7 million lines of CSS per second on a single thread. Use it to minify, vendor-prefix, and downlevel modern CSS syntax for older browsers.

Add it to Cargo.toml:

[dependencies]
lightningcss = { version = "1.0.0-alpha.70", default-features = false }

Disable default features to avoid pulling in Node.js binding dependencies. Enable bundler if you need @import resolution, or visitor if you need custom AST transforms.

A function to process a CSS string:

use lightningcss::stylesheet::{StyleSheet, ParserOptions, MinifyOptions};
use lightningcss::printer::PrinterOptions;
use lightningcss::targets::{Targets, Browsers};

pub fn process_css(raw: &str) -> Result<String, String> {
    let targets = Targets::from(Browsers {
        chrome: Some(95 << 16),
        firefox: Some(90 << 16),
        safari: Some(15 << 16),
        ..Browsers::default()
    });

    let mut stylesheet = StyleSheet::parse(raw, ParserOptions {
        filename: "styles.css".to_string(),
        ..ParserOptions::default()
    })
    .map_err(|e| format!("CSS parse error: {e}"))?;

    stylesheet
        .minify(MinifyOptions {
            targets,
            ..MinifyOptions::default()
        })
        .map_err(|e| format!("CSS minify error: {e}"))?;

    let result = stylesheet
        .to_css(PrinterOptions {
            minify: true,
            targets,
            ..PrinterOptions::default()
        })
        .map_err(|e| format!("CSS print error: {e}"))?;

    Ok(result.code)
}

Browser targets are encoded as major << 16 | minor << 8 | patch. Chrome 95 is 95 << 16.

Pass targets to both MinifyOptions and PrinterOptions. The minify step transforms modern syntax (nesting, oklch() colours, logical properties) into forms the target browsers understand. The printer step serialises the result, applying minification when minify: true.

What lightningcss handles automatically:

  • Flattens CSS nesting for older browsers
  • Adds vendor prefixes (-webkit-, -moz-) where targets require them
  • Converts modern colour functions (oklch(), lab(), color-mix()) to rgb()/rgba() fallbacks
  • Transpiles logical properties (margin-inline-start) to physical equivalents
  • Converts media query range syntax (@media (width >= 768px)) to min-width form
  • Merges longhand properties into shorthands
  • Removes redundant vendor prefixes the targets don’t need

Locality of behaviour with inventory

The inventory crate provides a distributed registration pattern: declare values in any module, collect them all in one place at startup. This enables locality of behaviour for CSS, where each component’s styles live in the same file as its markup.

[dependencies]
inventory = "0.3"

Define a CSS fragment type

Create a type to hold a CSS fragment and register it with inventory::collect!:

// src/styles.rs

pub struct CssFragment(pub &'static str);

inventory::collect!(CssFragment);

Co-locate CSS with components

In each component file, declare the CSS alongside the markup using inventory::submit!:

// src/components/article_card.rs

use maud::{html, Markup};
use crate::styles::CssFragment;

inventory::submit! {
    CssFragment(r#"
        .article-card {
            border: 1px solid var(--border);
            border-radius: 0.5rem;
            padding: 1rem;

            > .title {
                font-size: 1.25rem;
                font-weight: 600;
            }

            > .meta {
                color: var(--text-muted);
                font-size: 0.875rem;
            }

            &.__featured {
                border-color: var(--accent);
            }
        }
    "#)
}

pub fn article_card(article: &Article) -> Markup {
    html! {
        div.article-card.__featured[article.featured] {
            h2.title { (article.title) }
            p.meta { "By " (article.author) }
        }
    }
}

Adding a new component with styles requires no changes to any other file. The CSS lives next to the markup that uses it.

Another component

// src/components/nav_bar.rs

use maud::{html, Markup};
use crate::styles::CssFragment;

inventory::submit! {
    CssFragment(r#"
        .nav-bar {
            display: flex;
            align-items: center;
            gap: 1rem;
            padding: 0.75rem 1.5rem;
            background: var(--nav-bg);

            > .link {
                color: var(--nav-link);
                text-decoration: none;
            }

            > .link.__active {
                font-weight: 600;
                color: var(--nav-link-active);
            }
        }
    "#)
}

pub fn nav_bar(current_path: &str) -> Markup {
    html! {
        nav.nav-bar {
            a.link.__active[current_path == "/"] href="/" { "Home" }
            a.link.__active[current_path.starts_with("/users")] href="/users" { "Users" }
        }
    }
}

The processing pipeline

At startup, collect all CSS fragments, concatenate them with a base stylesheet, process through lightningcss, and cache the result in memory. A content hash in the filename enables indefinite browser caching.

Base stylesheet

A base.css file contains resets, custom properties, and global styles that don’t belong to any component:

/* assets/base.css */

*,
*::before,
*::after {
    box-sizing: border-box;
}

:root {
    --text: #1a1a2e;
    --text-muted: #6b7280;
    --bg: #ffffff;
    --border: #e5e7eb;
    --accent: #2563eb;
    --nav-bg: #f9fafb;
    --nav-link: #374151;
    --nav-link-active: #1a1a2e;
}

body {
    font-family: system-ui, -apple-system, sans-serif;
    color: var(--text);
    background: var(--bg);
    margin: 0;
    line-height: 1.6;
}

Build and serve the stylesheet

// src/styles.rs

use lightningcss::stylesheet::{StyleSheet, ParserOptions, MinifyOptions};
use lightningcss::printer::PrinterOptions;
use lightningcss::targets::{Targets, Browsers};
use std::sync::LazyLock;

pub struct CssFragment(pub &'static str);

inventory::collect!(CssFragment);

static BASE_CSS: &str = include_str!("../assets/base.css");

pub struct ProcessedCss {
    pub body: String,
    pub filename: String,
    pub route: String,
}

static STYLESHEET: LazyLock<ProcessedCss> = LazyLock::new(|| build_stylesheet());

pub fn stylesheet() -> &'static ProcessedCss {
    &STYLESHEET
}

fn build_stylesheet() -> ProcessedCss {
    // Concatenate base CSS and all component fragments
    let mut raw = String::from(BASE_CSS);
    for fragment in inventory::iter::<CssFragment> {
        raw.push('\n');
        raw.push_str(fragment.0);
    }

    // Process with lightningcss
    let targets = Targets::from(Browsers {
        chrome: Some(95 << 16),
        firefox: Some(90 << 16),
        safari: Some(15 << 16),
        ..Browsers::default()
    });

    let mut sheet = StyleSheet::parse(&raw, ParserOptions {
        filename: "styles.css".to_string(),
        ..ParserOptions::default()
    })
    .expect("CSS parse error");

    sheet
        .minify(MinifyOptions {
            targets,
            ..MinifyOptions::default()
        })
        .expect("CSS minify error");

    let result = sheet
        .to_css(PrinterOptions {
            minify: true,
            targets,
            ..PrinterOptions::default()
        })
        .expect("CSS print error");

    // Hash the output for cache-busting
    let hash = {
        use std::hash::{Hash, Hasher};
        let mut hasher = std::collections::hash_map::DefaultHasher::new();
        result.code.hash(&mut hasher);
        format!("{:x}", hasher.finish())
    };

    let filename = format!("style.{hash}.css");
    let route = format!("/assets/{filename}");

    ProcessedCss {
        body: result.code,
        filename,
        route,
    }
}

The LazyLock ensures the CSS is built once on first access and cached for the lifetime of the process. include_str! embeds base.css into the binary at compile time, so the binary is self-contained.

Wire it into Axum

Expose the stylesheet as a route and make the filename available to the layout:

// src/main.rs

use axum::{
    http::header,
    response::IntoResponse,
    routing::get,
    Router,
};

mod styles;
mod components;

async fn css_handler() -> impl IntoResponse {
    let css = styles::stylesheet();
    (
        [
            (header::CONTENT_TYPE, "text/css"),
            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
        ],
        css.body.clone(),
    )
}

fn app() -> Router {
    let css = styles::stylesheet();

    Router::new()
        .route(&css.route, get(css_handler))
        // ... other routes
}

The Cache-Control header tells browsers to cache the file for a year. Because the filename contains a content hash, deploying new CSS produces a new filename, and browsers fetch the new version automatically. Old cached versions expire naturally.

Reference the stylesheet in the layout

The layout component needs the hashed filename to build the <link> tag:

use maud::{html, Markup, DOCTYPE};
use crate::styles;

fn base_layout(title: &str, content: Markup) -> Markup {
    let css = styles::stylesheet();

    html! {
        (DOCTYPE)
        html lang="en" {
            head {
                meta charset="utf-8";
                meta name="viewport" content="width=device-width, initial-scale=1";
                title { (title) }
                link rel="stylesheet" href=(css.route);
                script src="/assets/htmx.min.js" defer {}
            }
            body {
                (content)
            }
        }
    }
}

Every page automatically references the current stylesheet version. When any component’s CSS changes, the hash changes, the filename changes, and browsers fetch the new file on the next page load.

How inventory works

inventory uses platform-specific linker constructor sections (the same mechanism as __attribute__((constructor)) in C). Each inventory::submit! call creates a static value and a constructor function that registers it in an atomic linked list. The OS loader runs all constructors before main() starts, so by the time your application code runs, every fragment is already registered and inventory::iter yields them all.

Three things to keep in mind:

  • No ordering guarantees. Fragments are yielded in whatever order the linker placed them. If CSS cascade order matters between components, switch to a struct with a weight field and sort after collecting. In practice, well-scoped component styles rarely depend on source order.
  • Same-crate usage is safe. The known linker dead-code-elimination issue (where submitted items in an unreferenced crate get stripped) does not apply when collect! and submit! are in the same crate. For a workspace with multiple crates, ensure each crate that submits fragments is referenced by at least one symbol in the binary crate.
  • submit! is module-level only. It cannot appear inside a function body. It is a static declaration, not a runtime statement.

Putting it together

The full flow:

  1. base.css contains resets, custom properties, and global styles. It is embedded with include_str!.
  2. Each component file uses inventory::submit! to register its CSS alongside its Maud markup.
  3. At startup, build_stylesheet() concatenates the base CSS with all registered fragments, processes the result through lightningcss, and hashes the output.
  4. The hashed filename is available to the layout via styles::stylesheet().route.
  5. A single Axum route serves the processed CSS from memory with long-lived cache headers.

No build step. No CSS preprocessor. No file watchers. The Rust compiler and lightningcss handle everything at compile time and startup.