Skip to content

Component style overrides need !important or selector mirroring — propose @layer openui contract #588

@ankit-thesys

Description

@ankit-thesys

Problem

Integrating OpenUI into an existing app inherits our component CSS into the consumer's cascade. Today there is no clean way for a consumer to override component styles without one of three workarounds — !important, mirroring our internal selector chains (which we change without notice), or stacking artificial specificity. None of these scale across an upgrade lifecycle.

Reproduced on main at eb4f3be9 against examples/openui-chat (Tailwind v4 + Next.js):

Scenario A — Tailwind v4 utility classes are silently ignored

A consumer writing <button className="openui-button-base-primary bg-red-500"> expects bg-red-500 to win. It doesn't. Tailwind v4 emits utilities inside @layer utilities; OpenUI emits component styles unlayered. Unlayered CSS always beats layered CSS regardless of specificity. Tailwind's utility is silently dropped.

Empirical probe in openui-chat:

@layer utilities { .__t1.openui-button-base-primary { background: red } }
→ computed background: oklch(0.994 0 89.876)   // OpenUI's accent, NOT red

The override looks right in source, doesn't show in browser, no error. This is the most painful failure mode.

Scenario B — Hover and [data-state] overrides require mirroring our internal selectors

A consumer who wants to recolor a button's hover state with .openui-button-base-primary:hover { background: red } will lose:

Selector Specificity
Consumer naive .openui-button-base-primary:hover (0,1,1)
OpenUI internal .openui-button-base-primary:not(:disabled):hover (0,3,1)

The consumer's only escapes today:

  1. Mirror our exact chain: .openui-button-base-primary:not(:disabled):hover { ... } — fragile, breaks on any refactor.
  2. Add !important — spreads across the consumer's codebase.
  3. Artificial parent wrapper (.my-app .openui-button-base-primary:hover) — works on the immediate state but loses on others (:disabled, :focus, [data-state]).

The library itself already pays this tax — 12 !important declarations in source SCSS.

Scenario C — Compound state selectors are widespread

Found on main across dist/components/index.css:

.openui-button-base-primary:not(:disabled):hover
.openui-button-base-primary:not(:disabled):active
.openui-button-base-secondary:not(:disabled):hover
... 8 more button variants

Plus Radix-backed components using [data-state="active"], [data-state="open"], etc. Consumer override patterns that don't mirror these silently fail.

Why this matters

  • Integration friction — consumers fork the library, ship !important, or work around with shadow DOM.
  • Upgrade pain — consumers who mirror our selector chains break on point releases.
  • Discoverability — no documented override path beyond ThemeProvider token overrides; CSS overrides are tribal knowledge.

Proposed solution

Adopt CSS Cascade Layers with a flat @layer openui wrap on all compiled component CSS. Cascade layers are a standard CSS feature (Chrome 99+, Firefox 97+, Safari 15.4+, Edge 99+ — March 2022 baseline) designed precisely for this problem.

The contract becomes:

  • Any unlayered consumer CSS beats OpenUI components automatically — no specificity matching, no !important. Works for plain CSS, CSS Modules, CSS-in-JS, and Tailwind v3.
  • Tailwind v4 consumers add one line to globals.css:
    @layer theme, base, openui, components, utilities;
    @import "tailwindcss";
    This puts openui between Tailwind's base (Preflight) and utilities — Preflight resets the baseline, OpenUI applies on top, utilities and consumer overrides win above OpenUI.

What stays the same:

  • ThemeProvider runtime style injection is intentionally unlayered → continues to override everything.
  • defaults.css token-only export stays unlayered → byte-identical to today.
  • var(--openui-*) lookups are unaffected (cascade layers don't affect custom-property resolution).

Implementation: two approaches prototyped

Source-side (wrap each .scss source file)

Every component SCSS gets @layer openui { ... } at source. Diff: 105 SCSS files + skill template + path-scoped rule + separate CI guard + Storybook preflight layer fix (preflight overrides components if not handled). Pro: Storybook (which consumes source SCSS) matches prod cascade exactly. Con: every new component author must remember the wrap; sprawl across many files.

Post-process (wrap compiled output in cp-css.js) — recommended

After Sass emits the CSS, cp-css.js walks dist/components/ and wraps each .css file in @layer openui { ... }. Diff: ~25 lines in one file. Component SCSS sources stay unchanged. Pro: zero contributor cognitive load; build is the source of truth; Storybook needs no config changes. Con: Storybook source consumes unlayered CSS, prod consumes layered — cascade behavior differs for consumer-override scenarios (which are typically tested in real consumer apps, not Storybook).

End-to-end verified in examples/openui-chat against both approaches. Identical consumer contract. Post-process ships ~50× smaller diff (+112/-1 vs +7434/-7150).

The accompanying PR implements post-process.

Browser support

The proposed branch declares "browserslist": "defaults and supports css-cascade-layers" to make the support floor explicit. Consumers on browsers older than March 2022 would see unstyled components — they either pin to an older OpenUI release or upgrade their target floor. This is the one real surface change.

Backwards compatibility

  • Plain CSS, CSS Modules, CSS-in-JS, Tailwind v3 — continue to work identically (unlayered still beats layered).
  • Tailwind v4 — needs one line in globals.css. All examples/ are updated; README + chat/installation.mdx document it.
  • Existing !important overrides — continue to work; they just become unnecessary.

Not a SemVer-major change for the documented integration paths.

Open questions

  1. Is the ./styles/* per-component CSS export used by anyone outside the monorepo? The proposed PR wraps these defensively; if no one consumes them, we could simplify by dropping the export path in a follow-up.
  2. Reserve a @layer reset slot inside openui now? Would let us ship a future global reset without changing the consumer contract later. Currently no — consumers manage their own reset.

Verification

Tested end-to-end in examples/openui-chat. Reproduction steps in the PR's test plan.

A PR implementing the post-process approach will be opened against this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions