Skip to content

CSS scoping

DriftCSS atomizes single-class rules — one selector, one element, zero combinators. Most real projects also have rules that target element trees, not individual elements. This page explains the limitation, why it exists, and the conventions that work around it.

What can't be atomized

An atom is a rule of the form .c-N { property: value }. It maps one-to-one with a declaration and applies to a single element. Rules that target multiple elements or add relational context can't fit that shape:

Rule typeExampleCan atomize?
Simple class.card { padding: 1rem }
CSS custom property.root { --ac: #f2a93c }
Pseudo-class.btn:hover { background: … }
Pseudo-element.label::before { content: "" }
Descendant.root a { color: inherit }
Universal child.root * { box-sizing: border-box }
Pseudo-element on descendant.root ::selection { … }

Non-atomizable rules are serialized as-is in the output stylesheet (pass-through). They still work — they just don't contribute to atom deduplication.

ID selectors

Using IDs in CSS causes two problems:

  1. Specificity#hero-scroll (1,0,0) overrides any class-based atom, which makes the cascade unpredictable.
  2. Mixed concerns — IDs serve three purposes in HTML: JavaScript hooks, anchor links (href="#section"), and (historically) CSS. Bundling all three in one selector makes it hard to reason about each.

The recommended pattern is to keep IDs purely for JS and anchors, and move all styling to classes:

css
/* before */
#hero-scroll { height: 280vh; position: relative; }

/* after — fully atomized */
.hero-scroll { height: 280vh; position: relative; }
html
<!-- id stays for JS/anchors; class carries the CSS -->
<div id="hero-scroll" class="hero-scroll">

The scope anchor problem

When DriftCSS atomizes a class, the class name is replaced in the HTML:

html
<!-- source -->
<div class="root">…</div>

<!-- after atomization -->
<div class="c-12 c-47 c-93 …">…</div>

The root class no longer exists on any element. Any compound selectors that relied on it break silently:

css
/* authored — .root is gone after build */
.root * { box-sizing: border-box }  /* ✗ never matches */
.root a { color: inherit }          /* ✗ never matches */

The same problem occurs if you were using #root and the element kept its ID — the ID is fine, but .root for the children is gone.

The data-driftcss convention

The solution is a dedicated attribute with a scope name that is not touched by DriftCSS (attributes are never atomized) and acts purely as a CSS scope anchor:

html
<div id="root" data-driftcss="landing" class="root">
css
/* compound selectors use the attribute, not the class */
[data-driftcss="landing"] * { box-sizing: border-box }
[data-driftcss="landing"] a { color: inherit }
[data-driftcss="landing"] ::selection { background: rgba(var(--ac-rgb), 0.28) }

Using a value (e.g. "landing", "docs", "sidebar") means multiple independent scopes can coexist on the same page without their compound rules interfering:

css
[data-driftcss="docs"] a { color: var(--link-color); }
[data-driftcss="landing"] a { color: inherit; text-decoration: none; }

This separates the three concerns on the element clearly:

AttributePurposeSurvives atomization?
id="root"JS (getElementById) + anchor links
data-driftcss="landing"CSS scope anchor for compound selectors
class="root"Atomizable declarations — rewritten at build

[data-driftcss="landing"] has specificity (0,1,0) — the same as a class selector — so it doesn't create the specificity issues that #id would.

Attribute selector specificity

[data-driftcss="landing"] * has specificity (0,1,0). That is lower than #id * (1,0,0) and equal to .class * (0,1,0). If you need zero-specificity scoping for a reset, wrap in :where():

css
:where([data-driftcss="landing"]) * { box-sizing: border-box }

When to use preserveSelectors

Use preserveSelectors when a class name must survive atomization because it appears as the subject of a compound or descendant selector. The most common case: a parent class is atomized (class removed from HTML) but a CSS rule targets its children.

css
/* .feature-description gets atomized — this rule becomes dead */
.feature-description code { font-family: monospace; color: #aaa }

/* .alpha-feature-done same problem */
.alpha-feature-done code { color: var(--ac-bright) }
js
// vite.config.ts — keep these class names on the element
driftcss({
  css: ['src/**/*.css'],
  preserveSelectors: ['feature-description', 'alpha-feature-done'],
})

With preserveSelectors, the class name is kept as-is in the HTML and its full rule is passed through to the output stylesheet unchanged. The compound selectors that reference it continue to work — but those declarations don't contribute to atom deduplication.

If a parent is already pass-through (combinator nesting, &.active on that block), extract leaves the semantic class in the mapping empty — JSX keeps the authored name and the CSS still matches. The silent-break case is only when the parent was atomized.

Rule of thumb:

  • Use data-driftcss="<scope>" for the root element of a component tree (resets, globals, pseudo-element rules).
  • Use preserveSelectors for leaf-adjacent classes whose compound rules target their children (code blocks inside a description, modifier states, child combinators).