diff --git a/CLAUDE.md b/CLAUDE.md
index 229f5f2989..9ce8fb6110 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -67,6 +67,10 @@ tests/visual/ Visual regression tests (Playwright + R2 baselines)
| Visual regression tests | `tests/visual/` (see its CLAUDE.md) |
| Document API contract | `packages/document-api/src/contract/operation-definitions.ts` |
| Adding a doc-api operation | See `packages/document-api/README.md` § "Adding a new operation" |
+| Theming (`createTheme()`) | `packages/superdoc/src/core/theme/create-theme.js` |
+| CSS variable defaults | `packages/superdoc/src/assets/styles/helpers/variables.css` |
+| Preset themes | `packages/superdoc/src/assets/styles/helpers/themes.css` |
+| Consumer-facing agent guide | `packages/superdoc/AGENTS.md` (ships with npm package) |
## Style Resolution Boundary
diff --git a/apps/docs/getting-started/theming.mdx b/apps/docs/getting-started/theming.mdx
index 3a46855e8e..98500f1949 100644
--- a/apps/docs/getting-started/theming.mdx
+++ b/apps/docs/getting-started/theming.mdx
@@ -1,32 +1,77 @@
---
title: Theming
sidebarTitle: Theming
-keywords: "theming, css variables, custom theme, dark mode, design tokens, branding, css customization, styling"
+keywords: "theming, css variables, custom theme, dark mode, design tokens, branding, css customization, createTheme, styling"
---
-Change five CSS variables and your brand colors flow through every SuperDoc component — toolbar, comments, dropdowns, everything. No JavaScript needed.
+Set your brand colors in JavaScript and every SuperDoc component updates — toolbar, comments, dropdowns, everything.
-```css
-.my-theme {
- --sd-ui-action: #6366f1;
- --sd-ui-action-hover: #4f46e5;
- --sd-ui-toolbar-bg: #f8fafc;
- --sd-ui-bg: #ffffff;
- --sd-ui-text: #1e293b;
-}
+```javascript
+import { createTheme } from 'superdoc';
+
+const theme = createTheme({
+ colors: { action: '#6366f1', bg: '#ffffff', text: '#1e293b', border: '#e2e8f0' },
+ font: 'Inter, sans-serif',
+});
+
+document.documentElement.classList.add(theme);
```
-```html
-
-
-
+Five properties theme the entire UI.
+
+## `createTheme()`
+
+Pass a config object, get back a CSS class name. Apply it to ``.
+
+```javascript
+import { createTheme } from 'superdoc';
+
+const theme = createTheme({
+ name: 'my-brand', // optional — generates sd-theme-my-brand
+ font: 'Inter, sans-serif',
+ radius: '8px',
+ colors: {
+ action: '#6366f1', // buttons, links, active states
+ actionHover: '#4f46e5',
+ bg: '#ffffff', // panels, cards, dropdowns
+ text: '#1e293b', // primary text
+ textMuted: '#64748b', // secondary text
+ border: '#e2e8f0', // all borders
+ },
+ // Escape hatch — any CSS variable
+ vars: {
+ '--sd-ui-toolbar-bg': '#f8fafc',
+ '--sd-ui-comments-card-bg': '#f0f0ff',
+ },
+});
+
+document.documentElement.classList.add(theme);
```
-Override any `--sd-*` CSS custom property on the `` element and the changes cascade to all components.
+`colors` controls the semantic tier — every component inherits from it. The `vars` escape hatch lets you set any `--sd-*` CSS variable directly for fine-grained control.
+
+## SSR support
+
+Use `buildTheme()` to get the raw CSS string for server-side rendering:
+
+```javascript
+import { buildTheme } from 'superdoc';
+
+const { className, css } = buildTheme({
+ colors: { action: '#6366f1', bg: '#ffffff', text: '#1e293b' },
+});
+
+const html = `
+
+
+
+
+`;
+```
## Preset themes
-Three preset themes ship out of the box. Add the class to `` — some SuperDoc elements (popovers, dropdowns) are appended to ``, so they need to inherit from `` to pick up the theme.
+Three presets ship out of the box. Add the class to `` — some SuperDoc elements (popovers, dropdowns) are appended to ``, so they need to inherit from ``.
@@ -49,55 +94,21 @@ Three preset themes ship out of the box. Add the class to `` — some Supe
-You can layer your own overrides on top of a preset:
+## CSS variables (advanced)
+
+`createTheme()` generates CSS variables under the hood. You can also set them directly:
```css
-.sd-theme-docs {
- --sd-ui-action: #your-brand-color;
- --sd-ui-comments-card-bg: #f0f0f0;
+:root {
+ --sd-ui-action: #6366f1;
+ --sd-ui-bg: #ffffff;
+ --sd-ui-text: #1e293b;
+ --sd-ui-border: #e2e8f0;
+ --sd-ui-font-family: Inter, sans-serif;
}
```
-## How the token system works
-
-Variables are organized in three tiers. Higher tiers reference lower tiers, so changing a primitive cascades everywhere.
-
-```
-Primitives UI semantic Component-specific
---sd-color-* --sd-ui-text --sd-ui-toolbar-*
---sd-font-size-* --sd-ui-bg --sd-ui-comments-*
---sd-radius-* --sd-ui-action --sd-ui-dropdown-*
-```
-
-**Tier 1 — Primitives.** Raw color scales, font sizes, and radii. You rarely need to touch these directly.
-
-**Tier 2 — UI semantic.** The "5-variable theme." Change these and every component updates:
-
-| Variable | Default | What it controls |
-|----------|---------|-----------------|
-| `--sd-ui-font-family` | `Arial, Helvetica, sans-serif` | Font for all UI chrome |
-| `--sd-ui-text` | `#47484a` | Primary text color |
-| `--sd-ui-bg` | `#ffffff` | Panels, cards, dropdowns |
-| `--sd-ui-border` | `#dbdbdb` | All borders |
-| `--sd-ui-action` | `#1355ff` | Buttons, links, active states |
-
-**Tier 3 — Component-specific.** Fine-grained overrides for individual components. These default to the semantic tier, so you only set them when a component needs to look different from the rest. See the [full reference](/guides/general/custom-themes).
-
-## JavaScript config
-
-The `trackChangesConfig` and `commentsConfig` JavaScript options still work. They take priority over CSS variables when set. Use the JS API for runtime changes, CSS variables for static theming.
-
-```javascript
-new SuperDoc({
- selector: '#editor',
- commentsConfig: {
- highlightHoverColor: '#ff000055',
- trackChangeHighlightColors: {
- insertBorder: '#00ff00',
- },
- },
-});
-```
+See the [full variable reference](/guides/general/custom-themes#variable-reference) for every token.
## Next steps
@@ -107,7 +118,7 @@ new SuperDoc({
icon="palette"
href="/guides/general/custom-themes"
>
- Full variable reference by component. Dark theme starter. How to build your own preset.
+ Full API reference, dark theme starter, all CSS variables by component.
` element.
-
-```css
-.my-company-theme {
- /* These cascade to every component */
- --sd-ui-action: #8b5cf6;
- --sd-ui-action-hover: #7c3aed;
- --sd-ui-text: #1e293b;
- --sd-ui-bg: #f8fafc;
- --sd-ui-border: #e2e8f0;
-
- /* Fine-tune where needed */
- --sd-ui-toolbar-bg: #f1f5f9;
- --sd-ui-comments-card-bg: #f0f0ff;
-}
+Full `createTheme()` API reference and CSS variable catalog. Start with the [theming overview](/getting-started/theming) if you're new to theming.
+
+## `createTheme()` options
+
+```javascript
+import { createTheme } from 'superdoc';
+
+const theme = createTheme({
+ name: 'my-brand',
+ font: 'Inter, sans-serif',
+ radius: '8px',
+ shadow: '0 2px 8px rgba(0,0,0,0.1)',
+ colors: {
+ action: '#6366f1', // buttons, links, active states
+ actionHover: '#4f46e5',
+ bg: '#ffffff', // panels, cards, dropdowns
+ hoverBg: '#f1f5f9', // hover backgrounds
+ activeBg: '#e2e8f0', // active/pressed backgrounds
+ disabledBg: '#f5f5f5',
+ text: '#1e293b', // primary text
+ textMuted: '#64748b', // secondary text
+ textDisabled: '#94a3b8',
+ border: '#e2e8f0',
+ },
+ // Escape hatch — any --sd-* CSS variable
+ vars: {
+ '--sd-ui-toolbar-bg': '#f8fafc',
+ '--sd-ui-comments-card-bg': '#f0f0ff',
+ },
+});
+
+document.documentElement.classList.add(theme);
```
-Start with the 5-10 semantic variables. Add component-specific ones only where the defaults don't look right.
+Start with `colors` only. Use `vars` when a specific component needs to look different.
-## Dark theme starter
+## Dark theme example
+
+```javascript
+import { createTheme } from 'superdoc';
+
+const dark = createTheme({
+ name: 'dark',
+ colors: {
+ bg: '#1a1a2e',
+ hoverBg: '#2a2a3e',
+ activeBg: '#3a3a4e',
+ text: '#e2e8f0',
+ textMuted: '#94a3b8',
+ border: '#334155',
+ action: '#60a5fa',
+ actionHover: '#93c5fd',
+ },
+ vars: {
+ '--sd-ui-toolbar-bg': '#0f172a',
+ '--sd-ui-toolbar-button-text': '#e2e8f0',
+ '--sd-ui-comments-card-bg': '#1e293b',
+ '--sd-ui-comments-body-text': '#cbd5e1',
+ },
+});
+
+document.documentElement.classList.add(dark);
+```
+
+## Raw CSS alternative
-Override the core surface and text variables. Component backgrounds cascade from `--sd-ui-bg` automatically.
+You can also write CSS directly. Apply overrides to ``.
```css
.dark-theme {
diff --git a/examples/features/theming/index.html b/examples/features/theming/index.html
new file mode 100644
index 0000000000..8f513da037
--- /dev/null
+++ b/examples/features/theming/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ SuperDoc — Theming
+
+
+
+
+
+
+
diff --git a/examples/features/theming/package.json b/examples/features/theming/package.json
new file mode 100644
index 0000000000..ff5a364c98
--- /dev/null
+++ b/examples/features/theming/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "superdoc-theming-example",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build"
+ },
+ "dependencies": {
+ "react": "^19.2.1",
+ "react-dom": "^19.2.1",
+ "superdoc": "latest"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.2",
+ "typescript": "^5.9.3",
+ "vite": "^7.2.7"
+ }
+}
diff --git a/examples/features/theming/src/App.tsx b/examples/features/theming/src/App.tsx
new file mode 100644
index 0000000000..33e2b9e2d2
--- /dev/null
+++ b/examples/features/theming/src/App.tsx
@@ -0,0 +1,99 @@
+import { useEffect, useRef, useState } from 'react';
+import { SuperDoc } from 'superdoc';
+import 'superdoc/style.css';
+import { themes, presets, themeLabels, type ThemeKey } from './themes';
+
+const allThemeClasses = [
+ ...Object.values(themes).filter(Boolean),
+ ...Object.values(presets),
+] as string[];
+
+function applyTheme(key: ThemeKey) {
+ const html = document.documentElement;
+ allThemeClasses.forEach((cls) => html.classList.remove(cls));
+
+ if (key === 'default') return;
+ const cls = (presets as any)[key] ?? (themes as any)[key];
+ if (cls) html.classList.add(cls);
+}
+
+export default function App() {
+ const [file, setFile] = useState(null);
+ const [theme, setTheme] = useState('default');
+ const containerRef = useRef(null);
+ const superdocRef = useRef(null);
+
+ useEffect(() => {
+ applyTheme(theme);
+ }, [theme]);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ superdocRef.current?.destroy();
+ superdocRef.current = new SuperDoc({
+ selector: containerRef.current,
+ document: file ?? undefined,
+ documentMode: 'editing',
+ user: { name: 'Jane Doe', email: 'jane@example.com' },
+ modules: { toolbar: true },
+ });
+
+ return () => {
+ superdocRef.current?.destroy();
+ superdocRef.current = null;
+ };
+ }, [file]);
+
+ return (
+
+ );
+}
diff --git a/examples/features/theming/src/main.tsx b/examples/features/theming/src/main.tsx
new file mode 100644
index 0000000000..c018515cd7
--- /dev/null
+++ b/examples/features/theming/src/main.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/examples/features/theming/src/themes.ts b/examples/features/theming/src/themes.ts
new file mode 100644
index 0000000000..c0a71c23bf
--- /dev/null
+++ b/examples/features/theming/src/themes.ts
@@ -0,0 +1,91 @@
+import { createTheme } from 'superdoc';
+
+export const themes = {
+ default: null,
+
+ indigo: createTheme({
+ name: 'indigo',
+ font: 'Inter, system-ui, sans-serif',
+ radius: '8px',
+ colors: {
+ action: '#6366f1',
+ actionHover: '#4f46e5',
+ bg: '#ffffff',
+ text: '#1e293b',
+ textMuted: '#64748b',
+ border: '#e2e8f0',
+ hoverBg: '#f1f5f9',
+ },
+ vars: {
+ '--sd-ui-toolbar-bg': '#f8fafc',
+ },
+ }),
+
+ dark: createTheme({
+ name: 'dark',
+ font: 'Inter, system-ui, sans-serif',
+ colors: {
+ bg: '#1a1a2e',
+ hoverBg: '#2a2a3e',
+ activeBg: '#3a3a4e',
+ text: '#e2e8f0',
+ textMuted: '#94a3b8',
+ textDisabled: '#64748b',
+ border: '#334155',
+ action: '#60a5fa',
+ actionHover: '#93c5fd',
+ },
+ vars: {
+ '--sd-ui-toolbar-bg': '#0f172a',
+ '--sd-ui-toolbar-button-text': '#e2e8f0',
+ '--sd-ui-dropdown-bg': '#1e293b',
+ '--sd-ui-dropdown-border': '#334155',
+ '--sd-ui-menu-bg': '#1e293b',
+ '--sd-ui-menu-border': '#334155',
+ '--sd-ui-comments-card-bg': '#1e293b',
+ '--sd-ui-comments-input-bg': '#1e293b',
+ '--sd-ui-comments-body-text': '#cbd5e1',
+ '--sd-ui-tooltip-bg': '#f1f5f9',
+ '--sd-ui-tooltip-text': '#1e293b',
+ '--sd-layout-page-shadow': '0 4px 20px rgba(0, 0, 0, 0.4)',
+ },
+ }),
+
+ warm: createTheme({
+ name: 'warm',
+ font: 'Georgia, serif',
+ radius: '4px',
+ colors: {
+ action: '#b45309',
+ actionHover: '#92400e',
+ bg: '#fffbeb',
+ hoverBg: '#fef3c7',
+ activeBg: '#fde68a',
+ text: '#451a03',
+ textMuted: '#78350f',
+ border: '#d97706',
+ },
+ vars: {
+ '--sd-ui-toolbar-bg': '#fef3c7',
+ '--sd-ui-comments-card-bg': '#fef9e7',
+ },
+ }),
+} as const;
+
+export const presets = {
+ 'preset-docs': 'sd-theme-docs',
+ 'preset-word': 'sd-theme-word',
+ 'preset-blueprint': 'sd-theme-blueprint',
+} as const;
+
+export type ThemeKey = keyof typeof themes | keyof typeof presets;
+
+export const themeLabels: Record = {
+ default: 'Default',
+ indigo: 'Indigo Brand',
+ dark: 'Dark',
+ warm: 'Warm Earth',
+ 'preset-docs': 'Preset: Docs',
+ 'preset-word': 'Preset: Word',
+ 'preset-blueprint': 'Preset: Blueprint',
+};
diff --git a/examples/features/theming/vite.config.ts b/examples/features/theming/vite.config.ts
new file mode 100644
index 0000000000..0466183af6
--- /dev/null
+++ b/examples/features/theming/vite.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+});
diff --git a/examples/getting-started/laravel/package.json b/examples/getting-started/laravel/package.json
index 5ced1f78dc..d42caa20b7 100644
--- a/examples/getting-started/laravel/package.json
+++ b/examples/getting-started/laravel/package.json
@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"build": "vite build",
- "dev": "concurrently \"php artisan serve\" \"vite\""
+ "dev": "concurrently \"php artisan serve --host=0.0.0.0 --port=8000\" \"vite\""
},
"dependencies": {
"concurrently": "^9.0.0",
diff --git a/packages/superdoc/CLAUDE.md b/packages/superdoc/CLAUDE.md
index c0a84e3656..1c6b28c204 100644
--- a/packages/superdoc/CLAUDE.md
+++ b/packages/superdoc/CLAUDE.md
@@ -55,6 +55,28 @@ Uses `layout-engine` for virtualized rendering with pagination.
`PresentationEditor.ts` bridges state between modes.
See `super-editor/src/core/presentation-editor/` for implementation.
+## Theming
+
+SuperDoc UI is themed via `--sd-*` CSS variables. Use `createTheme()` for JS-based theming or set variables directly in CSS.
+
+```javascript
+import { createTheme } from 'superdoc';
+
+const theme = createTheme({
+ colors: { action: '#6366f1', bg: '#ffffff', text: '#1e293b', border: '#e2e8f0' },
+ font: 'Inter, sans-serif',
+ vars: { '--sd-ui-toolbar-bg': '#f8fafc' }, // escape hatch for any --sd-* variable
+});
+
+document.documentElement.classList.add(theme);
+```
+
+- `createTheme()` / `buildTheme()` — `src/core/theme/create-theme.js`
+- CSS variable defaults — `src/assets/styles/helpers/variables.css`
+- Preset themes — `src/assets/styles/helpers/themes.css`
+- Backward-compat aliases — `src/assets/styles/helpers/compat.css`
+- Consumer-facing agent guide — `AGENTS.md` (ships with npm package)
+
## Testing
- Unit tests: `src/SuperDoc.test.js`
diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json
index 82f14d5313..1655b2bf29 100644
--- a/packages/superdoc/package.json
+++ b/packages/superdoc/package.json
@@ -14,7 +14,8 @@
},
"readme": "../../README.md",
"files": [
- "dist"
+ "dist",
+ "AGENTS.md"
],
"exports": {
".": {
diff --git a/packages/superdoc/src/core/theme/create-theme.test.js b/packages/superdoc/src/core/theme/create-theme.test.js
new file mode 100644
index 0000000000..73fe1d89a4
--- /dev/null
+++ b/packages/superdoc/src/core/theme/create-theme.test.js
@@ -0,0 +1,166 @@
+import { describe, expect, it, beforeEach } from 'vitest';
+import { createTheme, buildTheme } from './create-theme.ts';
+
+describe('createTheme', () => {
+ beforeEach(() => {
+ document.querySelectorAll('[data-sd-theme]').forEach((el) => el.remove());
+ });
+
+ it('returns a class name with sd-theme- prefix', () => {
+ const cls = createTheme({ colors: { action: '#ff0000' } });
+ expect(cls).toMatch(/^sd-theme-/);
+ });
+
+ it('uses the provided name', () => {
+ expect(createTheme({ name: 'dark', colors: { bg: '#000' } })).toBe('sd-theme-dark');
+ });
+
+ it('generates unique names when no name provided', () => {
+ const a = createTheme({ colors: { action: '#111' } });
+ const b = createTheme({ colors: { action: '#222' } });
+ expect(a).not.toBe(b);
+ });
+
+ describe('color mapping', () => {
+ it('maps all color properties to CSS variables', () => {
+ const { css } = buildTheme({
+ name: 'colors-test',
+ colors: {
+ action: '#6366f1',
+ actionHover: '#4f46e5',
+ bg: '#ffffff',
+ hoverBg: '#f1f5f9',
+ activeBg: '#e2e8f0',
+ disabledBg: '#f5f5f5',
+ text: '#1e293b',
+ textMuted: '#64748b',
+ textDisabled: '#94a3b8',
+ border: '#e2e8f0',
+ },
+ });
+ expect(css).toContain('--sd-ui-action: #6366f1');
+ expect(css).toContain('--sd-ui-action-hover: #4f46e5');
+ expect(css).toContain('--sd-ui-bg: #ffffff');
+ expect(css).toContain('--sd-ui-hover-bg: #f1f5f9');
+ expect(css).toContain('--sd-ui-active-bg: #e2e8f0');
+ expect(css).toContain('--sd-ui-disabled-bg: #f5f5f5');
+ expect(css).toContain('--sd-ui-text: #1e293b');
+ expect(css).toContain('--sd-ui-text-muted: #64748b');
+ expect(css).toContain('--sd-ui-text-disabled: #94a3b8');
+ expect(css).toContain('--sd-ui-border: #e2e8f0');
+ });
+
+ it('ignores null and undefined color values', () => {
+ const { css } = buildTheme({
+ name: 'null-test',
+ colors: { action: '#ff0000', bg: undefined, text: null },
+ });
+ expect(css).toContain('--sd-ui-action: #ff0000');
+ expect(css).not.toContain('--sd-ui-bg');
+ expect(css).not.toContain('--sd-ui-text');
+ });
+
+ it('ignores unknown color keys', () => {
+ const { css } = buildTheme({
+ name: 'unknown-test',
+ colors: { action: '#ff0000', notAColor: '#000' },
+ });
+ expect(css).toContain('--sd-ui-action');
+ expect(css).not.toContain('notAColor');
+ });
+ });
+
+ describe('top-level shortcuts', () => {
+ it('maps font to --sd-ui-font-family', () => {
+ const { css } = buildTheme({ name: 'font-test', font: 'Inter, sans-serif' });
+ expect(css).toContain('--sd-ui-font-family: Inter, sans-serif');
+ });
+
+ it('maps radius to --sd-ui-radius', () => {
+ const { css } = buildTheme({ name: 'radius-test', radius: '8px' });
+ expect(css).toContain('--sd-ui-radius: 8px');
+ });
+
+ it('maps shadow to --sd-ui-shadow', () => {
+ const { css } = buildTheme({ name: 'shadow-test', shadow: '0 2px 8px rgba(0,0,0,0.1)' });
+ expect(css).toContain('--sd-ui-shadow: 0 2px 8px rgba(0,0,0,0.1)');
+ });
+ });
+
+ describe('vars escape hatch', () => {
+ it('spreads raw CSS variable overrides', () => {
+ const { css } = buildTheme({
+ name: 'vars-test',
+ vars: {
+ '--sd-ui-toolbar-bg': '#f8fafc',
+ '--sd-ui-comments-card-bg': '#f0f0ff',
+ },
+ });
+ expect(css).toContain('--sd-ui-toolbar-bg: #f8fafc');
+ expect(css).toContain('--sd-ui-comments-card-bg: #f0f0ff');
+ });
+
+ it('ignores null vars', () => {
+ const { css } = buildTheme({
+ name: 'vars-null',
+ vars: { '--sd-ui-toolbar-bg': '#fff', '--sd-ui-menu-bg': null },
+ });
+ expect(css).toContain('--sd-ui-toolbar-bg');
+ expect(css).not.toContain('--sd-ui-menu-bg');
+ });
+
+ it('combines colors and vars', () => {
+ const { css } = buildTheme({
+ name: 'combined',
+ colors: { action: '#6366f1' },
+ vars: { '--sd-ui-toolbar-bg': '#f8fafc' },
+ });
+ expect(css).toContain('--sd-ui-action: #6366f1');
+ expect(css).toContain('--sd-ui-toolbar-bg: #f8fafc');
+ });
+ });
+
+ describe('style injection', () => {
+ it('injects a style element into the document', () => {
+ const cls = createTheme({ name: 'inject', colors: { action: '#abc' } });
+ const style = document.querySelector(`[data-sd-theme="${cls}"]`);
+ expect(style).not.toBeNull();
+ expect(style.textContent).toContain('--sd-ui-action: #abc');
+ });
+
+ it('updates existing style element on re-call with same name', () => {
+ createTheme({ name: 'reuse', colors: { action: '#111' } });
+ createTheme({ name: 'reuse', colors: { action: '#222' } });
+ const styles = document.querySelectorAll('[data-sd-theme="sd-theme-reuse"]');
+ expect(styles.length).toBe(1);
+ expect(styles[0].textContent).toContain('#222');
+ });
+
+ it('returns class name even with empty config', () => {
+ const cls = createTheme({ name: 'empty' });
+ expect(cls).toBe('sd-theme-empty');
+ // No style element injected for empty config
+ expect(document.querySelector('[data-sd-theme="sd-theme-empty"]')).toBeNull();
+ });
+ });
+
+ describe('buildTheme', () => {
+ it('returns className and css', () => {
+ const result = buildTheme({ name: 'build', colors: { action: '#f00' } });
+ expect(result.className).toBe('sd-theme-build');
+ expect(result.css).toContain('.sd-theme-build');
+ expect(result.css).toContain('--sd-ui-action: #f00');
+ });
+
+ it('does not inject styles into the DOM', () => {
+ buildTheme({ name: 'no-inject', colors: { action: '#abc' } });
+ expect(document.querySelector('[data-sd-theme="sd-theme-no-inject"]')).toBeNull();
+ });
+
+ it('wraps css in the class selector', () => {
+ const { css } = buildTheme({ name: 'selector', colors: { bg: '#fff' } });
+ expect(css).toMatch(/^\.sd-theme-selector \{/);
+ expect(css).toMatch(/\}$/);
+ });
+ });
+});
diff --git a/packages/superdoc/src/core/theme/create-theme.ts b/packages/superdoc/src/core/theme/create-theme.ts
new file mode 100644
index 0000000000..3a10ac1fe6
--- /dev/null
+++ b/packages/superdoc/src/core/theme/create-theme.ts
@@ -0,0 +1,162 @@
+export interface ThemeColors {
+ /** Action/accent color (buttons, links, active states). Default: #1355ff */
+ action?: string;
+ /** Action hover state. Default: #0f44cc */
+ actionHover?: string;
+ /** Default background for panels, cards, dropdowns. Default: #ffffff */
+ bg?: string;
+ /** Hover background. Default: #dbdbdb */
+ hoverBg?: string;
+ /** Active/pressed background. Default: #c8d0d8 */
+ activeBg?: string;
+ /** Disabled background. Default: #f5f5f5 */
+ disabledBg?: string;
+ /** Primary text color. Default: #47484a */
+ text?: string;
+ /** Secondary/muted text. Default: #666666 */
+ textMuted?: string;
+ /** Disabled text. Default: #ababab */
+ textDisabled?: string;
+ /** Default border color. Default: #dbdbdb */
+ border?: string;
+}
+
+export interface ThemeConfig {
+ /** Theme name — used in the generated class name (e.g., "dark" → "sd-theme-dark") */
+ name?: string;
+ /** UI font family */
+ font?: string;
+ /** Default border radius (e.g., "8px") */
+ radius?: string;
+ /** Default box shadow */
+ shadow?: string;
+ /** Core color palette — cascades to every component */
+ colors?: ThemeColors;
+ /** Escape hatch — raw CSS variable overrides (e.g., { '--sd-ui-toolbar-bg': '#f8fafc' }) */
+ vars?: Record;
+}
+
+export interface ThemeResult {
+ className: string;
+ css: string;
+}
+
+/*
+ * These map to the --sd-ui-* variable names introduced in the SD-2083
+ * theming system. Components consume them once that PR lands. Until then,
+ * createTheme() generates the correct variables ahead of time.
+ */
+const COLORS_TO_VARS: Record = {
+ action: '--sd-ui-action',
+ actionHover: '--sd-ui-action-hover',
+ bg: '--sd-ui-bg',
+ hoverBg: '--sd-ui-hover-bg',
+ activeBg: '--sd-ui-active-bg',
+ disabledBg: '--sd-ui-disabled-bg',
+ text: '--sd-ui-text',
+ textMuted: '--sd-ui-text-muted',
+ textDisabled: '--sd-ui-text-disabled',
+ border: '--sd-ui-border',
+};
+
+let themeCounter = 0;
+
+/**
+ * Generate the className and CSS string from a theme config.
+ * Shared core used by both createTheme and buildTheme.
+ */
+function generateTheme(config: ThemeConfig): ThemeResult {
+ const { name, font, radius, shadow, colors, vars } = config;
+ const className = `sd-theme-${name || `custom-${++themeCounter}`}`;
+
+ const declarations: string[] = [];
+
+ if (colors) {
+ for (const [key, value] of Object.entries(colors)) {
+ if (value == null) continue;
+ const varName = COLORS_TO_VARS[key];
+ if (varName) {
+ declarations.push(` ${varName}: ${value};`);
+ }
+ }
+ }
+
+ if (font != null) declarations.push(` --sd-ui-font-family: ${font};`);
+ if (radius != null) declarations.push(` --sd-ui-radius: ${radius};`);
+ if (shadow != null) declarations.push(` --sd-ui-shadow: ${shadow};`);
+
+ if (vars) {
+ for (const [varName, value] of Object.entries(vars)) {
+ if (value == null) continue;
+ declarations.push(` ${varName}: ${value};`);
+ }
+ }
+
+ const css = declarations.length > 0 ? `.${className} {\n${declarations.join('\n')}\n}` : '';
+
+ return { className, css };
+}
+
+/**
+ * Inject a theme's CSS into the document as a `...`;
+ * ```
+ */
+export function buildTheme(config: ThemeConfig): ThemeResult {
+ return generateTheme(config);
+}
diff --git a/packages/superdoc/src/index.js b/packages/superdoc/src/index.js
index 34047a9504..1fa8493be2 100644
--- a/packages/superdoc/src/index.js
+++ b/packages/superdoc/src/index.js
@@ -17,6 +17,7 @@ import { getSchemaIntrospection } from './helpers/schema-introspection.js';
// Public exports
export { SuperDoc } from './core/SuperDoc.js';
+export { createTheme, buildTheme } from './core/theme/create-theme.ts';
export {
BlankDOCX,
getFileObject,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5372675ab2..3cf5dcee57 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -851,6 +851,34 @@ importers:
specifier: npm:rolldown-vite@7.3.1
version: rolldown-vite@7.3.1(@types/node@25.3.5)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)
+ examples/features/theming:
+ dependencies:
+ react:
+ specifier: ^19.2.1
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.2.1
+ version: 19.2.4(react@19.2.4)
+ superdoc:
+ specifier: latest
+ version: 1.19.1(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.7
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^5.1.2
+ version: 5.1.3(rolldown-vite@7.3.1(@types/node@25.3.5)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vite:
+ specifier: npm:rolldown-vite@7.3.1
+ version: rolldown-vite@7.3.1(@types/node@25.3.5)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)
+
examples/features/track-changes:
dependencies:
react:
@@ -16745,6 +16773,14 @@ packages:
y-prosemirror: ^1.3.7
yjs: 13.6.19
+ superdoc@1.19.1:
+ resolution: {integrity: sha512-RRX1TTzWS/7K7sXwLpscjPYjuos3FUZN2BsO5F0hj8BPP9He50cDEmv7zx2HsbmoX1Ugufe6lc3fp2gRRPTFxA==}
+ peerDependencies:
+ '@hocuspocus/provider': ^2.13.6
+ pdfjs-dist: ^5.4.296
+ y-prosemirror: ^1.3.7
+ yjs: 13.6.19
+
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
@@ -21005,10 +21041,6 @@ snapshots:
dependencies:
css-render: 0.15.14
- '@css-render/vue3-ssr@0.15.14(vue@3.5.25(typescript@5.5.4))':
- dependencies:
- vue: 3.5.25(typescript@5.5.4)
-
'@css-render/vue3-ssr@0.15.14(vue@3.5.25(typescript@5.9.3))':
dependencies:
vue: 3.5.25(typescript@5.9.3)
@@ -25512,7 +25544,7 @@ snapshots:
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
- superdoc: 1.18.2(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.5.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19)
+ superdoc: 1.19.1(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.5.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19)
transitivePeerDependencies:
- '@hocuspocus/provider'
- '@vue/composition-api'
@@ -25529,7 +25561,7 @@ snapshots:
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
- superdoc: 1.18.2(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19)
+ superdoc: 1.19.1(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19)
transitivePeerDependencies:
- '@hocuspocus/provider'
- '@vue/composition-api'
@@ -33552,29 +33584,6 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
- naive-ui@2.43.2(vue@3.5.25(typescript@5.5.4)):
- dependencies:
- '@css-render/plugin-bem': 0.15.14(css-render@0.15.14)
- '@css-render/vue3-ssr': 0.15.14(vue@3.5.25(typescript@5.5.4))
- '@types/katex': 0.16.8
- '@types/lodash': 4.17.23
- '@types/lodash-es': 4.17.12
- async-validator: 4.2.5
- css-render: 0.15.14
- csstype: 3.2.3
- date-fns: 4.1.0
- date-fns-tz: 3.2.0(date-fns@4.1.0)
- evtd: 0.2.4
- highlight.js: 11.11.1
- lodash: 4.17.23
- lodash-es: 4.17.23
- seemly: 0.3.10
- treemate: 0.3.11
- vdirs: 0.1.8(vue@3.5.25(typescript@5.5.4))
- vooks: 0.2.12(vue@3.5.25(typescript@5.5.4))
- vue: 3.5.25(typescript@5.5.4)
- vueuc: 0.4.65(vue@3.5.25(typescript@5.5.4))
-
naive-ui@2.43.2(vue@3.5.25(typescript@5.9.3)):
dependencies:
'@css-render/plugin-bem': 0.15.14(css-render@0.15.14)
@@ -37608,14 +37617,37 @@ snapshots:
- typescript
- utf-8-validate
- superdoc@1.18.2(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.5.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19):
+ superdoc@1.18.2(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19):
+ dependencies:
+ '@hocuspocus/provider': 2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19)
+ buffer-crc32: 1.0.0
+ eventemitter3: 5.0.4
+ jsdom: 27.3.0(canvas@3.2.1)
+ konva: 10.2.0
+ naive-ui: 2.43.2(vue@3.5.25(typescript@5.9.3))
+ pdfjs-dist: 5.4.624
+ pinia: 2.3.1(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3))
+ rollup-plugin-copy: 3.5.0
+ uuid: 9.0.1
+ vue: 3.5.25(typescript@5.9.3)
+ y-prosemirror: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19)
+ y-websocket: 3.0.0(yjs@13.6.19)
+ yjs: 13.6.19
+ transitivePeerDependencies:
+ - '@vue/composition-api'
+ - bufferutil
+ - canvas
+ - supports-color
+ - typescript
+ - utf-8-validate
+
+ superdoc@1.19.1(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.5.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19):
dependencies:
'@hocuspocus/provider': 2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19)
buffer-crc32: 1.0.0
eventemitter3: 5.0.4
jsdom: 27.3.0(canvas@3.2.1)
konva: 10.2.0
- naive-ui: 2.43.2(vue@3.5.25(typescript@5.5.4))
pdfjs-dist: 5.4.624
pinia: 2.3.1(typescript@5.5.4)(vue@3.5.25(typescript@5.5.4))
rollup-plugin-copy: 3.5.0
@@ -37632,14 +37664,13 @@ snapshots:
- typescript
- utf-8-validate
- superdoc@1.18.2(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19):
+ superdoc@1.19.1(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19):
dependencies:
'@hocuspocus/provider': 2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19)
buffer-crc32: 1.0.0
eventemitter3: 5.0.4
jsdom: 27.3.0(canvas@3.2.1)
konva: 10.2.0
- naive-ui: 2.43.2(vue@3.5.25(typescript@5.9.3))
pdfjs-dist: 5.4.624
pinia: 2.3.1(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3))
rollup-plugin-copy: 3.5.0
@@ -38634,11 +38665,6 @@ snapshots:
vary@1.1.2: {}
- vdirs@0.1.8(vue@3.5.25(typescript@5.5.4)):
- dependencies:
- evtd: 0.2.4
- vue: 3.5.25(typescript@5.5.4)
-
vdirs@0.1.8(vue@3.5.25(typescript@5.9.3)):
dependencies:
evtd: 0.2.4
@@ -39075,11 +39101,6 @@ snapshots:
vm-browserify@1.1.2: {}
- vooks@0.2.12(vue@3.5.25(typescript@5.5.4)):
- dependencies:
- evtd: 0.2.4
- vue: 3.5.25(typescript@5.5.4)
-
vooks@0.2.12(vue@3.5.25(typescript@5.9.3)):
dependencies:
evtd: 0.2.4
@@ -39152,17 +39173,6 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
- vueuc@0.4.65(vue@3.5.25(typescript@5.5.4)):
- dependencies:
- '@css-render/vue3-ssr': 0.15.14(vue@3.5.25(typescript@5.5.4))
- '@juggle/resize-observer': 3.4.0
- css-render: 0.15.14
- evtd: 0.2.4
- seemly: 0.3.10
- vdirs: 0.1.8(vue@3.5.25(typescript@5.5.4))
- vooks: 0.2.12(vue@3.5.25(typescript@5.5.4))
- vue: 3.5.25(typescript@5.5.4)
-
vueuc@0.4.65(vue@3.5.25(typescript@5.9.3)):
dependencies:
'@css-render/vue3-ssr': 0.15.14(vue@3.5.25(typescript@5.9.3))