Theme
On this page (12)
Component Skill — components/mermaid/component-skill.md

Native basis

<figure> + Mermaid's native <pre class="mermaid"> - progressive enhancement from readable source to SVG.

Web Platform APIs

import()MutationObserver<output><figure>

Classes

.mermaid-diagram.mermaid.mermaid-output.mermaid-error

Upstream

mermaid@12.0.0Official renderer (mermaid-js/mermaid, MIT) - dist/mermaid.esm.min.mjs via jsDelivr, pinnedmeta name="mermaid-module"Self-host: point the loader at your copy of the same buildsecurityLevel: strictLocked - HTML in labels encoded, click handlers off

Accessibility

• aria-label on the figure names the SVG (role="img")

• accTitle: / accDescr: in the source become the SVG's title and description

• Render errors are an <output role="alert">; the source stays readable

§Flowchart

The figure wraps Mermaid's own markup. Nodes are card surfaces, edges the muted foreground - all from the design tokens.

§Sequence diagram

Actors, messages, activations and notes.

§Class and state diagrams

Any Mermaid diagram type works - the theme is the same everywhere.

§Added at runtime

Diagrams inserted later - SPA navigation, streamed content, a click - render on their own: a MutationObserver finds them. No init call.

§Light and dark

The colors come from the design tokens the figure resolves, converted to the hex values Mermaid needs. A figure inside a .dark scope renders dark on a light page; toggling the site theme (or any theme switch) re-renders every diagram.

§A malformed diagram

Invalid syntax never destroys the source: it stays visible, and an output with role="alert" says what went wrong.

§Accessible diagrams

aria-label names the SVG; Mermaid's accTitle / accDescr lines become its title and description; a figcaption is visible to everyone.

§Mermaid's own markup

A bare pre class="mermaid" - the convention from Mermaid's documentation - is wrapped in the figure automatically.

§In the docs: a fence

In these docs (MDX) and in ARCH.md, a mermaid code fence becomes the component - the same figure, rendered by the same runtime. label="…" names it, caption="…" adds a figcaption. The Verified Agentic Engineering proof loop and the Component Skills build diagram are written this way.

flowchart LR
    Fence["mermaid fence<br/>(MDX or ARCH.md)"] --> Figure["figure.mermaid-diagram<br/>pre.mermaid"]
    Figure --> Static["static HTML:<br/>readable source"]
    Figure --> Runtime["mermaid.js<br/>+ design tokens"]
    Runtime --> SVG["themed SVG"]
One content primitive: text in the source, SVG in the browser.

§States

Named states via the shared State API, bound on each figure:

  • default - the source as text (not rendered yet, or reset)
  • rendered - the SVG, rendered from the source (setState re-renders)
  • error - the source plus the error message (config.message)

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/mermaid-{state}.png.

Machine contract - verified against mermaid.schema.json by bun run verify:

StateTypeValuesDefaultDescription
renderedbooleantrue, falsetrueThe SVG is shown (unchecking returns to the source text via setState('default')).
errorbooleantrue, falsefalseThe error state: the source plus an output role="alert" message.

§CSS view file

