scrml.dev v0.7.1
Articles › The what

CSS without a build step

authored by claude, rubber stamped by Bryan MacLee

TL;DR: Native CSS shipped @scope while we were not looking. scrml compiles to that. No PostCSS, no runtime, no className mangler.

I designed scrml to have the fewest moving parts that could deliver real reactivity, real state, and real components. Then I looked at what shipping styles costs in modern frameworks, and I almost laughed.

I am not an experienced framework developer. I can hobble through React if I HAVE TO. I have written enough CSS to know what fine-tuning a layout feels like. Most of what I have read about scoped CSS in framework land is people building elaborate machinery to compensate for a feature the browser had not yet shipped. The browser shipped it. The machinery did not retire.

This is the fourth of six features the browser-language overview piece promised to unpack later. CSS, in detail.

What shipping scoped styles actually costs (BEFORE)

Pick the path you took the last time you scoped a component's styles. One of these:

Tailwind utility-first. A PostCSS pipeline. A JIT scanner that walks your source files. A tailwind.config.js. A content glob you keep wrong until something disappears in production. The class-name strings on every element grow until the markup looks like a base64 blob. The system is good. The plumbing it sits on is a build pipeline you maintain.

styled-components or emotion (CSS-in-JS). A runtime that injects style tags as components mount. A Babel plugin to attach component display names. SSR ceremony to extract critical CSS without flashing unstyled content. A re-render every time a styled component's props change. The runtime cost has been argued about for years, and it is genuinely real for high-frequency renders.

CSS Modules. A build step that hashes class names. The compiled output reads .btn_3xK9f and the source reads .btn. DevTools shows you the hash. You learn to read it. Source maps fix this when they work.

vanilla-extract. A typed DSL on top of CSS. Still a build step. Still a layer between what you write and what the browser parses.

Each of those paths exists for one reason: in 2019, scoped styles were not a primitive. The browser did not give you a way to say "these rules apply only inside this component, and not inside its children." Frameworks built it themselves. Five different ways. All five required tooling. All five became how things are done.

What native CSS actually shipped

@scope is in the browser now.

@scope ([data-card]) to ([data-card]) {
    .body { padding: 16px; }
    .title { font-size: 1.25rem; }
}

The block applies inside any element with data-card, and stops at any nested element with data-card. That second clause is the donut: child components inherit nothing of the parent's styles, no matter how generic the selector inside the block looks. Parent's .title does not bleed into child's .title. No hash. No mangler. No runtime. The browser does it.

@scope reached Baseline in December 2025. Chrome 143+, Firefox 146+, Safari 26.2+. Most application devs do not know about it because the framework path obscured it. The framework path was a workaround for an absent feature. The feature is no longer absent.

The scrml way (AFTER)

scrml's CSS sigil is #{}. Inside a component declaration, it scopes. At program scope, it goes global. There is no third mode.

<program>

${
    const Card = <article props={ title: string }>
        #{
            .card { padding: 16px; border: 1px solid #e5e7eb; border-radius: 8px; }
            .title { font-size: 1.25rem; font-weight: 600; }
        }
        <div class="card" data-scrml="card">
            <h2 class="title">${title}</h2>
        </div>
    </>
}

</program>

The compiler emits this CSS:

@scope ([data-scrml="card"]) to ([data-scrml]) {
    .card { padding: 16px; border: 1px solid #e5e7eb; border-radius: 8px; }
    .title { font-size: 1.25rem; font-weight: 600; }
}

That is the entire pipeline. The selectors in the source are the selectors in the output. DevTools shows you .title, not .title_h7Bz4. Source maps are optional. The donut limit (to ([data-scrml])) is implicit: nested constructors get their own scope and the outer rules stop at the boundary.

There is one more sub-mode. A #{} block with no selectors, just bare property: value; pairs, compiles to an inline style="" attribute on the containing element. It does not appear in the .css file at all.

${
    <title> = "Hello"

    lift <div data-scrml="card">
        #{ padding: 16px; }
        <h2>${@title}</h2>
    </div>
}

becomes a <div style="padding: 16px;"> directly. The most common case (one or two declarations on one element) gets the lightest possible compilation.

That is it. No PostCSS. No runtime. No hash mangler. No tailwind.config.js content glob. No styled-components Babel plugin. No SSR critical-extraction. The compiler reads the #{} block, decides whether it is selector-based or flat-declaration, and emits the corresponding output. Co-location of behavior was a founding philosophy of scrml. The styles for a component live inside the component. They compile next to the markup that wears them.

What this kills

  • The PostCSS config. There is no postcss.config.js in a scrml project. There is a scrml build.
  • The Tailwind CLI step. The compiler runs the utility-class scan as part of its normal pass.
  • The styled-components runtime. There are no style tags injected on mount. CSS is emitted at compile time and served as a .css file the browser loads once.
  • The Babel plugin tier for CSS-in-JS. There is no Babel.
  • Hash-mangled class names. The class you wrote is the class the browser sees.
  • The "where is my SSR critical CSS extraction layer" decision. There isn't one to configure.
  • The decision of which CSS-in-JS library to use. The decision is no CSS-in-JS library; the language has CSS as a context.

That's the tier of tooling that compresses out when the browser already has scoping.

What is still real

Tailwind's system is genuinely useful. The utility-first vocabulary, the design-token discipline, the spacing scale, the way the class names compose: that is real ergonomics. The point of this article is not "no Tailwind." It is "no Tailwind pipeline."

scrml has a built-in Tailwind engine. It scans the source for used utility classes and emits only the CSS rules for what the source actually uses. No tailwind.config.js. No content array. No PostCSS. The engine is in the compiler.

${
    const Badge = <span props={ label: string }
                         data-scrml="badge"
                         class="px-3 py-1 rounded-full bg-emerald-100 text-emerald-800">
        ${label}
    </>
}

That works out of the box. Mixed with #{} for things utilities don't reach, in the same component.

Two honest disclosures.

1. The current built-in engine covers the core utility set. Arbitrary values (p-[1.5rem]), responsive prefixes (md:, lg:), variant prefixes (hover:, focus:), and custom theme configuration are tracked in SPEC-ISSUE-012 and not yet shipped. If your design language requires those today, you would still need them. They are on the roadmap. 2. Tailwind utility classes live outside @scope. They remain globally scoped, by design. A .bg-emerald-100 rule in a child constructor uses the same emerald as the parent. That is what Tailwind users expect; it is the right call.

The #{} block is for the cases utilities don't cover. The utilities are for the design-token cases, where consistency across the app is the value.

The deeper point

A reactive system that wires its dependencies at compile time does no work at runtime to figure out what to update. A query that batches itself at compile time does not need a DataLoader. A boundary that is enforced at compile time does not need a validator on the wire. CSS that compiles to native @scope does not need a runtime mangler.

The runtime does less because the compiler did more. The browser shipped a feature. A language designed today should compile to it. A language designed in 2019 had to ship the workaround as a library. That is not a critique of styled-components or Tailwind: those are excellent solutions to the problem available at the time. It is an observation about the tooling tier that compresses out when the language sits on top of the browser the browser actually is, not the browser it was when the framework was conceived.

I am sure I am wrong about plenty. But every time I look at what a fresh-shape primitive subsumes, the same thing happens. A whole tier of tooling stops being a thing you have to assemble. A little short of perfect is still pretty awesome.

Further reading