Skip to content
D
Documentation

JavaScript: elements and content

how-to
7 min readUpdated

Use the custom element for HTML-driven embeds and render for programmatic mounts. Both give you an engine-positioned box whose inside you paint: your content does not take over dragging, selection, ports or connections. This page mounts connected cards, wires events, and updates a custom dashboard widget without rebuilding the board.

1. Choose the embedding surface

GrafloriaFlowElement implements <grafloria-flow>. Importing @grafloria/element registers that tag. Pass arrays through the element's nodes and edges properties; use JSON attributes when the data lives in markup.

render() takes the spec first, a container or CSS selector second, and options third. It returns a live DiagramInstance. Its input is an object or JSON, not Mermaid text; see Import diagram text and files for text parsing.

ChooseWhat you get
Element attributesAn HTML embed with JSON data and named DOM events
Element propertiesArray inputs without JSON encoding, plus diagram and fitView()
render(spec, target, options)A direct instance return, instance events, and kit finalization

Run the JavaScript examples in a browser project with an ESM bundler, such as the project from the JavaScript quick start. Install the packages they import:

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

This property-based embed uses NodeSpec and EdgeSpec to draw Build and Publish with a connection between them. Register the DOM listener before appending the element. The ready event's detail contains { diagram }; the example reads the typed diagram property instead of casting an untyped DOM event payload.

ts
import { GrafloriaFlowElement } from '@grafloria/element';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';

const nodes: NodeSpec[] = [
  { id: 'build', label: 'Build', position: { x: 60, y: 100 },
    size: { width: 180, height: 80 } },
  { id: 'publish', label: 'Publish', position: { x: 360, y: 100 },
    size: { width: 180, height: 80 } },
];
const edges: EdgeSpec[] = [{ id: 'release', source: 'build', target: 'publish' }];
const flow = document.createElement('grafloria-flow');
if (!(flow instanceof GrafloriaFlowElement)) throw new Error('Element not registered');
flow.style.height = '400px';
flow.setAttribute('theme', 'light');
flow.setAttribute('fit-view', '');
flow.nodes = nodes;
flow.edges = edges;
const status = document.createElement('p');
status.textContent = 'Starting diagram';
flow.addEventListener('grafloria-ready', () => {
  status.textContent = `Ready: ${flow.diagram?.getModel().getNodes().length} nodes`;
});
flow.addEventListener('grafloria-selection-change', () => {
  const selected = flow.diagram?.getModel().getNodes()
    .filter((node) => node.isSelected()).map((node) => node.id) ?? [];
  status.textContent = selected.length ? `Selected: ${selected.join(', ')}` : 'No selection';
});
const close = document.createElement('button');
close.textContent = 'Close diagram';
close.onclick = () => { flow.remove(); status.remove(); close.remove(); };
document.body.append(status, close, flow);
Build connects to Publish below the Ready: 2 nodes status and Close diagram button.

Assigning flow.nodes or flow.edges after mounting calls the instance's corresponding setter. Removing the element disposes its instance. For parent sizing arrangements, see Theme a canvas.

Attribute options

The following defaults come from the element's mount logic. Set mount-only attributes before inserting the element.

OptionTypeDefaultWhat it does
nodes, edgesJSON array stringsEmpty arraysSupplies node and edge specs; prefer properties for rich data
theme'light' or 'dark''light'Selects a shipped theme; unknown names resolve to light
fit-viewPresence flagAbsentFrames the content on mount
readonlyPresence flagAbsentConfigures read-only interaction on mount
panStringEnabled"false" disables pan on mount
wheel-zoomStringEnabled"false" disables zoom interaction on mount
zoomNumeric stringNot supplied by the elementSupplies the initial zoom
min-zoom, max-zoomNumeric stringsNot supplied by the elementSupplies mount-time zoom bounds
highlight-connectedFlag, "false", "trace", or depth stringOffHighlights selected nodes' connections; "trace" follows every path

DOM events

Every forwarded event bubbles and is composed, so it can cross a surrounding shadow boundary. Read the payload from CustomEvent.detail when your host supplies a typed event adapter.

DOM eventDetail
grafloria-ready{ diagram }
grafloria-nodes-change{ nodes } with live node models
grafloria-edges-change{ edges } with live link models
grafloria-selection-change{ nodes, edges }
grafloria-connect{ link }
grafloria-node-click{ node, world }
grafloria-edge-click{ edge, world }
grafloria-viewport-change{ viewport, zoom }

For a programmatic mount, use instance.on('selection:change', handler) instead. It infers the payload type and returns a function that removes the subscription. The programmatic example in the next step shows that cleanup on Close.

Rename the tag

Grafloria exposes the intended call Grafloria.define('project-flow').

Known issue: Grafloria.define('project-flow') reuses the constructor already registered as grafloria-flow, which the browser rejects for a second tag. Until it is fixed, register a subclass under your own tag.