/* -- Mermaid component ---------------------------------------------- */
@layer components {
  @keyframes mermaid-wait { from { background-position: 150% 0; } to { background-position: -50% 0; } }
  @keyframes mermaid-reveal { to { color: var(--muted-foreground); user-select: auto; } }
  /* figure.mermaid-diagram > pre.mermaid (the source) [+ .mermaid-output,
     the rendered SVG, + output.mermaid-error]. Without JavaScript - or
     before the renderer arrives - the source reads as a code block; once
     rendered, the SVG replaces it visually (the source stays in the DOM:
     every re-render starts from it). Everything inside the SVG is themed
     through Mermaid's themeVariables (mermaid.ts), not from here. */
  .mermaid-diagram {
    margin: 0;
    display: grid;
    align-content: start;
    gap: 0.75rem;
    min-width: 0;
    max-width: 100%;
    & > pre.mermaid {
      margin: 0;
      padding: 1rem;
      overflow-x: auto;
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
      background-color: var(--muted);
      color: var(--muted-foreground);
      font-family: var(--font-mono);
      font-size: 0.8125rem;
      line-height: 1.6;
      white-space: pre;
      tab-size: 2;
    }
    /* rendered: the SVG takes the stage, the source stays for re-renders */
    &[data-state="rendered"] > pre.mermaid { display: none; }
    /* no flash of the source: before the first render (not initialized yet,
       or pending) the source box is a quiet placeholder - same box, text
       hidden, a soft shimmer. Without scripting nothing hides; an error
       shows the source; and if no renderer ever arrives the text appears
       after 4s anyway. */
    @media (scripting: enabled) {
      &:is(:not([data-init]), [data-state="pending"]) > pre.mermaid {
        color: transparent;
        user-select: none;
        background-image: linear-gradient(90deg, transparent, color-mix(in oklch, var(--foreground) 6%, transparent), transparent);
        background-size: 200% 100%;
        animation: mermaid-wait 1.4s linear infinite, mermaid-reveal 0s 4s forwards;
      }
    }
    & > figcaption {
      font-size: 0.8125rem;
      color: var(--muted-foreground);
      text-align: center;
      text-wrap: balance;
    }
  }
  /* the SVG scales down to the column, never up past its natural size;
     very wide diagrams scroll inside the figure instead of the page */
  .mermaid-output {
    overflow-x: auto;
    overscroll-behavior-inline: contain;
    display: flex;
    justify-content: center;
    & svg {
      display: block;
      max-width: 100%;
      height: auto;
    }
  }
  /* a malformed diagram: the source stays, the message is token-styled */
  .mermaid-error {
    display: block;
    padding: 0.625rem 0.875rem;
    border: 1px solid color-mix(in oklch, var(--destructive) 45%, transparent);
    border-radius: var(--radius-md);
    background-color: color-mix(in oklch, var(--destructive) 8%, var(--background));
    color: var(--destructive);
    /* the parse error keeps its lines and a caret under the position - a
       monospace face without ligatures keeps the caret aligned */
    font-family: var(--font-mono);
    font-variant-ligatures: none;
    font-size: 0.75rem;
    line-height: 1.5;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }
  /* -- Accessibility / print ------------------------------------------ */
  @media (forced-colors: active) {
    .mermaid-output svg { forced-color-adjust: auto; }
    .mermaid-error { border-color: CanvasText; color: CanvasText; }
  }
  @media print {
    .mermaid-output { overflow: visible; }
    .mermaid-diagram { break-inside: avoid; }
  }
  /* no shimmer for reduced motion - the 4s fallback reveal stays */
  @media (prefers-reduced-motion: reduce) {
    .mermaid-diagram > pre.mermaid { animation-name: none, mermaid-reveal; }
  }
}

§JavaScript view file

// -- Mermaid ---------------------------------------------------------------
// A thin adapter around the OFFICIAL Mermaid renderer (mermaid-js/mermaid,
// MIT) - zero Mermaid bytes ship here. The markup is Mermaid's own native
// convention, `<pre class="mermaid">` (inside a `.mermaid-diagram` figure);
// defuss owns the lifecycle, the design tokens, security, errors and the
// State API, Mermaid owns parsing, layout and SVG.
//
// - Lazy: a page without a diagram never requests Mermaid. The first diagram
//   imports the pinned ESM build ONCE (MERMAID_URL, or <meta name=
//   "mermaid-module" content="…"> / df$.shadcn.mermaid.load(url) to
//   self-host).
// - Controlled: startOnLoad is off; every render is mermaid.render() on the
//   diagram's SOURCE - the <pre> stays in the DOM untouched (CSS hides it
//   once rendered), so re-renders never read back Mermaid's SVG.
// - Strict: securityLevel "strict" is locked (HTML in labels encoded, click
//   handlers off); Mermaid's own `secure` list keeps %%{init}%% directives
//   from lowering it.
// - Themed: design tokens → Mermaid "base" themeVariables, converted to hex
//   (Mermaid parses only hex; our tokens are oklch()). Theme changes (dark
//   mode, the theme switcher) re-render every live diagram.
// - Errors keep the source visible and add a token-styled <output role=alert>.
//
// Markup contract (component-skill.md): figure.mermaid-diagram > pre.mermaid.
// A bare <pre class="mermaid"> is wrapped in that figure on init. State lives
// ON THE FIGURE (data-state + dataset.stateName) - the bound `api` is the
// only state mutator (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals } from '../../shared/state-api.js';
const df$ = defussGlobals();
// 'default' = the source (not rendered yet / shown as text), 'rendered' =
// the SVG, 'error' = the source + an error message
const mermaidStates = ['default', 'rendered', 'error'];
/** The pinned, tested official build - never @latest (tests/e2e pin it). */
export const MERMAID_URL = 'https://cdn.jsdelivr.net/npm/mermaid@12.0.0/dist/mermaid.esm.min.mjs';
interface MermaidLike {
  initialize(config: Record<string, unknown>): void;
  render(id: string, text: string): Promise<{ svg: string; bindFunctions?: (el: Element) => void }>;
}
// -- loading ----------------------------------------------------------------
let modulePromise: Promise<MermaidLike> | null = null;
let moduleUrl = '';
/**
 * Import the official Mermaid ESM once. `url` overrides the source (a
 * self-hosted copy of the same build); without it: <meta name=
 * "mermaid-module">, else the pinned jsDelivr build. A failed import can be
 * retried (the next call imports again).
 */
