Page actions

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.

graph.py — the conversation graph
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.

app.config.ts
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.

theming.component.ts — the four presets
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.

theming.component.ts — the layout and the picker
<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.

theming.component.ts — applying a preset at runtime
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);
    });
  }
}
Note: Runtime switching is optional

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

TokenLightDarkPurpose
--tplane-chat-bgrgb(255, 255, 255)rgb(17, 17, 17)Window background
--tplane-chat-surfacergb(255, 255, 255)rgb(28, 28, 28)Cards, the input pill
--tplane-chat-surface-altrgb(251, 251, 251)rgb(44, 44, 44)Secondary fills, such as the thread sidebar
--tplane-chat-primaryrgb(28, 28, 28)rgb(255, 255, 255)User bubble background, primary buttons
--tplane-chat-on-primaryrgb(255, 255, 255)rgb(28, 28, 28)Text on those primary surfaces
--tplane-chat-textrgb(28, 28, 28)rgb(245, 245, 245)Assistant text, headings
--tplane-chat-text-mutedrgb(115, 115, 115)rgb(160, 160, 160)Labels, placeholders, metadata
--tplane-chat-separatorrgb(229, 229, 229)rgb(45, 45, 45)Hairlines and borders
--tplane-chat-mutedrgb(200, 200, 200)rgb(60, 60, 60)Quiet borders, such as suggestion chips
--tplane-chat-error-bg#fef2f2rgb(45, 21, 21)Error background
--tplane-chat-error-border#fecaca#dc2626Error border
--tplane-chat-error-text#dc2626#fca5a5Error text
--tplane-chat-destructive#dc2626#ef4444Destructive confirm button
--tplane-chat-warning-bg#fffbebrgb(45, 35, 21)Warning background
--tplane-chat-warning-text#b45309#fbbf24Warning text
--tplane-chat-success#16a34a#4ade80Success 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

TokenDefaultPurpose
--tplane-chat-radius-bubble15pxUser message bubble and suggestion chips
--tplane-chat-radius-input20pxDeclared for consumers; no chat component reads it today
--tplane-chat-radius-card8pxTool call cards, citations, generative UI surfaces
--tplane-chat-radius-button8pxButtons in panels and lists
--tplane-chat-radius-launcher9999pxCircular launcher button
--tplane-chat-launcher-offset-x1remDistance from the viewport right edge to the <chat-popup> launcher and window
--tplane-chat-launcher-offset-y1remDistance from the viewport bottom edge to the <chat-popup> launcher
--tplane-chat-max-width48remMessage column and input width
--tplane-chat-edge-pad16pxHorizontal padding at the edges of the conversation
--tplane-chat-space-1 … --tplane-chat-space-6, --tplane-chat-space-84px … 24px, 32pxInternal spacing scale
--tplane-chat-sidenav-width-expanded280pxExpanded thread navigation width
--tplane-chat-sidenav-width-collapsed56pxCollapsed thread navigation width

Typography

TokenDefaultPurpose
--tplane-chat-font-familySystem stackFont on every chat component host
--tplane-chat-font-monoMonospace stackCode blocks and token labels
--tplane-chat-font-size1remMessage text
--tplane-chat-font-size-sm0.875remControls and secondary text
--tplane-chat-font-size-xs0.75remLabels and metadata
--tplane-chat-line-height1.6Assistant text
--tplane-chat-line-height-tight1.5User 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 tokenNew 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
Warning: There are no style constants to import

@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

Looking for something specific?