This browser example registers the subclass once and mounts a Release node under <project-flow>:

ts
import { GrafloriaFlowElement } from '@grafloria/element';
import type { NodeSpec } from '@grafloria/renderer';

if (!customElements.get('project-flow')) {
  class ProjectFlow extends GrafloriaFlowElement {}
  customElements.define('project-flow', ProjectFlow);
}
const flow = document.createElement('project-flow');
if (!(flow instanceof GrafloriaFlowElement)) throw new Error('Unexpected tag constructor');
const nodes: NodeSpec[] = [
  { id: 'release', label: 'Release', position: { x: 80, y: 90 },
    size: { width: 180, height: 80 } },
];
flow.nodes = nodes;
flow.style.height = '400px';
flow.setAttribute('fit-view', '');
document.body.append(flow);
A Release node is centered in the renamed element's canvas.

2. Fill custom node hosts

Register a JavaScript renderer before mounting, and set custom: true on each node that uses it. Registering a type alone does not opt JavaScript or React nodes into HTML rendering. React uses nodeTypes; Vue exact #node-<type> slots and Angular typed templates opt matching specs in automatically. Vue's wildcard #node slot still needs custom: true.

A JavaScript renderer is a mount hook, not a data binding: it runs once when the host is created. Later node-data changes do not reinvoke it. Update the DOM you own. A missing renderer leaves an empty host, and registering later does not back-fill that host. Style a child of the host; the engine owns the host's position and dimensions.

Use NodeSpec and EdgeSpec in the shared file below. All framework tabs import it. The cards display Build and Publish, and the wire remains attached when you drag either card.

ts
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';

export const nodes: NodeSpec[] = [
  { id: 'build', type: 'card', custom: true, position: { x: 60, y: 100 },
    size: { width: 220, height: 110 }, data: { title: 'Build' } },
  { id: 'publish', type: 'card', custom: true, position: { x: 380, y: 100 },
    size: { width: 220, height: 110 }, data: { title: 'Publish' } },
];
export const edges: EdgeSpec[] = [{ id: 'release', source: 'build', target: 'publish' }];
export const cardStyle = {
  boxSizing: 'border-box' as const, height: '100%', padding: '12px',
  border: '1px solid #94a5f0', borderRadius: '10px', background: '#fff',
  color: '#232a3d', fontFamily: 'system-ui',
};

Install your binding in addition to the three shared packages above.

JavaScript:

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

Angular:

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

Qwik:

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

React:

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

Vue:

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

Use these components in an existing framework project. React's GrafloriaFlow maps types to components accepting NodeProps. Qwik's GrafloriaFlow uses Qwik components and its own NodeProps. Vue's GrafloriaFlow uses named slots. Angular's DiagramCanvasComponent uses ng-template[grafloriaNode], declared by GrafloriaNodeDefDirective.

Each tab exposes a card-local Mark complete button. It changes that card's content to Complete, without pretending that a mount hook is a reactive binding. Selection updates the status outside the canvas through the binding's own event.

ts
import { Grafloria, render } from '@grafloria/element';
import { nodes, edges, cardStyle } from './content';

Grafloria.registerNodeType('card', (node, host) => {
  const card = document.createElement('div');
  Object.assign(card.style, cardStyle);
  const title = document.createElement('strong');
  title.textContent = String(node.getData('title') ?? '');
  const complete = document.createElement('button');
  complete.textContent = 'Mark complete';
  complete.onclick = () => { title.textContent = 'Complete'; };
  card.append(title, document.createElement('br'), complete);
  host.replaceChildren(card);
});
const canvas = document.createElement('div');
canvas.style.height = '400px';
const status = document.createElement('p');
status.textContent = 'Select a card';
const close = document.createElement('button');
close.textContent = 'Close diagram';
document.body.append(status, close, canvas);
const instance = render({ nodes, edges }, canvas, { fitView: true });
const off = instance.on('selection:change', ({ nodes: selected }) => {
  status.textContent = selected.map((node) => node.id).join(', ') || 'No selection';
});
close.onclick = () => {
  off();
  instance.dispose();
  canvas.remove();
  status.remove();
  close.remove();
};

The JavaScript mount includes a Close diagram button above the connected cards.

JavaScript shows Build and Publish cards with Mark complete buttons, a connecting arrow, and Close diagram.

The Angular template fills both cards and leaves the surrounding node outlines and ports visible.

Angular shows connected Build and Publish cards with Mark complete buttons, orange outlines, and gray port dots.

The Qwik components draw the card content inside the connected hosts.

Qwik shows Build and Publish cards with Mark complete buttons below Select a card.

The React components and Vue slot components produce the same initial card content: Build and Publish, each with a Mark complete button.