function load(url?: string): Promise<MermaidLike> {
  const vendorUrl = url || document.querySelector<HTMLMetaElement>('meta[name="mermaid-module"]')?.content || MERMAID_URL;
  if (modulePromise && vendorUrl === moduleUrl) return modulePromise;
  moduleUrl = vendorUrl;
  // the ONE vendor import (verify: VENDOR_IMPORTS) - the official renderer, never our code
  const pending = import(/* @vite-ignore */ vendorUrl).then((m) => (m.default ?? m) as MermaidLike);
  pending.catch(() => {
    if (modulePromise === pending) modulePromise = null;
  });
  modulePromise = pending;
  return pending;
}
// -- tokens → Mermaid theme ---------------------------------------------------
let probe: CanvasRenderingContext2D | null = null;
/**
 * Why: Mermaid's theme engine documents hex colors only; our tokens are
 * oklch() and themes may use color-mix()/lab(). Painting one pixel and
 * reading it back is the browser's own conversion to sRGB (the same probe
 * the Chart adapter uses). '' when the color does not resolve.
 */
export function toHex(css: string): string {
  if (!css || css === 'none') return '';
  probe ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
  if (!probe) return '';
  probe.clearRect(0, 0, 1, 1);
  probe.fillStyle = 'rgba(1, 2, 3, 0.5)'; // sentinel: an invalid color keeps it
  probe.fillStyle = css;
  if (probe.fillStyle === 'rgba(1, 2, 3, 0.5)') return '';
  probe.fillRect(0, 0, 1, 1);
  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
  const hex = (n: number) => n.toString(16).padStart(2, '0');
  return `#${hex(r)}${hex(g)}${hex(b)}${a < 255 ? hex(a) : ''}`;
}
/** Mermaid "base" themeVariables from the tokens the figure resolves. */
export function mermaidTheme(el: Element): Record<string, unknown> {
  const cs = getComputedStyle(el);
  const tok = (name: string, fallback: string) => toHex(cs.getPropertyValue(name).trim()) || fallback;
  const background = tok('--background', '#ffffff');
  const foreground = tok('--foreground', '#0a0a0a');
  const card = tok('--card', background);
  const cardFg = tok('--card-foreground', foreground);
  const muted = tok('--muted', '#f5f5f5');
  const mutedFg = tok('--muted-foreground', '#737373');
  const border = tok('--border', '#e5e5e5');
  const primary = tok('--primary', foreground);
  const primaryFg = tok('--primary-foreground', background);
  const secondary = tok('--secondary', muted);
  const accent = tok('--accent', muted);
  const accentFg = tok('--accent-foreground', foreground);
  const destructive = tok('--destructive', '#dc2626');
  const [r, g, b] = [1, 3, 5].map((i) => parseInt(background.slice(i, i + 2), 16));
  const dark = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.5;
  return {
    darkMode: dark,
    background,
    fontFamily: cs.fontFamily || 'system-ui, sans-serif',
    fontSize: '14px',
    textColor: foreground,
    // nodes are cards: card surface, card text, a border edge
    primaryColor: card,
    primaryTextColor: cardFg,
    primaryBorderColor: mutedFg,
    mainBkg: card,
    nodeBorder: mutedFg,
    nodeTextColor: cardFg,
    secondaryColor: secondary,
    secondaryTextColor: foreground,
    secondaryBorderColor: border,
    tertiaryColor: muted,
    tertiaryTextColor: foreground,
    tertiaryBorderColor: border,
    lineColor: mutedFg,
    defaultLinkColor: mutedFg,
    edgeLabelBackground: background,
    titleColor: foreground,
    clusterBkg: muted,
    clusterBorder: border,
    // sequence diagrams
    actorBkg: card,
    actorBorder: mutedFg,
    actorTextColor: cardFg,
    actorLineColor: border,
    signalColor: foreground,
    signalTextColor: foreground,
    labelBoxBkgColor: muted,
    labelBoxBorderColor: border,
    labelTextColor: foreground,
    loopTextColor: foreground,
    activationBkgColor: muted,
    activationBorderColor: mutedFg,
    sequenceNumberColor: primaryFg,
    noteBkgColor: accent,
    noteTextColor: accentFg,
    noteBorderColor: border,
    // class / state / er
    classText: cardFg,
    labelColor: cardFg,
    altBackground: muted,
    stateBkg: card,
    stateLabelColor: cardFg,
    compositeBackground: muted,
    compositeTitleBackground: muted,
    innerEndBackground: foreground,
    specialStateColor: foreground,
    // emphasis + errors
    pie1: primary,
    errorBkgColor: destructive,
    errorTextColor: primaryFg,
  };
}
// -- source + rendering -----------------------------------------------------
/**
 * The diagram text: the <pre>'s markup with entities decoded - exactly how
 * Mermaid reads it (a literal <br> in a label survives, &lt;br&gt; decodes
 * to it), so hand-written and generated markup agree.
 */
