Theming
@threadplane/chat styles every component with CSS custom properties. Colors, radii, fonts, spacing, and z-index layers all come from variables prefixed with --tplane-chat-, so matching an existing design system is a matter of setting those variables rather than writing component styles. The running example puts a theme picker beside a live conversation, so every token this page names is one click away from changing in front of you.
What the demo does
The Run tab shows a conversation on the left and a Theme Picker panel on the right. The panel lists four presets — Dark, Light, Ocean, and Forest — above the eight CSS variables those presets set, and the agent behind the conversation is an aviation assistant whose mock dataset covers ten United States airports and four airlines.
Ask it something like which airlines fly out of SFO, then click through the presets while the answer is on screen. The message bubbles, the input pill, and the panel itself all change together, because the panel is styled from the same variables as the chat components.
How it is built
Three files carry the example: the graph that answers the question, the provider that points Angular at it, and the component that owns the presets and applies them.
The conversation graph
The backend is deliberately plain, because the subject of the example is the frontend. generate reads the capability's prompt file, prepends it as a system message, and awaits the model; generate_title runs after it and writes a short thread title back through the LangGraph SDK.
def build_theming_graph():
"""
Constructs a simple conversational agent for demonstrating
chat theming and CSS custom property customization.
"""
llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
async def generate(state: MessagesState) -> dict:
system_prompt = (PROMPTS_DIR / "theming.md").read_text()
messages = [SystemMessage(content=system_prompt)] + state["messages"]
response = await llm.ainvoke(messages)
return {"messages": [response]}
graph = StateGraph(MessagesState)
graph.add_node("generate", generate)
graph.add_node("generate_title", generate_title)
graph.set_entry_point("generate")
graph.add_edge("generate", "generate_title")
graph.add_edge("generate_title", END)
return graph.compile()
The compiled graph is exported as graph, which is the symbol langgraph.json points at.
The application configuration
provideAgent() registers the agent for the whole application, and it is the only provider the chat components require. This example resolves its connection details at runtime from the host that serves the demo, which is why its factory reads them rather than hard-coding them. There is no theme provider at all: theming is CSS, not configuration.
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'langgraph') {
throw new Error('incompatible runtime');
}
return {
apiUrl: connection.apiUrl,
assistantId: connection.assistantId,
clientOptions: connection.clientOptions,
};
}),
],
};Your own application does not need the factory: pass apiUrl and assistantId directly, where assistantId is the graph name declared in langgraph.json, which is c-theming for this example.
The theme presets
A preset is nothing more than a map of custom property names to values. These four override eight of the color tokens and leave every other token at its default, which is the normal shape of a theme: change what the brand cares about, inherit the rest.
const THEMES: Record<string, Record<string, string>> = {
dark: {
'--tplane-chat-bg': '#171717',
'--tplane-chat-surface': '#1c1c1c',
'--tplane-chat-surface-alt': '#222222',
'--tplane-chat-primary': '#3b82f6',
'--tplane-chat-on-primary': '#ffffff',
'--tplane-chat-text': '#e0e0e0',
'--tplane-chat-separator': '#333',
'--tplane-chat-text-muted': '#777',
},
light: {
'--tplane-chat-bg': '#ffffff',
'--tplane-chat-surface': '#ffffff',
'--tplane-chat-surface-alt': '#f3f4f6',
'--tplane-chat-primary': '#2563eb',
'--tplane-chat-on-primary': '#ffffff',
'--tplane-chat-text': '#1a1a1a',
'--tplane-chat-separator': '#d1d5db',
'--tplane-chat-text-muted': '#6b7280',
},
ocean: {
'--tplane-chat-bg': '#0c1426',
'--tplane-chat-surface': '#111b31',
'--tplane-chat-surface-alt': '#152238',
'--tplane-chat-primary': '#0abde3',
'--tplane-chat-on-primary': '#07111f',
'--tplane-chat-text': '#c8d6e5',
'--tplane-chat-separator': '#1e3a5f',
'--tplane-chat-text-muted': '#576574',
},
forest: {
'--tplane-chat-bg': '#1a2e1a',
'--tplane-chat-surface': '#203420',
'--tplane-chat-surface-alt': '#243524',
'--tplane-chat-primary': '#4ade80',
'--tplane-chat-on-primary': '#102410',
'--tplane-chat-text': '#d4e6d4',
'--tplane-chat-separator': '#2d4a2d',
'--tplane-chat-text-muted': '#6b8f6b',
},
};The chat surface and the picker
<chat> is bound to the agent and projected into the layout's main slot. The sidebar beside it renders one button per preset and marks the active one, and the component's own styles read --tplane-chat-surface-alt, --tplane-chat-separator, --tplane-chat-primary, and --tplane-chat-on-primary, which is why the buttons restyle themselves along with the conversation.
<example-chat-layout sidebarWidth="18rem">
<chat main [agent]="agent" class="flex-1 min-w-0" />
<div sidebar class="panel">
<h3 class="cap">Theme Picker</h3>
<div class="theme-list">
@for (name of themeNames; track name) {
<button
class="theme-button"
[class.theme-button--active]="activeTheme() === name"
(click)="setTheme(name)">
{{ name | titlecase }}
</button>
}
</div>
<div>
<h4 class="cap">CSS Variables</h4>
<ul class="token-list">
<li><code>--tplane-chat-bg</code></li>
<li><code>--tplane-chat-surface</code></li>
<li><code>--tplane-chat-surface-alt</code></li>
<li><code>--tplane-chat-primary</code></li>
<li><code>--tplane-chat-on-primary</code></li>
<li><code>--tplane-chat-text</code></li>
<li><code>--tplane-chat-separator</code></li>
<li><code>--tplane-chat-text-muted</code></li>
</ul>
</div>
</div>
</example-chat-layout>Applying a preset
setTheme writes each entry of the preset onto document.documentElement as an inline style. Inline styles beat every stylesheet rule, so a preset applied this way overrides both the library defaults and anything set in your own global CSS.
export class ThemingComponent {
protected readonly agent = injectAgent();
protected readonly themeNames = Object.keys(THEMES);
protected readonly activeTheme = signal('dark');
setTheme(name: string) {
const theme = THEMES[name];
if (!theme) return;
this.activeTheme.set(name);
Object.entries(theme).forEach(([key, value]) => {
document.documentElement.style.setProperty(key, value);
});
}
}Setting the properties in a static stylesheet is enough for a fixed theme. The example writes them from TypeScript only because the picker changes them after load.
Where the token defaults come from
You do not import a stylesheet to get the defaults. The stylesheet is appended when the chat styles module first evaluates: a single <style id="tplane-chat-root-tokens"> element goes onto <head> with every default token declared on :root, and the injection is idempotent and skipped during server-side rendering. The top-level compositions repeat the call from their constructors as a fallback for bundlers that strip the module side effect.
That block is wrapped in @layer tplane-chat. Unlayered CSS beats layered CSS in the cascade, so a plain :root { --tplane-chat-primary: … } rule in your own stylesheet wins over the library defaults no matter which file the browser loads first, and no matter how specific the library rule is.
Token reference
Colors
| Token | Light | Dark | Purpose |
|---|---|---|---|
--tplane-chat-bg | rgb(255, 255, 255) | rgb(17, 17, 17) | Window background |
--tplane-chat-surface | rgb(255, 255, 255) | rgb(28, 28, 28) | Cards, the input pill |
--tplane-chat-surface-alt | rgb(251, 251, 251) | rgb(44, 44, 44) | Secondary fills, such as the thread sidebar |
--tplane-chat-primary | rgb(28, 28, 28) | rgb(255, 255, 255) | User bubble background, primary buttons |
--tplane-chat-on-primary | rgb(255, 255, 255) | rgb(28, 28, 28) | Text on those primary surfaces |
--tplane-chat-text | rgb(28, 28, 28) | rgb(245, 245, 245) | Assistant text, headings |
--tplane-chat-text-muted | rgb(115, 115, 115) | rgb(160, 160, 160) | Labels, placeholders, metadata |
--tplane-chat-separator | rgb(229, 229, 229) | rgb(45, 45, 45) | Hairlines and borders |
--tplane-chat-muted | rgb(200, 200, 200) | rgb(60, 60, 60) | Quiet borders, such as suggestion chips |
--tplane-chat-error-bg | #fef2f2 | rgb(45, 21, 21) | Error background |
--tplane-chat-error-border | #fecaca | #dc2626 | Error border |
--tplane-chat-error-text | #dc2626 | #fca5a5 | Error text |
--tplane-chat-destructive | #dc2626 | #ef4444 | Destructive confirm button |
--tplane-chat-warning-bg | #fffbeb | rgb(45, 35, 21) | Warning background |
--tplane-chat-warning-text | #b45309 | #fbbf24 | Warning text |
--tplane-chat-success | #16a34a | #4ade80 | Success state |
Three shadow tokens, --tplane-chat-shadow-sm, --tplane-chat-shadow-md, and --tplane-chat-shadow-lg, are declared with the light palette and are not redefined for dark.
Shape and layout
| Token | Default | Purpose |
|---|---|---|
--tplane-chat-radius-bubble | 15px | User message bubble and suggestion chips |
--tplane-chat-radius-input | 20px | Declared for consumers; no chat component reads it today |
--tplane-chat-radius-card | 8px | Tool call cards, citations, generative UI surfaces |
--tplane-chat-radius-button | 8px | Buttons in panels and lists |
--tplane-chat-radius-launcher | 9999px | Circular launcher button |
--tplane-chat-launcher-offset-x | 1rem | Distance from the viewport right edge to the <chat-popup> launcher and window |
--tplane-chat-launcher-offset-y | 1rem | Distance from the viewport bottom edge to the <chat-popup> launcher |
--tplane-chat-max-width | 48rem | Message column and input width |
--tplane-chat-edge-pad | 16px | Horizontal padding at the edges of the conversation |
--tplane-chat-space-1 … --tplane-chat-space-6, --tplane-chat-space-8 | 4px … 24px, 32px | Internal spacing scale |
--tplane-chat-sidenav-width-expanded | 280px | Expanded thread navigation width |
--tplane-chat-sidenav-width-collapsed | 56px | Collapsed thread navigation width |
Typography
| Token | Default | Purpose |
|---|---|---|
--tplane-chat-font-family | System stack | Font on every chat component host |
--tplane-chat-font-mono | Monospace stack | Code blocks and token labels |
--tplane-chat-font-size | 1rem | Message text |
--tplane-chat-font-size-sm | 0.875rem | Controls and secondary text |
--tplane-chat-font-size-xs | 0.75rem | Labels and metadata |
--tplane-chat-line-height | 1.6 | Assistant text |
--tplane-chat-line-height-tight | 1.5 | User bubble |
Three further families are declared alongside these and follow the same override rule: --tplane-chat-citation-* for citation markers and the sources panel, --tplane-chat-z-* for the overlay, drawer, and modal layers, and --a2ui-* for generative UI surfaces rendered inside the conversation. Four ready-made presets ship as @threadplane/chat/themes/{default,material}-{light,dark}.css and set only --a2ui-* values; see the A2UI overview for what those surfaces render.
Light and dark mode
Tokens hold their light values by default. Dark values are applied under @media (prefers-color-scheme: dark), and two attribute pairs override that preference:
[data-theme="dark"]or[data-threadplane-chat-theme="dark"]forces the dark values.[data-theme="light"]or[data-threadplane-chat-theme="light"]forces the light values.
Both attribute rules are declared after the media query, so an explicit attribute always wins over the system preference. Set the attribute on <html>, on <body>, or on any element that wraps the chat components — custom properties inherit, so everything below the attribute picks up the palette.
<html data-theme="dark">Overriding tokens in your own application
Set the variables you care about on :root in a global stylesheet. Unlayered rules beat the library defaults, so no !important and no import order juggling are needed.
/* src/styles.css */
:root {
--tplane-chat-primary: #2563eb;
--tplane-chat-on-primary: #ffffff;
--tplane-chat-radius-bubble: 12px;
--tplane-chat-max-width: 56rem;
}Scope the same declarations to a wrapper element when only part of the application should use the palette, and the components inside that wrapper inherit it.
.marketing-chat {
--tplane-chat-primary: #0abde3;
--tplane-chat-on-primary: #07111f;
}Bridging your design system
If your application already has design tokens, keep those as the source of truth and map them into the chat variables. This gives you one stable control surface without coupling chat internals to your own namespace, and it is exactly what the example applications do in their shared layout stylesheet.
:root {
/* Application-owned design tokens */
--ds-canvas: #ffffff;
--ds-surface: #f8fafc;
--ds-border: #e2e8f0;
--ds-text-primary: #0f172a;
--ds-text-muted: #64748b;
--ds-accent: #2563eb;
--ds-font-sans: Inter, system-ui, sans-serif;
/* Chat-owned public API */
--tplane-chat-bg: var(--ds-canvas);
--tplane-chat-surface: var(--ds-surface);
--tplane-chat-surface-alt: var(--ds-surface);
--tplane-chat-separator: var(--ds-border);
--tplane-chat-text: var(--ds-text-primary);
--tplane-chat-text-muted: var(--ds-text-muted);
--tplane-chat-primary: var(--ds-accent);
--tplane-chat-font-family: var(--ds-font-sans);
}The chat names describe component semantics, such as primary, surface-alt, separator, and radius-bubble, while your own tokens stay product-wide and evolve independently.
Migrating from --chat-* tokens
Custom properties written against the earlier --chat-* names are inert. Rename them:
| Old token | New token |
|---|---|
--chat-bg | --tplane-chat-bg |
--chat-text | --tplane-chat-text |
--chat-user-bg | --tplane-chat-primary |
--chat-user-text | --tplane-chat-on-primary |
--chat-radius-message | --tplane-chat-radius-bubble |
--chat-radius-input | --tplane-chat-radius-input |
--chat-radius-card | --tplane-chat-radius-card |
--chat-max-width | --tplane-chat-max-width |
--chat-error-bg | --tplane-chat-error-bg |
--chat-error-text | --tplane-chat-error-text |
--chat-warning-bg | --tplane-chat-warning-bg |
--chat-warning-text | --tplane-chat-warning-text |
--chat-success | --tplane-chat-success |
@threadplane/chat exports no theme or markdown style strings. CHAT_THEME_STYLES does not exist, and CHAT_MARKDOWN_STYLES is an internal constant that is deliberately not part of the public API. Theming is done through the custom properties above; custom markdown rendering uses <chat-streaming-md> or your own host styles around the exported renderMarkdown().
What's Next
The composition the example mounts, and the slots it projects.
The input pill, and the exact variables it reads.
Inline, popup, and sidebar surfaces for the same conversation.
Generative UI surfaces rendered inside the conversation, and the --a2ui-* tokens that theme them.