CSS isolation without hashing
CSS isolation without hashing
How the Furnace design system shares one global class namespace — and why nothing collides.
Written August 2026. This is a descriptive article about how the system works today; the normative rules live in the Furnace repository.
Most component systems buy style isolation with tooling: CSS Modules and styled-components hash class names so two components can both call something .title; Tailwind sidesteps naming entirely by making every class a single property. Furnace does neither. A Button really is class="button" in the DOM, every app imports one global stylesheet, and there are no namespace prefixes — .button, not .ui-button.
There is no hashed or scoped isolation anywhere. Furnace treats class names as a shared global vocabulary with exclusive ownership. Collisions stay rare because almost nobody else is allowed to mint names in the same set.
What actually ships
Every app imports a single stylesheet from the design system package. That file declares one cascade-layer order and then pulls in every component block:
@layer reset, tokens, utilities, components, overrides;
No CSS-in-JS, no utility classes in component markup, no build-time class rewriting. Isolation is not "this class can only match this component." Isolation is "this name is reserved, and everyone else stays out of it."
How names are reserved
Three conventions keep the global set small and unique:
One root class per component, derived from the filename. card.tsx → .card. user-status-indicator.tsx → .user-status-indicator. One block file per component in the design system's styles directory. The React root always emits that class plus a matching data-slot attribute.
Parts are block-prefixed, not generic. .card-header, .card-title, .list-count. The part name includes the block, so a bare .title never exists as a global style. The same string is dual-emitted as data-slot="card-title" for tests and composition.
Variants and state are not extra classes. Size, tone, and variant ride on data-* attributes. Overlay lifecycle uses data-state. Interactive state uses ARIA or a small is-* vocabulary. BEM (__element, --modifier) is banned and lint-gated. That removes the usual explosion of .button--primary, .button__icon, .button.is-loading names that would otherwise crowd the namespace.
The remaining short names — .stack, .row, .button, .card — are few, owned by the design system, and treated as reserved words.
How apps stay out of that set
Apps almost never style by reusing a design-system class name. The path of least resistance is the opposite:
- Use
<Button>, notclassName="button"— a lint gate flags class-as-component usage. - Use layout components and tokens instead of inventing layout classes.
- Restyle through component-scoped CSS variables (
--button-bg,--card-padding) or an intentional@layer overridesrule against an existing selector. - When an app does need its own CSS, it mints a feature- or app-prefixed name:
.ledger-table,.marozzo-not-found-page.
So the collision domain splits cleanly: the design system owns .button and .card-title; each app owns its own prefix. Two authors are never independently inventing .header.
That is why you see neither the styled-components problem nor the Tailwind problem. Styled-components hashes because any component can invent .title. Tailwind avoids name collisions by making every class a single property — and then you collide on intent instead. Furnace simply does not let a third author mint names in the same vocabulary.
What layers actually isolate
Cascade layers are about override order, not selector identity:
reset → tokens → utilities → components → overrides
:where(.button) makes every component root zero-specificity, so an app rule in @layer overrides wins without !important. Utilities sit under components, so .stack never fights .card. Unlayered CSS would beat all of this — which is why app styles are required to declare a layer.
If an app writes .button { … } in @layer overrides, that is not a collision. It is a deliberate restyle of the reserved name. A collision would be an app inventing its own .button that means something else — and discipline plus prefixed app classes make that the unusual case.
The one place layers do namespace
When a component is copied out of Furnace as a portable source artifact, each stylesheet is wrapped in @layer fui { … }; an inner @layer components becomes fui.components. That exists so verbatim layer names cannot collide with a host sheet that also declares components and utilities — Tailwind v4 does. The class names stay .button and .color-editor, shipped verbatim as a hard dependency of the copied code; theming happens through tokens, and the host's own layer order decides who wins.
Why this works without a proof
Nothing mechanically proves two files never both defined .panel. There is no cross-repo uniqueness lint. The system holds because:
- One package authors the reserved set.
- Apps are pushed to compose components instead of writing CSS.
- Remaining app CSS is prefixed and layered.
- The class surface stays small — no variant-class combinatorics.
- Restyling goes through tokens and variables, not competing class names.
"No collisions" is a social contract, not a bundler guarantee. The contract holds because the cheaper path is always to use the existing name, not to invent a colliding one.