function sourceOf(fig: HTMLElement): string {
  const pre = fig.querySelector<HTMLElement>(':scope > pre.mermaid');
  if (!pre) return '';
  const decode = document.createElement('textarea');
  decode.innerHTML = pre.innerHTML;
  return decode.value.replace(/^\n+|\s+$/g, '');
}
/** The runtime-made output node (SVG host) - CodeExample chrome, never source. */
function outputOf(fig: HTMLElement): HTMLElement {
  let out = fig.querySelector<HTMLElement>(':scope > .mermaid-output');
  if (!out) {
    out = document.createElement('div');
    out.className = 'mermaid-output';
    out.setAttribute('data-ce-chrome', '');
    fig.querySelector(':scope > pre.mermaid')?.after(out);
  }
  return out;
}
function clearError(fig: HTMLElement): void {
  fig.querySelector(':scope > .mermaid-error')?.remove();
}
function showError(fig: HTMLElement, message: string): void {
  clearError(fig);
  fig.querySelector(':scope > .mermaid-output')?.remove();
  const out = document.createElement('output');
  out.className = 'mermaid-error';
  out.setAttribute('role', 'alert');
  out.setAttribute('data-ce-chrome', '');
  out.textContent = message;
  fig.querySelector(':scope > pre.mermaid')?.after(out);
  fig.dataset.state = 'error';
  fig.dataset.stateName = 'error';
}
let seq = 0;
/** Mermaid's config is global - renders run one at a time, each with its own theme. */
let queue: Promise<unknown> = Promise.resolve();
/** Render one diagram from its source (queued). Resolves true on success. */
function render(fig: HTMLElement): Promise<boolean> {
  // until the first render lands the source is a placeholder (mermaid.css hides
  // its text - no flash of raw markup); a re-render keeps the old SVG meanwhile
  if (fig.dataset.state !== 'rendered') fig.dataset.state = 'pending';
  const job = queue.then(async () => {
    const source = sourceOf(fig);
    if (!fig.isConnected || !source) {
      if (fig.dataset.state === 'pending') delete fig.dataset.state;
      return false;
    }
    let mermaid: MermaidLike;
    try {
      mermaid = await load();
    } catch {
      showError(fig, `Mermaid could not be loaded from ${moduleUrl} - the diagram source is shown instead.`);
      return false;
    }
    const theme = mermaidTheme(fig);
    mermaid.initialize({
      startOnLoad: false,
      securityLevel: 'strict',
      suppressErrorRendering: true,
      theme: 'base',
      themeVariables: theme,
    });
    try {
      const { svg, bindFunctions } = await mermaid.render(`defuss-mermaid-${++seq}`, source);
      if (!fig.isConnected) return false;
      clearError(fig);
      const out = outputOf(fig);
      out.innerHTML = svg;
      const el = out.querySelector('svg');
      const label = fig.getAttribute('aria-label');
      if (el) {
        el.removeAttribute('height');
        el.style.maxWidth = '';
        el.setAttribute('role', 'img');
        if (label && !el.querySelector(':scope > title')) el.setAttribute('aria-label', label);
      }
      bindFunctions?.(out);
      fig.dataset.state = 'rendered';
      fig.dataset.stateName = 'rendered';
      fig._mermaidTheme = JSON.stringify(theme);
      return true;
    } catch (err) {
      const text = err instanceof Error ? err.message : String(err);
      // Mermaid's parse errors are multi-line (a caret under the position) - keep the lines
      showError(fig, `This diagram could not be rendered.\n${text.split('\n').slice(0, 4).join('\n')}`);
      return false;
    }
  });
  queue = job.catch(() => undefined);
  return job;
}
function renderAll(): Promise<boolean[]> {
  return Promise.all([...document.querySelectorAll<HTMLElement>('.mermaid-diagram[data-init]')].map(render));
}
/** UI side of setState: the only function that switches a diagram's state. */
function triggerStateChange(fig: HTMLElement, stateName: string, config: Record<string, unknown>): Promise<boolean> | void {
  switch (stateName) {
    case 'default':
      clearError(fig);
      fig.querySelector(':scope > .mermaid-output')?.remove();
      delete fig.dataset.state;
      fig.dataset.stateName = 'default';
      return;
    case 'rendered':
      return render(fig);
    case 'error':
      showError(fig, typeof config.message === 'string' ? config.message : 'This diagram could not be rendered.');
      return;
  }
}
/** Registry-level API; pass the figure explicitly. Unknown names throw. */
export const mermaidApi = {
  setState(fig: HTMLElement, stateName: string, config: Record<string, unknown> = {}) {
    if (!mermaidStates.includes(stateName)) {
      throw new Error(`mermaid: unknown state "${stateName}" (supported: ${mermaidStates.join(', ')})`);
    }
    fig._stateConfig = config;
    return triggerStateChange(fig, stateName, config);
  },
  getState(fig: HTMLElement) {
    return { name: fig.dataset.stateName || 'default', config: fig._stateConfig ?? {} };
  },
};
df$.mermaidApi = mermaidApi;
df$.mermaidStates = mermaidStates;
// public imperative API (AGENTS.md "No window globals": df$.shadcn.mermaid)
df$.mermaid = { load, render, renderAll, theme: mermaidTheme, url: MERMAID_URL };
// -- live re-theming ---------------------------------------------------------
/**
 * Why: themes are live - dark mode toggles a class on <html>, the theme
 * switcher writes token overrides into <html style> or swaps a <style>.
 * One observer re-renders every live diagram whose derived theme changed
 * (debounced; untouched themes are skipped - Mermaid renders are not free).
 */
