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:
- Mirror our exact chain:
.openui-button-base-primary:not(:disabled):hover { ... } — fragile, breaks on any refactor.
- Add
!important — spreads across the consumer's codebase.
- 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
- 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.
- 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.
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
mainateb4f3be9againstexamples/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">expectsbg-red-500to 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: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 selectorsA consumer who wants to recolor a button's hover state with
.openui-button-base-primary:hover { background: red }will lose:.openui-button-base-primary:hover.openui-button-base-primary:not(:disabled):hoverThe consumer's only escapes today:
.openui-button-base-primary:not(:disabled):hover { ... }— fragile, breaks on any refactor.!important— spreads across the consumer's codebase..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
!importantdeclarations in source SCSS.Scenario C — Compound state selectors are widespread
Found on
mainacrossdist/components/index.css: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
!important, or work around with shadow DOM.ThemeProvidertoken overrides; CSS overrides are tribal knowledge.Proposed solution
Adopt CSS Cascade Layers with a flat
@layer openuiwrap 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:
!important. Works for plain CSS, CSS Modules, CSS-in-JS, and Tailwind v3.globals.css:openuibetween Tailwind'sbase(Preflight) andutilities— Preflight resets the baseline, OpenUI applies on top, utilities and consumer overrides win above OpenUI.What stays the same:
ThemeProviderruntime style injection is intentionally unlayered → continues to override everything.defaults.csstoken-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
.scsssource 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) — recommendedAfter Sass emits the CSS,
cp-css.jswalksdist/components/and wraps each.cssfile 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-chatagainst 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
globals.css. Allexamples/are updated; README +chat/installation.mdxdocument it.!importantoverrides — continue to work; they just become unnecessary.Not a SemVer-major change for the documented integration paths.
Open questions
./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.@layer resetslot insideopenuinow? 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.