Vue slots do not repaint for in-place node-data changes. Put a component in the slot that subscribes to your own reactive source, rather than relying on slot reinvocation. The Vue card above owns a reactive completion flag. Qwik custom nodes occupy separate Qwik containers: keep them self-contained and pass their initial payload through node data, not surrounding app context.

Use a template for markup-driven cards

For a CMS or static page, place a <template data-node-type="template-card"> inside the element. This alternative clones the template for the Build node. data-field="title" reads the data bag; data-field="id" reads the node id. Missing and null values become empty strings. A registered renderer takes precedence over a matching template.

html
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><title>Template card</title></head>
<body>
  <grafloria-flow style="height:400px" fit-view
    nodes='[{"id":"build","type":"template-card","custom":true,"position":{"x":80,"y":90},"size":{"width":220,"height":110},"data":{"title":"Build"}}]'>
    <template data-node-type="template-card">
      <div style="box-sizing:border-box;height:100%;padding:12px;border:1px solid #94a5f0;border-radius:10px;background:white">
        <strong data-field="title"></strong>
        <p data-field="id"></p>
      </div>
    </template>
  </grafloria-flow>
  <script type="module" src="/src/register.ts"></script>
</body>
</html>
ts
import '@grafloria/element';

Template substitution uses textContent, not HTML injection. Use the same rule in your JavaScript renderers: never interpolate user-supplied text into innerHTML. Templates share the mount-once contract; use owned DOM or widget handles when content must change later. The canvas uses light DOM, so your page stylesheet applies to the cloned children.

3. Update custom dashboard content through a handle

Use a WidgetHandle to repaint your custom content without remounting the board; see Arrange dashboard containers for creating and mounting the board and obtaining its handles.

The kit ships KPI, line, bar, donut, funnel and table painters. Delegate those kinds to defaultWidgetRenderer before adding your own painter. Type the data with DashboardWidgetSpec and the painter with WidgetRenderer. This board draws a shipped Builds KPI beside a custom Release note. Update note changes the note to Ready to publish without replacing the board or its instance.

ts
import { dashboard, defaultWidgetRenderer, render } from '@grafloria/element';
import type { DashboardWidgetSpec, WidgetRenderer } from '@grafloria/element';

const widgets: DashboardWidgetSpec[] = [
  { id: 'builds', kind: 'kpi', span: 3, rows: 1,
    data: { label: 'Builds', value: '24' } },
  { id: 'note', kind: 'release-note', span: 3, rows: 1,
    title: 'Release note', data: { text: 'Waiting for review' } },
];
const paint: WidgetRenderer = (widget, host) => {
  if (widget.kind !== 'release-note') {
    defaultWidgetRenderer(widget, host);
    return;
  }
  const card = document.createElement('div');
  Object.assign(card.style, {
    boxSizing: 'border-box', height: '100%', padding: '12px',
    background: '#fff', border: '1px solid #94a5f0', borderRadius: '10px',
    fontFamily: 'system-ui', color: '#232a3d',
  });
  const title = document.createElement('strong');
  title.textContent = widget.title ?? '';
  const text = document.createElement('p');
  text.textContent = String(widget.data?.['text'] ?? '');
  card.append(title, text);
  host.replaceChildren(card);
};
const canvas = document.createElement('div');
canvas.style.height = '400px';
const update = document.createElement('button');
update.textContent = 'Update note';
const close = document.createElement('button');
close.textContent = 'Close board';
document.body.append(update, close, canvas);
const spec = dashboard({ columns: 6, widgets, renderWidget: paint });
const instance = render(spec, canvas);
const handle = spec.handle;
update.onclick = () => {
  handle.widget('note')?.update({ data: { text: 'Ready to publish' } });
};
close.onclick = () => {
  handle.dispose();
  instance.dispose();
  canvas.remove();
  update.remove();
  close.remove();
};
The Builds KPI displays 24 beside a Release note reading Waiting for review; Update note and Close board appear above.

WidgetRenderer receives the declared widget spec and its HTML host. It mounts once during ordinary rendering; the widget handle explicitly invokes it again for content updates.

Operation or optionTypeDefaultWhat it does
renderWidgetWidgetRendererShipped painterFills each widget's host
columnsnumber12Sets the board's column count
rowHeightnumber130Sets row height in grow mode
update(patch)Partial data, title, kind fieldsNo patchReplaces supplied fields and repaints; returns void
repaint()No argumentsNo automatic data bindingReinvokes the painter with the current declared spec; returns void

update() replaces data; it does not merge keys into the old payload. Supply every data field the painter needs. If your painter reads an external store, change that store and call handle.widget('note')?.repaint() to refresh the same host. Neither call recreates the widget node.

The handle calls are shared across bindings. For React widget components, Vue widget slots, Angular widget templates and Qwik widget components, use the complete mounted examples in Build a dashboard.

Demos and next steps

Was this page helpful?