let themeWatched = false;
function watchTheme(): void {
  if (themeWatched) return;
  themeWatched = true;
  let timer = 0;
  const schedule = (): void => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      document.querySelectorAll<HTMLElement>('.mermaid-diagram[data-state="rendered"]').forEach((fig) => {
        if (JSON.stringify(mermaidTheme(fig)) !== fig._mermaidTheme) render(fig);
      });
    }, 80) as unknown as number;
  };
  const mo = new MutationObserver(schedule);
  mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
  if (document.head) mo.observe(document.head, { childList: true, subtree: true, characterData: true });
  matchMedia('(prefers-color-scheme: dark)').addEventListener('change', schedule);
}
// -- init --------------------------------------------------------------------
function init() {
  // Mermaid's own convention works bare: wrap a lone <pre class="mermaid">
  document.querySelectorAll<HTMLElement>('pre.mermaid:not(.mermaid-diagram > pre)').forEach((pre) => {
    const fig = document.createElement('figure');
    fig.className = 'mermaid-diagram';
    pre.before(fig);
    fig.append(pre);
  });
  document.querySelectorAll<HTMLElement>('.mermaid-diagram:not([data-init])').forEach((fig) => {
    fig.dataset.init = '';
    if (!fig.querySelector(':scope > pre.mermaid')) return;
    fig.dataset.stateName = 'default';
    fig.api = {
      setState: (stateName: string, config?: Record<string, unknown>) => mermaidApi.setState(fig, stateName, config),
      getState: () => mermaidApi.getState(fig),
    };
    watchTheme();
    render(fig);
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub