Lesson 21 / 23
Scoped Styling Approaches
Approaches that genuinely limit the scope of names; generated local names, class sharing by declaration hash, the document scope declaration, shadow tree encapsulation, and comparing the guarantees each approach gives.
Contents
A problem was left open at the end of the previous lesson. A naming methodology prevents collisions, but only by convention: if two developers give the same block the same name, no tool warns them, and the rules silently mix together.
This lesson takes up approaches that move a name’s scope out of convention and into a mechanism. First, what the word “scope” guarantees needs to be separated out.
What Scope Guarantees
Style leakage is not a single event; it is three separate events.
Name collision. Two separate components use the same class name; one’s rules also match the other’s elements.
Leak-in. A general rule written outside the component — p, a, * + *, and the like —
matches elements inside the component.
Leak-out. A rule written inside the component matches elements outside the component. This is the risk every rule written with a descendant combinator carries.
An approach being counted as “scoped” does not mean it solves all three. The program below first computes two generation methods, then compares which direction each of five approaches blocks.
// scoping.mjs — generating scoped local names, and sharing classes by declaration hash import { createHash } from "node:crypto"; const hash = (text, length = 5) => createHash("sha256").update(text).digest("hex").slice(0, length); // --- 1. local name + file path -> unique generated name --- const generate = (path, localName) => `${localName}_${hash(`${path}#${localName}`)}`; const INPUTS = [ ["components/measurement-card.css", "card"], ["components/measurement-card.css", "heading"], ["components/sidebar.css", "card"], // same local name, different file ["components/sidebar.css", "heading"], ["components/measurement-card.css", "card"], // same input: must generate the same name ]; console.log("--- mapping local names to generated names ---"); const generatedNames = new Map(); for (const [path, name] of INPUTS) { const g = generate(path, name); console.log(` ${path.padEnd(34)} .${name.padEnd(10)} -> .${g}`); generatedNames.set(`${path}#${name}`, g); } const collision = new Set([...generatedNames.values()]).size !== generatedNames.size; console.log(` distinct input count: ${generatedNames.size}, generated name count: ${new Set([...generatedNames.values()]).size}, collision: ${collision}`); // --- 2. sharing a class by the hash of the declaration set --- const normalize = (declarations) => declarations.map((d) => d.replace(/\s+/g, " ").trim()).sort().join(";"); const className = (declarations) => "b" + hash(normalize(declarations), 7); const COMPONENTS = { "measurement-card": ["padding: 1.5rem", "border: 1px solid #ccd", "border-radius: 4px"], "sidebar-box": ["border-radius: 4px", "padding: 1.5rem", "border: 1px solid #ccd"], // same set "masthead": ["padding: 1.5rem", "border: 1px solid #ccd"], "location-panel": ["padding: 1.5rem", "border: 1px solid #ccd", "border-radius: 4px"], }; console.log("\n--- class generated by the hash of the declaration set ---"); const generatedRules = new Map(); for (const [name, declarations] of Object.entries(COMPONENTS)) { const cls = className(declarations); generatedRules.set(cls, normalize(declarations)); console.log(` ${name.padEnd(18)} -> .${cls}`); } console.log(` component count: ${Object.keys(COMPONENTS).length}, generated rule count: ${generatedRules.size}`); for (const [cls, body] of generatedRules) console.log(` .${cls} { ${body.replaceAll(";", "; ")} }`); // --- 3. the guarantees scoping approaches give --- console.log("\n--- which approach guarantees what ---"); const APPROACHES = [ { name: "naming convention alone", nameCollision: false, leakIn: false, leakOut: false }, { name: "generated local name", nameCollision: true, leakIn: false, leakOut: true }, { name: "runtime generation", nameCollision: true, leakIn: false, leakOut: true }, { name: "document scope declaration", nameCollision: false, leakIn: false, leakOut: true }, { name: "shadow tree encapsulation", nameCollision: true, leakIn: true, leakOut: true }, ]; console.log("approach".padEnd(26) + "name collision".padStart(16) + "leak-in".padStart(12) + "leak-out".padStart(12)); for (const a of APPROACHES) { const blocked = (v) => (v ? "blocked" : "not blocked"); console.log(a.name.padEnd(26) + blocked(a.nameCollision).padStart(16) + blocked(a.leakIn).padStart(12) + blocked(a.leakOut).padStart(12)); }
--- mapping local names to generated names ---
components/measurement-card.css .card -> .card_574af
components/measurement-card.css .heading -> .heading_3232f
components/sidebar.css .card -> .card_fb1b0
components/sidebar.css .heading -> .heading_85231
components/measurement-card.css .card -> .card_574af
distinct input count: 4, generated name count: 4, collision: false
--- class generated by the hash of the declaration set ---
measurement-card -> .b3fd5e52
sidebar-box -> .b3fd5e52
masthead -> .bc3527a6
location-panel -> .b3fd5e52
component count: 4, generated rule count: 2
.b3fd5e52 { border-radius: 4px; border: 1px solid #ccd; padding: 1.5rem }
.bc3527a6 { border: 1px solid #ccd; padding: 1.5rem }
--- which approach guarantees what ---
approach name collision leak-in leak-out
naming convention alone not blocked not blocked not blocked
generated local name blocked not blocked blocked
runtime generation blocked not blocked blocked
document scope declaration not blocked not blocked blocked
shadow tree encapsulation blocked blocked blocked
Generated Local Names
In the first approach, short, readable names are written in the source files: .card,
.heading. A compile step hashes these names together with the file path into unique names,
and the template that produces the document uses the same mapping.
The output’s first block shows two properties. The name .card in two separate files is
translated into two different names — collision is structurally impossible. The same input
always generates the same name; generation is deterministic, giving the same output across
two separate compiles.
Putting the local name at the front of the generated name is deliberate: seeing
.card_574af in developer tools tells you which source name it is. Names made of nothing but a
hash lose this readability.
The approach’s limit is that a name cannot be assembled at runtime. If a name is produced by string concatenation, the compiler cannot see that name and cannot map it; names must appear as full literal text in the source.
Sharing by Declaration Hash
In the second approach, the class name is derived from the declarations themselves. The declaration set is sorted and reduced to a single piece of text, and that text’s hash becomes the class name.
The output’s second block produces two rules for four components. Three components’ declarations are the same — the write order and extra whitespace differ, but the normalized text is identical — and all three share the same class. The one component whose declaration set differs gets its own class.
The result is that the generated file grows not with the number of components but with the number of distinct declaration sets. Compared to the previous lesson’s notation that copies the same body, repetition disappears on its own.
Its cost falls into three categories. If generation happens at runtime, rules may be added to the document after the first paint. The cascade order of rules depends on generation order and cannot be predicted by reading the source. Hashed names are not readable; a debugging tool shows the rule but not which component it came from.
Document Scope Declaration
CSS’s own tool is the @scope rule. It takes a scoping root and an optional scoping
limit; the rules inside it match only elements in the root’s subtree, outside the limit.
@scope (.measurement-card) to (.measurement-card__body) { p { margin-block: 0; } a { color: var(--brand-dark); } }
The p rule here applies only inside the measurement card, and only before entering the
card’s body. Written without scope, the same rule would match every paragraph on the page.
@scope brings one more novelty: between two scoped rules writing to the same property, the
one whose scoping root is closer to the element wins. This criterion is evaluated before
specificity. This is why, in two nested theme scopes, the inner theme wins.
The table’s limit matters here: @scope blocks leak-out, it does not block name collision. If
two components use the same class name and both write unscoped rules, the problem persists.
Shadow Tree Encapsulation
The one approach that blocks all three directions at once is the shadow tree. A stylesheet inside a shadow root built with Web Components applies only to that tree; outside selectors do not match inward, inside selectors do not match outward.
Encapsulation has three deliberate gaps. Inherited properties cross the boundary: font and
color flow from outside in. Custom properties are inherited too, so a component’s style
interface is filled from outside through the same mechanism. The ::part pseudo-element lets
the parts a component exposes outward be targeted.
These gaps are not a flaw, they are a design: full isolation would close the component off from theming.
The Selection Criterion
The approaches do not exclude each other, and the choice depends on three questions. Are components written by separate teams — is name collision a real risk? Does style arrive on the page from outside — is protection against leak-in needed? Is a compile step already in place?
On a small page, a naming convention together with @scope is enough; neither needs a compile
step. Where components are distributed independently, generated names or a shadow tree are
needed.
In the Station Page
/* components/measurement-card.css — step 4: scoped notation */ @scope (.measurement-card) { :scope { display: grid; gap: var(--spacing-0); background-color: var(--card-surface, var(--surface)); } .measurement-card__value { font-size: 2rem; font-variant-numeric: tabular-nums; } p { margin-block: 0; } dt { color: var(--text-muted); } }
The :scope pseudo-class targets the scoping root itself. Type selectors can now be written
with confidence: the p and dt rules apply only inside the card and do not leak into the
rest of the page.
Class names were still written methodically. Scope cuts off leak-out, but if another
.measurement-card__value is defined elsewhere on the page, the two rules still compete.
Summary
- Style leakage is three separate events: name collision, leak-in, and leak-out; an approach may not block all three at once.
- Generated local names structurally prevent collision by hashing the local name together with the file path, and are deterministic; they work on the condition that the name is not assembled at runtime.
- Classes generated by declaration hash let components share the same declaration set; the generated file grows with the number of distinct declaration sets, not the number of components.
@scopetakes a root and a limit, blocks leak-out, and applies its proximity criterion before specificity; it does not prevent name collision.- The shadow tree closes off all three directions; inherited properties, custom properties,
and
::partare deliberately left-open gateways.
Next Step
Every approach up to this point shares the same assumption: style is written per component, and each component has its own rules. There is an approach that argues the opposite — never writing component-specific rules at all, building the document out of predefined, single-declaration classes. The next lesson takes up this approach and the constraint philosophy it rests on.
To keep your progress and take notes, Log in
My notes
Log in to take notes.