Documentschainlit
Copilot CSS Theming
Copilot CSS Theming
Type
Topic
Status
Published
Created
Jul 7, 2026
Updated
Jul 7, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

Copilot CSS Theming#

The Chainlit Copilot widget runs inside a shadow DOM to isolate its styles from the host page. All visual theming — fonts, colors, light/dark mode — is managed through CSS custom properties scoped to that shadow root. The two primary files are:


Shadow DOM Setup#

window.mountChainlitWidget bootstraps the widget by:

  1. Creating a <div id="chainlit-copilot"> and attaching an open shadow root to it.
  2. Creating <div id="cl-shadow-root"> inside the shadow root and storing a reference as window.cl_shadowRootElement.
  3. Rendering the React tree (including all inline <style> tags) into #cl-shadow-root.

Because styles are injected inline into the shadow root, no host-page CSS leaks in — but it also means every selector must target elements inside #cl-shadow-root.


CSS Variable Architecture#

All design tokens are defined in @layer base inside index.css:

SelectorRole
#shadow-root-containerLight-mode default values for --background, --foreground, --card, sidebar tokens, etc.
.darkDark-mode overrides applied when ThemeProvider adds the dark class

The #cl-shadow-root block applies font-family: var(--font-sans) and color: hsl(var(--foreground)) to the root element, consuming those variables.

Known selector mismatch: PR #2768 identified that @layer base variables were scoped to #shadow-root-container, while the actual DOM element created at mount time uses id="cl-shadow-root". The mismatch caused light-mode to render transparently because the variable declarations never matched any element — dark mode was unaffected because .dark is a class selector that did match. The PR's proposed fix was to rename the selector to #cl-shadow-root and add explicit #cl-shadow-root.light / #cl-shadow-root.dark scoped rules. The current index.css still uses #shadow-root-container, so check whether this discrepancy is live in your branch.


ThemeProvider: Runtime Class & Variable Application#

ThemeProvider.tsx manages the active theme via three mechanisms:

  1. Class toggling : On theme change, it calls shadowContainer.classList.remove('light', 'dark') then .add(theme) on window.cl_shadowRootElement. This is what activates the .dark CSS block.

  2. System preference detection : When theme === 'system', it reads window.matchMedia('(prefers-color-scheme: dark)') to resolve to 'light' or 'dark' and listens for OS-level changes.

  3. Custom variable injection : applyThemeVariables() reads window.theme.light or window.theme.dark (a Record<string, string> populated from /public/theme.json) and calls shadowContainer.style.setProperty(key, value) for each entry, allowing server-driven overrides of any token.

The useTheme() hook exposes both the raw theme value and the resolved variant ('light' | 'dark'), which components can consume to make variant-specific decisions in code.


Key Files & Entry Points#

FilePurpose
libs/copilot/index.tsxShadow DOM creation, window.cl_shadowRootElement assignment, inline style injection
libs/copilot/src/index.cssAll CSS variables and Tailwind base; the source of truth for token values
libs/copilot/src/ThemeProvider.tsxRuntime class toggling, OS-preference sync, custom variable injection
libs/copilot/sonner.cssToast notification theming via `[data-sonner-toaster][data-theme='light

Debugging Theming Issues#

  • Transparent / blank background in light mode: Check that the container selector in @layer base of index.css matches the actual element ID (cl-shadow-root vs shadow-root-container). This was the root cause of the issue described in PR #2768.
  • Theme class not applied: Confirm window.cl_shadowRootElement is non-null before ThemeProvider mounts; the guard at silently returns if it is.
  • Custom theme overrides not working: Verify /public/theme.json is served and window.theme is populated; applyThemeVariables is a no-op if window.theme is undefined.