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:
libs/copilot/src/index.css— Tailwind base layer, CSS variable definitions, and shimmer animationlibs/copilot/src/ThemeProvider.tsx— React component that toggleslight/darkclasses and injects custom theme variables at runtime
Shadow DOM Setup#
window.mountChainlitWidget bootstraps the widget by:
- Creating a
<div id="chainlit-copilot">and attaching an open shadow root to it. - Creating
<div id="cl-shadow-root">inside the shadow root and storing a reference aswindow.cl_shadowRootElement. - 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:
| Selector | Role |
|---|---|
#shadow-root-container | Light-mode default values for --background, --foreground, --card, sidebar tokens, etc. |
.dark | Dark-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 basevariables were scoped to#shadow-root-container, while the actual DOM element created at mount time usesid="cl-shadow-root". The mismatch caused light-mode to render transparently because the variable declarations never matched any element — dark mode was unaffected because.darkis a class selector that did match. The PR's proposed fix was to rename the selector to#cl-shadow-rootand add explicit#cl-shadow-root.light/#cl-shadow-root.darkscoped rules. The currentindex.cssstill 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:
-
Class toggling : On theme change, it calls
shadowContainer.classList.remove('light', 'dark')then.add(theme)onwindow.cl_shadowRootElement. This is what activates the.darkCSS block. -
System preference detection : When
theme === 'system', it readswindow.matchMedia('(prefers-color-scheme: dark)')to resolve to'light'or'dark'and listens for OS-level changes. -
Custom variable injection :
applyThemeVariables()readswindow.theme.lightorwindow.theme.dark(aRecord<string, string>populated from/public/theme.json) and callsshadowContainer.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#
| File | Purpose |
|---|---|
libs/copilot/index.tsx | Shadow DOM creation, window.cl_shadowRootElement assignment, inline style injection |
libs/copilot/src/index.css | All CSS variables and Tailwind base; the source of truth for token values |
libs/copilot/src/ThemeProvider.tsx | Runtime class toggling, OS-preference sync, custom variable injection |
libs/copilot/sonner.css | Toast 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 baseofindex.cssmatches the actual element ID (cl-shadow-rootvsshadow-root-container). This was the root cause of the issue described in PR #2768. - Theme class not applied: Confirm
window.cl_shadowRootElementis non-null beforeThemeProvidermounts; the guard at silently returns if it is. - Custom theme overrides not working: Verify
/public/theme.jsonis served andwindow.themeis populated;applyThemeVariablesis a no-op ifwindow.themeis undefined.