Skip to content
D
Documentation

Theme a canvas

how-to
5 min readUpdated

Use a theme for canvas-wide defaults and spec styles for individual nodes and edges. One Theme object drives node defaults, link colors and selection states; changing it repaints the mounted diagram without replacing your data.

The examples below draw four nodes: a theme-default node, an orange named-style node, a theme-bound warning node, and a gradient-filled node with a shadow. The buttons switch palettes or request a host-token bridge.

1. Install your binding

Run the command for your existing framework project. The JavaScript example runs in the browser through your project's bundler.

JavaScript:

bash
npm install @grafloria/element @grafloria/renderer @grafloria/engine

React:

bash
npm install @grafloria/react @grafloria/element @grafloria/renderer @grafloria/engine react react-dom

Vue:

bash
npm install @grafloria/vue @grafloria/element @grafloria/renderer @grafloria/engine vue

Angular:

bash
npm install @grafloria/angular @grafloria/element @grafloria/renderer @grafloria/engine @angular/common @angular/core @angular/forms @angular/platform-browser rxjs

Qwik:

bash
npm install @grafloria/qwik @grafloria/element @grafloria/renderer @grafloria/engine @builder.io/qwik

2. Describe the paint once

Create canvas-theme.ts beside your component. Type the data with NodeSpec and EdgeSpec; the bindings turn these specs into live models.

Start with the shipped LIGHT_THEME, DARK_THEME, HIGH_CONTRAST_LIGHT_THEME and HIGH_CONTRAST_DARK_THEME. Use themeRef when a property expresses a meaning rather than a fixed color: category.warning reads the active theme's warning palette, and numbers.emphasis reads its numeric scale.

Register reusable paint with defineStyles. This is the named-style extension point; the mounted canvas consumes the definitions through style.styleClass.

The initialization helper receives a mounted DiagramInstance. The shipped muiBridge supplies the host-token map used by the buttons.

ts
import {
  LIGHT_THEME, DARK_THEME,
  HIGH_CONTRAST_LIGHT_THEME, HIGH_CONTRAST_DARK_THEME,
  defineStyles, themeRef, muiBridge,
  type Theme, type NodeSpec, type EdgeSpec, type DiagramInstance,
} from '@grafloria/renderer';

export const palettes: Record<string, Theme> = {
  light: LIGHT_THEME,
  dark: DARK_THEME,
  contrastLight: HIGH_CONTRAST_LIGHT_THEME,
  contrastDark: HIGH_CONTRAST_DARK_THEME,
};

export function registerPaint(): void {
  defineStyles({
    'orders-warning': { fill: '#fed7aa', stroke: '#9a3412', strokeWidth: 2 },
    'orders-bold': { strokeWidth: 5 },
  });
}

export const nodes: NodeSpec[] = [
  { id: 'plain', label: 'Theme default',
    position: { x: 50, y: 70 }, size: { width: 180, height: 76 } },
  { id: 'named', label: 'Named warning',
    position: { x: 350, y: 70 }, size: { width: 180, height: 76 },
    style: { styleClass: 'orders-warning orders-bold' } },
  { id: 'bound', label: 'Theme-bound warning',
    position: { x: 50, y: 230 }, size: { width: 180, height: 76 },
    style: {
      fill: themeRef('category.warning'),
      stroke: themeRef('category.warning'),
      strokeWidth: themeRef('numbers.emphasis'),
    } },
  { id: 'gradient', label: 'Gradient + shadow',
    position: { x: 350, y: 230 }, size: { width: 180, height: 76 },
    style: {
      fill: {
        type: 'linear', x1: 0, y1: 0, x2: 1, y2: 0,
        stops: [
          { offset: 0, color: '#ddd6fe' },
          { offset: 1, color: '#fbcfe8' },
        ],
      },
      stroke: '#7c3aed', strokeWidth: 2,
      shadow: { offsetX: 4, offsetY: 6, blur: 8, color: 'rgba(0,0,0,0.3)' },
    } },
];

