How AdiaUI components use @scope, CSS custom properties, and zero-specificity defaults to create a themeable, isolated styling system without Shadow DOM or BEM.
Every AdiaUI component follows a consistent CSS architecture built on three principles:
@scope(tag-ui) — styles cannot leak in or out--{component}-{prop} custom properties — every visual property is configurable:where(:scope) — tokens can be overridden from outside without specificity warsThis pattern gives you the encapsulation benefits of Shadow DOM (no style leakage) with the flexibility of light DOM (global styles, form participation, CSS inheritance).
CSS @scope limits style rules to a specific subtree. AdiaUI scopes
each component to its custom element tag:
Unlike Shadow DOM, @scope does not create a style boundary — it creates
a style scope. CSS custom properties still inherit through the scope boundary,
global resets still apply, and form elements participate in their parent form natively.
This is the key mechanism that makes component tokens overridable. AdiaUI uses two distinct selectors for two different purposes:
:where(:scope) — Zero-specificity token defaults
Token declarations go in :where(:scope), which has zero specificity.
This means any external rule — even a bare element selector — can override these values:
:scope — Base styles (normal specificity)
Structural styles that consume the tokens go in :scope,
which has normal specificity. These define the component's layout, transitions,
and visual properties by referencing the tokens:
The separation is intentional: :where(:scope) sets what the values are,
:scope sets how they are applied. Because the defaults have zero
specificity, consumers can retheme without !important or deeply nested selectors.
Component tokens follow a strict --{component}-{property} naming scheme:
| Pattern | Example | Meaning |
|---|---|---|
--{comp}-bg |
--button-bg |
Background color |
--{comp}-fg |
--button-fg |
Foreground / text color |
--{comp}-border |
--button-border |
Border color |
--{comp}-radius |
--button-radius |
Border radius |
--{comp}-height |
--button-height |
Component height |
--{comp}-px |
--button-px |
Horizontal padding |
--{comp}-gap |
--button-gap |
Internal gap |
--{comp}-font-size |
--button-font-size |
Font size |
--{comp}-bg-{variant} |
--button-bg-primary |
Variant-specific background |
--{comp}-{prop}-{state} |
--button-bg-hover |
State-specific value |
All variant and state tokens are pre-declared in :where(:scope), even
if they are only used inside a variant selector. This makes every configurable value
discoverable at the top of the file.
Variants work by reassigning the base tokens to their variant-specific counterparts.
This keeps the :scope base styles unchanged — they always read from
--button-bg, --button-fg, etc. Variants just swap what
those tokens resolve to:
This "token indirection" approach means hover, focus, and disabled states only need to be written once — they always target the base tokens. Variants just redirect which values flow through.
Because tokens in :where(:scope) have zero specificity, consumers
can override any token from outside the component with a simple selector:
You never need !important to override component tokens. Even a type selector
like button-ui { --button-bg: red; } beats the zero-specificity defaults.
This is the entire point of the :where() wrapper.
Here is the complete button-ui CSS file, annotated to show how each
part of the pattern works together:
Shadow DOM provides strong style encapsulation, but it comes with significant trade-offs for a design system:
| Concern | Shadow DOM | @scope + Tokens |
|---|---|---|
| Style isolation | Hard boundary — no styles in or out | Soft boundary — scoped but inheritable |
| Theming | Requires ::part() or CSS custom properties piercing the boundary |
Standard CSS selectors work; tokens override naturally |
| Form participation | Requires ElementInternals + manual delegation |
Native — inputs inside the element are in the form |
| Global styles | Blocked — resets, typography must be duplicated inside | Inherited — global resets and tokens flow through |
| Dev tools | Styles hidden in shadow tree | All styles visible in the regular cascade |
| SSR / progressive | Requires declarative shadow DOM or FOUC | Works with plain HTML — styles apply immediately |
BEM (.block__element--modifier) solves naming collisions through convention.
AdiaUI's approach solves it through the platform:
| Concern | BEM | @scope + Tokens |
|---|---|---|
| Isolation | Convention-based (easy to break) | Enforced by @scope |
| Selector length | .button__icon--large |
icon-ui (inside @scope(button-ui)) |
| Theming | Override each BEM class individually | Set a token, all usages update |
| Variants | New class per variant per element | Attribute selector redirects tokens |
| Specificity | Flat but requires discipline | Structured: zero for defaults, normal for styles |
The component token pattern lets you write clean, minimal selectors inside each component while maintaining full isolation. No naming conventions to memorize, no class-name collisions to debug.
Follow this checklist when creating a new component's CSS:
@scope (my-component-ui) {:where(:scope) { } — base values, variants, sizes, states:scope { } that consume only your component tokens:scope:focus-visible with box-shadow: var(--a-focus-ring):scope[variant="..."] that reassign base tokens:scope[size="..."] that reassign height/padding/font tokens:scope[disabled]--a-*) in your token defaults, never in base styles