setSidebarOpen((o) => !o)} />
{isEmpty ? (
diff --git a/packages/react-ui/README.md b/packages/react-ui/README.md
index 2f3044661..655514018 100644
--- a/packages/react-ui/README.md
+++ b/packages/react-ui/README.md
@@ -141,34 +141,45 @@ function App() {
## Styling integration
-OpenUI's component styles live inside a CSS cascade layer named `openui`. Any unlayered consumer CSS overrides OpenUI without `!important` or specificity matching:
+OpenUI ships its component styles in two variants:
+
+| Import | Cascade behavior |
+| --- | --- |
+| `@openuidev/react-ui/components.css` (default) | Unlayered — override via normal CSS specificity, as in 0.11.x and earlier |
+| `@openuidev/react-ui/layered-components.css` (opt-in) | Wrapped in `@layer openui` — any unlayered consumer CSS wins |
+
+Per-component granular imports follow the same split: `./styles/*` (unlayered) and `./layered/styles/*` (layered).
+
+With the layered variant, plain CSS overrides OpenUI without `!important` or specificity matching:
```css
-@import "@openuidev/react-ui/components.css";
+@import "@openuidev/react-ui/layered-components.css";
/* Wins, no specificity tricks needed */
.openui-button-base-primary { background: hotpink; }
```
-### With Tailwind v4
+### With Tailwind v4 (layered variant)
Declare layer order at the top of your entry stylesheet so `openui` sits above Tailwind's reset but below `components` and `utilities`:
```css
@layer theme, base, openui, components, utilities;
-@import "@openuidev/react-ui/components.css";
@import "tailwindcss";
+@import "@openuidev/react-ui/layered-components.css";
```
This places Tailwind's Preflight (in `base`) below OpenUI components so its element resets don't override them, while keeping utilities (`bg-red-500`, etc.) winning over OpenUI styles.
-### With Tailwind v3, CSS Modules, or CSS-in-JS
+### Rules for the layered variant
-No configuration needed — these all emit unlayered CSS, which automatically beats anything in `@layer openui`.
+- Import OpenUI CSS from **exactly one place** — multiple import sites under chunk-splitting bundlers (e.g. Turbopack) can register `openui` before your layer-order statement and lock the wrong order.
+- Wrap app-wide resets in a layer below `openui` (e.g. `@layer base { * { margin: 0; } }`) — unlayered resets beat all layered styles regardless of specificity.
+- `./defaults.css` and the `ThemeProvider` runtime style injection stay unlayered in both modes so runtime theming always overrides component defaults.
### Browser support
-CSS cascade layers require Chrome 99+, Firefox 97+, Safari 15.4+, or Edge 99+ (all baseline from March 2022). On older browsers, the `@layer { ... }` block is dropped entirely and components render unstyled. The package declares this floor via the `browserslist` field in its `package.json`.
+The layered variant requires CSS cascade layers: Chrome 99+, Firefox 97+, Safari 15.4+, Edge 99+ (all baseline from March 2022). On older browsers the `@layer { ... }` block is dropped entirely and components render unstyled. The default unlayered styles have no such floor.
## Components
@@ -200,9 +211,11 @@ import { Charts } from "@openuidev/react-ui/Charts";
| :--- | :--- |
| `@openuidev/react-ui` | All components and libraries |
| `@openuidev/react-ui/components.css` | Compiled component styles |
+| `@openuidev/react-ui/layered-components.css` | Opt-in aggregate stylesheet wrapped in `@layer openui` |
+| `@openuidev/react-ui/defaults.css` | Theme tokens, always unlayered |
| `@openuidev/react-ui/genui-lib` | OpenUI Lang libraries and prompt options |
-| `@openuidev/react-ui/tailwind` | Tailwind CSS plugin |
-| `@openuidev/react-ui/styles/*` | SCSS utilities |
+| `@openuidev/react-ui/styles/*` | Per-component compiled styles (unlayered) |
+| `@openuidev/react-ui/layered/styles/*` | Per-component styles wrapped in `@layer openui` |
| `@openuidev/react-ui/scssUtils` | SCSS utility functions |
| `@openuidev/react-ui/` | Per-component entry points |
diff --git a/packages/react-ui/check-css-artifacts.js b/packages/react-ui/check-css-artifacts.js
new file mode 100644
index 000000000..1f972fb88
--- /dev/null
+++ b/packages/react-ui/check-css-artifacts.js
@@ -0,0 +1,74 @@
+// Pre-publish guard for the CSS artifact contract:
+// - default exports stay UNLAYERED (./components.css, ./styles/*)
+// - the layered mirror is wrapped in @layer openui and BOM-free
+// - openui-defaults.css is unlayered in both trees (runtime theming contract)
+// Born out of the 2026-06 BOM incident: a U+FEFF pushed inside the layer
+// block silently killed the :root theme tokens in the packed tarball.
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const dirname = path.dirname(fileURLToPath(import.meta.url));
+const dist = path.join(dirname, "dist");
+const failures = [];
+
+const read = (rel) => fs.readFileSync(path.join(dist, rel), "utf8");
+const assert = (cond, msg) => {
+ if (!cond) failures.push(msg);
+};
+
+assert(
+ !/^\s*@layer/.test(read("components/index.css")),
+ "components/index.css must stay unlayered",
+);
+assert(!/^\s*@layer/.test(read("styles/index.css")), "styles/index.css must stay unlayered");
+assert(
+ read("layered/components/index.css").startsWith("@layer openui{"),
+ "layered/components/index.css must start with @layer openui{",
+);
+assert(
+ !/^\s*@layer/.test(read("styles/openui-defaults.css")),
+ "styles/openui-defaults.css must stay unlayered",
+);
+assert(
+ !/^\s*@layer/.test(read("layered/styles/openui-defaults.css")),
+ "layered/styles/openui-defaults.css must stay unlayered",
+);
+
+const unlayered = fs.readdirSync(path.join(dist, "styles")).filter((f) => f.endsWith(".css"));
+const layered = fs
+ .readdirSync(path.join(dist, "layered", "styles"))
+ .filter((f) => f.endsWith(".css"));
+assert(
+ unlayered.length === layered.length,
+ `layered mirror has ${layered.length} css files, unlayered has ${unlayered.length}`,
+);
+
+// Every per-component DEFAULT style must stay unlayered too — not just the
+// index files checked above. Guards against a regression that re-wraps
+// dist/styles/*.css in place (the `wrapComponentCssInPlace` behavior this
+// contract intentionally removed); since consumers can import individual
+// ./styles/.css, an index-only check would miss it.
+// openui-defaults.css is asserted unlayered separately above.
+for (const name of unlayered) {
+ if (name === "openui-defaults.css") continue;
+ assert(!/^\s*@layer/.test(read(path.join("styles", name))), `styles/${name} must stay unlayered`);
+}
+
+for (const f of [
+ ...layered.map((n) => path.join("layered", "styles", n)),
+ "layered/components/index.css",
+]) {
+ const content = read(f);
+ assert(!content.includes("\uFEFF"), `${f} contains a BOM`);
+ const base = path.basename(f);
+ if (base !== "openui-defaults.css" && content.trim() !== "") {
+ assert(content.startsWith("@layer openui{"), `${f} is not wrapped in @layer openui`);
+ }
+}
+
+if (failures.length > 0) {
+ console.error("CSS artifact check FAILED:\n - " + failures.join("\n - "));
+ process.exit(1);
+}
+console.log(`CSS artifact check passed (${layered.length} layered files verified).`);
diff --git a/packages/react-ui/cp-css.js b/packages/react-ui/cp-css.js
index dc8f9b78a..2c645f860 100644
--- a/packages/react-ui/cp-css.js
+++ b/packages/react-ui/cp-css.js
@@ -2,6 +2,7 @@ import fs from "fs";
import { camelCase } from "lodash-es";
import path from "path";
import { fileURLToPath } from "url";
+import { mirrorStylesWithLayer, writeLayeredCopy } from "./css-layer-utils.mjs";
const dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -12,29 +13,6 @@ function ensureDirectoryExists(dirPath) {
}
}
-// Wrap a CSS file's contents in @layer openui { ... } if not already wrapped.
-// Idempotency check protects watch-mode and back-to-back builds.
-function wrapInLayer(content) {
- if (content.trim() === "") return content;
- if (/^\s*@layer\s+openui\b/.test(content)) return content;
- return `@layer openui{${content}}`;
-}
-
-// Walk dist/components and wrap every emitted .css file in @layer openui.
-// *.module.css are Storybook CSS Modules — locally scoped, not shipped, not wrapped.
-function wrapComponentCssInPlace(dir) {
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
- const full = path.join(dir, entry.name);
- if (entry.isDirectory()) {
- wrapComponentCssInPlace(full);
- } else if (entry.name.endsWith(".css") && !entry.name.endsWith(".module.css")) {
- const content = fs.readFileSync(full, "utf8");
- const wrapped = wrapInLayer(content);
- if (wrapped !== content) fs.writeFileSync(full, wrapped, "utf8");
- }
- }
-}
-
// Replace .scss imports with .css imports in compiled JS files
function fixScssImportsInJs(dir) {
const entries = fs.readdirSync(dir);
@@ -60,12 +38,6 @@ function copyCssFiles() {
const srcDir = path.join(dirname, "dist", "components");
const distDir = path.join(dirname, "dist", "styles");
- // Wrap every emitted component CSS in @layer openui before copying.
- // dist/openui-defaults.css lives outside dist/components and stays unwrapped
- // so the defaults.css export remains in the unlayered cascade — matching the
- // ThemeProvider runtime injection contract.
- wrapComponentCssInPlace(srcDir);
-
// Ensure the dist/styles directory exists
ensureDirectoryExists(distDir);
@@ -101,6 +73,15 @@ function copyCssFiles() {
fs.copyFileSync(defaultsCssPath, path.join(distDir, "openui-defaults.css"));
}
+ // Emit the opt-in layered mirror (./layered-components.css and
+ // ./layered/styles/*). The default exports above stay unlayered — see
+ // README "Styling integration".
+ writeLayeredCopy(
+ path.join(srcDir, "index.css"),
+ path.join(dirname, "dist", "layered", "components", "index.css"),
+ );
+ mirrorStylesWithLayer(distDir, path.join(dirname, "dist", "layered", "styles"));
+
// Fix .scss imports in compiled JS to point to .css files instead
fixScssImportsInJs(path.join(dirname, "dist"));
}
diff --git a/packages/react-ui/css-layer-utils.mjs b/packages/react-ui/css-layer-utils.mjs
new file mode 100644
index 000000000..a4799c68d
--- /dev/null
+++ b/packages/react-ui/css-layer-utils.mjs
@@ -0,0 +1,40 @@
+import fs from "fs";
+import path from "path";
+
+// Wrap a CSS file's contents in @layer openui { ... } if not already wrapped.
+// Idempotency check protects watch-mode and back-to-back builds.
+export function wrapInLayer(content) {
+ // Sass emits a UTF-8 BOM for files with non-ASCII output. At byte 0 the
+ // decoder strips it, but wrapping would push it inside the layer block,
+ // where U+FEFF parses as an identifier and kills the first rule
+ // (e.g. the :root theme tokens). Strip it before wrapping.
+ content = content.replace(/^\uFEFF/, "");
+ if (content.trim() === "") return content;
+ if (/^\s*@layer\s+openui\b/.test(content)) return content;
+ return `@layer openui{${content}}`;
+}
+
+// Write a layered copy of srcFile at destFile, creating parent directories.
+export function writeLayeredCopy(srcFile, destFile) {
+ const content = fs.readFileSync(srcFile, "utf8");
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
+ fs.writeFileSync(destFile, wrapInLayer(content), "utf8");
+}
+
+// Mirror every top-level *.css file in srcDir into destDir wrapped in
+// @layer openui. Files named in `unwrapped` are copied verbatim — they must
+// stay in the unlayered cascade (openui-defaults.css backs the runtime
+// theming override contract). Non-CSS files (e.g. cssUtils.scss) are skipped.
+export function mirrorStylesWithLayer(srcDir, destDir, unwrapped = ["openui-defaults.css"]) {
+ fs.mkdirSync(destDir, { recursive: true });
+ for (const name of fs.readdirSync(srcDir)) {
+ if (!name.endsWith(".css")) continue;
+ const src = path.join(srcDir, name);
+ if (!fs.statSync(src).isFile()) continue;
+ if (unwrapped.includes(name)) {
+ fs.copyFileSync(src, path.join(destDir, name));
+ } else {
+ writeLayeredCopy(src, path.join(destDir, name));
+ }
+ }
+}
diff --git a/packages/react-ui/css-layer-utils.test.mjs b/packages/react-ui/css-layer-utils.test.mjs
new file mode 100644
index 000000000..fa4e4be63
--- /dev/null
+++ b/packages/react-ui/css-layer-utils.test.mjs
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import fs from "fs";
+import os from "os";
+import path from "path";
+import { mirrorStylesWithLayer, wrapInLayer, writeLayeredCopy } from "./css-layer-utils.mjs";
+
+describe("wrapInLayer", () => {
+ it("wraps plain css in @layer openui", () => {
+ expect(wrapInLayer(".a{color:red}")).toBe("@layer openui{.a{color:red}}");
+ });
+
+ it("strips a leading BOM before wrapping so the first rule stays valid", () => {
+ // U+FEFF inside a layer block parses as an identifier and kills the
+ // first rule (e.g. the :root theme tokens) — the 2026-06 BOM incident.
+ expect(wrapInLayer("\uFEFF:root{--x:1}")).toBe("@layer openui{:root{--x:1}}");
+ });
+
+ it("is idempotent", () => {
+ const once = wrapInLayer(".a{color:red}");
+ expect(wrapInLayer(once)).toBe(once);
+ });
+
+ it("leaves empty/whitespace-only content untouched", () => {
+ expect(wrapInLayer("")).toBe("");
+ expect(wrapInLayer(" \n")).toBe(" \n");
+ });
+});
+
+describe("writeLayeredCopy", () => {
+ it("writes a wrapped copy, creating parent directories", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "css-layer-"));
+ const src = path.join(dir, "in.css");
+ const dest = path.join(dir, "nested", "out.css");
+ fs.writeFileSync(src, ".a{color:red}");
+ writeLayeredCopy(src, dest);
+ expect(fs.readFileSync(dest, "utf8")).toBe("@layer openui{.a{color:red}}");
+ });
+});
+
+describe("mirrorStylesWithLayer", () => {
+ it("wraps css files, copies unwrapped names verbatim, skips non-css", () => {
+ const src = fs.mkdtempSync(path.join(os.tmpdir(), "css-layer-src-"));
+ const dest = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "css-layer-dest-")), "layered");
+ fs.writeFileSync(path.join(src, "button.css"), ".b{color:red}");
+ fs.writeFileSync(path.join(src, "openui-defaults.css"), ":root{--x:1}");
+ fs.writeFileSync(path.join(src, "cssUtils.scss"), "$x: 1;");
+ mirrorStylesWithLayer(src, dest);
+ expect(fs.readFileSync(path.join(dest, "button.css"), "utf8")).toBe("@layer openui{.b{color:red}}");
+ expect(fs.readFileSync(path.join(dest, "openui-defaults.css"), "utf8")).toBe(":root{--x:1}");
+ expect(fs.existsSync(path.join(dest, "cssUtils.scss"))).toBe(false);
+ });
+});
diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json
index 9c3889a47..937012550 100644
--- a/packages/react-ui/package.json
+++ b/packages/react-ui/package.json
@@ -21,12 +21,18 @@
"./components.css": {
"default": "./dist/components/index.css"
},
+ "./layered-components.css": {
+ "default": "./dist/layered/components/index.css"
+ },
"./defaults.css": {
"default": "./dist/styles/openui-defaults.css"
},
"./styles/*": {
"default": "./dist/styles/*"
},
+ "./layered/styles/*": {
+ "default": "./dist/layered/styles/*"
+ },
"./genui-lib": {
"import": {
"types": "./dist/genui-lib/index.d.mts",
@@ -60,11 +66,11 @@
"README.md"
],
"scripts": {
- "test": "vitest run --passWithNoTests",
+ "test": "vitest run",
"copy-css": "node cp-css.js",
"generate-scss-index": "node src/scripts/scss-import.js",
"generate:css-utils": "tsx src/scripts/generate-css-utils.ts",
- "build": "rimraf dist && pnpm generate:css-utils && pnpm build:scss && pnpm build:tsc && pnpm build:cjs && pnpm run copy-css",
+ "build": "rimraf dist && pnpm generate:css-utils && pnpm build:scss && pnpm build:tsc && pnpm build:cjs && pnpm run copy-css && pnpm run check:css",
"typecheck": "tsc --noEmit",
"build:tsc": "tsc -p . || node -e \"process.exit(0)\"",
"build:cjs": "tsdown",
@@ -76,11 +82,12 @@
"lint:fix": "eslint ./src --fix",
"format:fix": "prettier --write ./src",
"format:check": "prettier --check ./src",
+ "check:css": "node check-css-artifacts.js",
"check:publint": "publint",
"check:attw": "attw --pack . --ignore-rules no-resolution",
"prepare": "pnpm run build",
- "prepublishOnly": "pnpm run check:publint && pnpm run check:attw",
- "ci": "pnpm run lint:check && pnpm run format:check"
+ "prepublishOnly": "pnpm run check:css && pnpm run check:publint && pnpm run check:attw",
+ "ci": "pnpm run lint:check && pnpm run format:check && pnpm run test"
},
"peerDependencies": {
"@openuidev/react-headless": "workspace:^",
@@ -196,7 +203,6 @@
"bugs": {
"url": "https://github.com/thesysdev/openui/issues"
},
- "browserslist": "defaults and supports css-cascade-layers",
"eslintConfig": {
"extends": [
"plugin:storybook/recommended"
diff --git a/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx b/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx
index 424fab01a..d9e54675d 100644
--- a/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx
+++ b/packages/react-ui/src/components/ThemeProvider/ThemeProvider.tsx
@@ -236,8 +236,9 @@ export const ThemeProvider = ({
const useAutoScope = isNested && !hasExplicitSelector;
const styleSelector = useAutoScope ? `.${scopedClassName}` : effectiveCssSelector;
- // Intentionally unlayered — must override @layer openui so runtime theme
- // switching takes effect. See README "Styling integration" before changing.
+ // Intentionally unlayered — must override component styles in both modes,
+ // including when consumers opt into layered-components.css (@layer openui),
+ // so runtime theming always wins. See README "Styling integration" before changing.
useInsertionEffect(() => {
const style = document.createElement("style");
style.setAttribute("data-openui-theme", id);