export const edges: EdgeSpec[] = [
  { id: 'named-edge', source: 'plain', target: 'named',
    style: { styleClass: 'orders-warning', strokeDasharray: '6 3' } },
  { id: 'bound-edge', source: 'bound', target: 'gradient',
    style: {
      stroke: themeRef('category.warning'),
      strokeWidth: themeRef('numbers.emphasis'),
    } },
];

export function prepareCanvas(instance: DiagramInstance): void {
  registerPaint();
  instance.renderNow();
}

export const hostBridge = muiBridge();

export function useHostTokens(
  instance: DiagramInstance | null | undefined,
  enabled: boolean,
): void {
  if (!instance) return;
  instance.setTokenBridge(enabled ? hostBridge : null);
  instance.renderNow();
  const node = instance.container.querySelector('[data-node-id="plain"] rect.diagram-node');
  if (!node) return;
  const paint = getComputedStyle(node);
  let readout = instance.container.querySelector('output');
  if (!readout) {
    readout = document.createElement('output');
    readout.style.cssText = 'position:absolute; bottom:0; left:0; background:white; color:black';
    instance.container.append(readout);
  }
  readout.textContent = `Resolved default paint: fill ${paint.fill}; stroke ${paint.stroke}`;
}

export const hostCSS = `
.orders-host {
  --mui-palette-background-paper: #fffbf2;
  --mui-palette-divider: #b8860b;
  --mui-palette-text-primary: #1c1b1f;
  --mui-palette-primary-main: #6750a4;
}
`;

The named node gets the orange fill and a five-unit border: later names in the space-separated styleClass list win conflicts. An element's own fill, stroke or strokeWidth overrides its named styles; interaction state sits above both. The cascade is theme → type-default → named-class → element-inline → state.

Gradient objects produce SVG paint servers, and shadow objects produce drop-shadow filters. Use a linear gradient's normalized endpoints and stops as above, or a radial gradient with type: 'radial', cx, cy, r and stops. Edge stroke accepts the same gradient objects; an edge is a line, not a filled box.

3. Mount and switch the palette

Choose your framework tab. Each sample uses the shared file from step 2 and gives the drawing a resolved height of 400px. Light, Dark, Contrast light and Contrast dark change the theme. Host tokens requests the MUI bridge; Theme tokens requests its removal.

Pass theme to JavaScript's render, or bind it on React's GrafloriaFlow, Vue's GrafloriaFlow, Qwik's GrafloriaFlow or Angular's DiagramCanvasComponent; see Edit nodes for mounting and instance access.

ts
import { render } from '@grafloria/element';
import {
  nodes, edges, palettes, prepareCanvas, useHostTokens, hostCSS,
} from './canvas-theme';

const wrapper = document.createElement('section');
wrapper.className = 'orders-host';
const style = document.createElement('style');
style.textContent = hostCSS;
const toolbar = document.createElement('div');
const canvas = document.createElement('div');
canvas.style.height = '400px';
wrapper.append(style, toolbar, canvas);
document.body.append(wrapper);

const instance = render({ nodes, edges }, canvas, { theme: palettes.light });
prepareCanvas(instance);
for (const [key, label] of [
  ['light', 'Light'], ['dark', 'Dark'],
  ['contrastLight', 'Contrast light'], ['contrastDark', 'Contrast dark'],
]) {
  const button = document.createElement('button');
  button.textContent = label;
  button.onclick = () => instance.setTheme(palettes[key]);
  toolbar.append(button);
}
for (const enabled of [true, false]) {
  const button = document.createElement('button');
  button.textContent = enabled ? 'Host tokens' : 'Theme tokens';
  button.onclick = () => useHostTokens(instance, enabled);
  toolbar.append(button);
}
Initial light palette: four nodes, a dashed upper edge, a solid warning-colored lower edge, and six palette and token buttons. The lower-right node has a purple-to-pink fill and a shadow.

