Component Token Pattern

How AdiaUI components use @scope, CSS custom properties, and zero-specificity defaults to create a themeable, isolated styling system without Shadow DOM or BEM.

Overview

Every AdiaUI component follows a consistent CSS architecture built on three principles:

This pattern gives you the encapsulation benefits of Shadow DOM (no style leakage) with the flexibility of light DOM (global styles, form participation, CSS inheritance).

The @scope Pattern

CSS @scope limits style rules to a specific subtree. AdiaUI scopes each component to its custom element tag:

@scope (button-ui) { /* Everything in here only applies inside <button-ui> */ :scope { ... } /* the button-ui element itself */ [slot="trailing"] { ... } /* children of button-ui */ }

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.

:where(:scope) vs :scope

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 (button-ui) { :where(:scope) { --button-bg: var(--a-bg-muted); --button-fg: var(--a-fg-muted); --button-radius: var(--a-radius); /* ... all component tokens declared here ... */ } }

: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:

@scope (button-ui) { :scope { display: inline-flex; align-items: center; background: var(--button-bg); /* consumes the token */ color: var(--button-fg); /* consumes the token */ border-radius: var(--button-radius); /* consumes the token */ transition: background var(--a-duration-fast) var(--a-easing); } }

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.

Naming Convention

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.

Variant Overrides

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:

/* Variant selectors reassign the base tokens */ :scope[variant="primary"] { --button-bg: var(--button-bg-primary); --button-fg: var(--button-fg-primary); } :scope[variant="outline"] { --button-bg: var(--button-bg-outline); --button-fg: var(--button-fg-outline); --button-border: var(--button-border-outline); } :scope[variant="ghost"] { --button-bg: var(--button-bg-ghost); --button-fg: var(--button-fg-ghost); --button-border: var(--button-border-ghost); }

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.

Consumer Overrides

Because tokens in :where(:scope) have zero specificity, consumers can override any token from outside the component with a simple selector:

/* Override from a parent context */ .my-toolbar button-ui { --button-bg: var(--md-sys-color-primary); --button-fg: var(--md-sys-color-primary-on-primary); --button-radius: var(--a-radius-full); } /* Override a specific instance */ button-ui.big-cta { --button-height: 3rem; --button-px: var(--a-space-6); --button-font-size: var(--a-ui-lg); }

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.

Annotated Example: button-ui

Here is the complete button-ui CSS file, annotated to show how each part of the pattern works together:

@scope (button-ui) { /* ── LAYER 1: Token defaults (zero specificity) ── Every configurable value is declared here. Consumers can override any of these from outside. */ :where(:scope) { /* Base tokens — consumed by :scope styles */ --button-bg: var(--a-bg-muted); --button-fg: var(--a-fg-muted); --button-border: transparent; --button-height: var(--a-size); --button-px: var(--a-ui-px); --button-radius: var(--a-radius); --button-font-size: var(--a-ui-size); --button-font-weight: var(--a-ui-weight); --button-gap: var(--a-space-1); /* Hover state tokens */ --button-bg-hover: var(--md-sys-color-neutral-background); --button-fg-hover: var(--md-sys-color-neutral-on-surface); --button-border-hover: var(--md-sys-color-neutral-on-surface); /* Variant: primary */ --button-bg-primary: var(--md-sys-color-primary); --button-fg-primary: var(--md-sys-color-primary-on-primary); /* Variant: outline */ --button-bg-outline: transparent; --button-fg-outline: var(--md-sys-color-neutral-on-surface); --button-border-outline: var(--a-ui-border); /* Variant: ghost */ --button-bg-ghost: transparent; --button-fg-ghost: var(--md-sys-color-neutral-on-surface-variant); --button-fg-ghost-hover: var(--md-sys-color-neutral-on-surface); --button-bg-ghost-hover: var(--a-bg-hover); --button-border-ghost: transparent; --button-border-ghost-hover: var(--md-sys-color-neutral-outline); /* Variant: danger */ --button-bg-danger: var(--md-sys-color-danger); --button-fg-danger: var(--md-sys-color-danger-on-danger); /* Size: sm */ --button-height-sm: var(--a-ui-size-sm); --button-px-sm: var(--a-space-1); --button-font-size-sm: var(--a-ui-sm); /* Size: lg */ --button-height-lg: var(--a-ui-size-lg); --button-px-lg: var(--a-space-2); --button-font-size-lg: var(--a-ui-lg); /* Disabled state */ --button-bg-disabled: var(--a-ui-bg-disabled); --button-fg-disabled: var(--a-ui-text-disabled); --button-border-disabled: transparent; } /* ── LAYER 2: Base styles (normal specificity) ── These consume the tokens above. They define layout and visual properties that apply to ALL variants. */ :scope { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: var(--button-gap); min-width: var(--button-height); min-height: var(--button-height); padding-inline: var(--button-px); border: 1px solid var(--button-border); border-radius: var(--button-radius); background: var(--button-bg); color: var(--button-fg); font-size: var(--button-font-size); font-weight: var(--button-font-weight); cursor: pointer; transition: background var(--a-duration-fast) var(--a-easing), border-color var(--a-duration-fast) var(--a-easing), color var(--a-duration-fast) var(--a-easing); } :scope:focus-visible { outline: none; box-shadow: var(--a-focus-ring); } /* ── LAYER 3: Variants ── Reassign base tokens to variant-specific values. Base styles don't change — only the token values do. */ :scope[variant="primary"] { --button-bg: var(--button-bg-primary); --button-fg: var(--button-fg-primary); } :scope[variant="outline"] { --button-bg: var(--button-bg-outline); --button-fg: var(--button-fg-outline); --button-border: var(--button-border-outline); } :scope[variant="ghost"] { --button-bg: var(--button-bg-ghost); --button-fg: var(--button-fg-ghost); --button-border: var(--button-border-ghost); } /* ── LAYER 4: States ── Hover, disabled, sizes. Written once, works for all variants. */ :scope:not([disabled]):hover { --button-bg: var(--button-bg-hover); --button-fg: var(--button-fg-hover); --button-border: var(--button-border-hover); } :scope[disabled] { --button-bg: var(--button-bg-disabled); --button-fg: var(--button-fg-disabled); --button-border: var(--button-border-disabled); pointer-events: none; } :scope[size="sm"] { --button-height: var(--button-height-sm); --button-px: var(--button-px-sm); --button-font-size: var(--button-font-size-sm); } }

Why Not Shadow DOM?

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

Why Not BEM?

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.

Writing a New Component

Follow this checklist when creating a new component's CSS:

  1. Open with @scope (my-component-ui) {
  2. Declare all tokens in :where(:scope) { } — base values, variants, sizes, states
  3. Write base styles in :scope { } that consume only your component tokens
  4. Add :scope:focus-visible with box-shadow: var(--a-focus-ring)
  5. Write variants as :scope[variant="..."] that reassign base tokens
  6. Write sizes as :scope[size="..."] that reassign height/padding/font tokens
  7. Write disabled as :scope[disabled]
  8. Reference global tokens (--a-*) in your token defaults, never in base styles
@scope (my-widget-ui) { :where(:scope) { --widget-bg: var(--a-bg-subtle); --widget-fg: var(--md-sys-color-neutral-on-surface); --widget-radius: var(--a-radius-md); --widget-px: var(--a-space-3); } :scope { display: flex; padding-inline: var(--widget-px); background: var(--widget-bg); color: var(--widget-fg); border-radius: var(--widget-radius); } }