The default node follows each palette. The literal orange and gradient fills remain literal; the warning node and its edge follow the theme's semantic colors and numeric scale. Qwik registers the named styles in the browser's initialization callback and keeps the live instance out of serialized state.

Choose theme or colorMode

Use theme when your application selects a complete palette, as above. Use ColorMode when Grafloria selects the palette from a light/dark choice or OS preferences:

  • React and Qwik: pass colorMode="system" to the flow instead of theme.
  • Vue: pass color-mode="system" instead of :theme.
  • Angular: bind [colorMode]="'system'" instead of [theme].
  • JavaScript: pass colorMode: 'system' in the options to render() instead of theme.

'light' and 'dark' select the light/dark axis explicitly; 'system' follows prefers-color-scheme. A request for more contrast or active forced colors upgrades that axis to the available high-contrast theme, even with an explicit light/dark choice. The default set includes both high-contrast palettes.

On a mounted instance, setColorMode('system') starts following the OS and getColorMode() returns the requested mode, not the resolved theme. Pass a ThemeSet as the second argument to choose your own light, dark, highContrastLight and highContrastDark palettes. Angular exposes the same set through [themes].

While a binding's colorMode is set, it takes precedence over theme. In React, removing the prop keeps the last mode. Do not combine the manual palette buttons above with a mode prop that selects a different palette.

Bridge and scope CSS variables

The shared file uses the shipped muiBridge to map Grafloria colors to MUI's --mui-palette-* variables. Host tokens calls setTokenBridge(hostBridge); Theme tokens calls setTokenBridge(null). Angular supplies or removes the bridge through its [tokenBridge] input.

In JavaScript, React, Vue and Qwik, the token-button helper reads the default node's browser-computed fill and stroke when it finds the rendered rectangle. The readout reports computed paint, not the requested palette; a bridge request alone does not establish a visible paint change.

The bridge is a TokenBridge: a map from a token such as node.fill to a CSS expression such as var(--app-card). For other design systems, use the shipped shadcnBridge or tailwindBridge. The shadcn preset supports HSL components, OKLCH components or full color values; the Tailwind preset targets v4's --color-* variables.

Keep host variables on the wrapper, as .orders-host does, rather than changing :root for one diagram. Grafloria scopes its own variable block to the rendered root's data-grafloria-instance value. Two diagrams can use different themes on one page; setTheme() rewrites only the target instance's block. No Grafloria stylesheet import is required.

styleClass names registered paint; it is not a CSS class selector. Use style.className for a host-CSS hook on a rendered node or edge. Keep that CSS under your wrapper selector too.

Options that matter

OptionTypeDefaultWhat it does
themeThemeBuilt-in light theme without another theme sourceSets the palette for defaults and state colors.
colorMode'light' | 'dark' | 'system'No requested modeSelects the light/dark axis and watches accessibility preferences.
Angular themes / setColorMode() second argumentThemeSetBuilt-in light, dark and high-contrast setSupplies the palettes a mode chooses between.
tokenBridgeTokenBridgeNo bridgeRepoints scoped variables to host CSS values.
style.styleClassstringNo named styleApplies registered styles, left to right.
style.classNamestringNo extra classAdds a hook for your CSS.
Node style.fill / node or edge style.strokeColor string, linear/radial gradient or patternCascade supplies paintOverrides lower paint layers.
style.strokeWidthnumberCascade supplies widthSets border or line weight.
Node style.shadowBoolean or shadow objectNo element overrideUses a drop-shadow filter for a shadow object.

Pitfalls

A canvas fills its parent. If that parent has no resolved height, the drawing is blank: give the wrapper a real height or a sized flex/grid allocation, as the samples do.

Namespace global named styles, such as orders-warning, to avoid replacing another feature's definitions. Fixed colors do not become theme-bound when you switch palettes; use themeRef() for paint that must follow a theme.

For styling your own HTML node content, continue with JavaScript elements and content.

Was this page helpful?