Use a group as a real container: add nodes to it, add another group as a member, fit the frames around their contents, and collapse or expand the group without losing its links.
When to use groups
Use groups when membership has meaning. A node remains a node after it joins a group, and its links remain part of the diagram. A nested group is a member of its parent, so fitting the parent includes the nested group's outer frame rather than only the nested node positions.
The examples below use a DiagramInstance as the live handle to the mounted diagram. From it, get the DiagramEngine and call the group operations there; use the GroupModel for fitToContents() and nesting.
Create and nest containers
Start with four nodes and two links. render mounts them, after which you add three nodes to Pipeline, add retry to Retry handler, then add the inner group to the outer group. Fit the inner group first and the outer group second. The rendered result shows two frames around the stages, with Retry handler inside Pipeline; outside remains beyond both frames.
jsimport { render } from '@grafloria/element';
async function main() {
const host = document.getElementById('app');
if (!host) throw new Error('Missing #app');
host.setAttribute('style', 'display:block;width:800px;height:400px;');
const api = render({
nodes: [
{ id: 'n1', position: { x: 400, y: 150 }, size: { width: 120, height: 60 }, label: 'stage 1' },
{ id: 'n2', position: { x: 560, y: 150 }, size: { width: 120, height: 60 }, label: 'stage 2' },
{ id: 'n3', position: { x: 480, y: 275 }, size: { width: 120, height: 60 }, label: 'retry' },
{ id: 'outside', position: { x: 80, y: 150 }, size: { width: 120, height: 60 }, label: 'outside' },
],
edges: [
{ id: 'e1', source: 'n1', target: 'n2' },
{ id: 'e2', source: 'n1', target: 'n3' },
],
}, host);
const engine = api.getEngine();
const diagram = api.getModel();
const pipeline = await engine.addGroup({ name: 'Pipeline' });
await engine.addToGroup(pipeline.id, 'n1');
await engine.addToGroup(pipeline.id, 'n2');
await engine.addToGroup(pipeline.id, 'n3');
const retryHandler = await engine.addGroup({ name: 'Retry handler' });
await engine.addToGroup(retryHandler.id, 'n3');
pipeline.addMember(retryHandler.id, diagram);
retryHandler.fitToContents(diagram);
pipeline.fitToContents(diagram);
api.renderNow();
}
void main();
tsimport { Component, viewChild } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
@Component({
standalone: true,
imports: [DiagramCanvasComponent],
template: `<grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges" style="display:block;height:400px" />`,
})
export class NestedGroupsComponent {
canvas = viewChild.required(DiagramCanvasComponent);
nodes = [
{ id: 'n1', position: { x: 400, y: 150 }, size: { width: 120, height: 60 }, label: 'stage 1' },
{ id: 'n2', position: { x: 560, y: 150 }, size: { width: 120, height: 60 }, label: 'stage 2' },
{ id: 'n3', position: { x: 480, y: 275 }, size: { width: 120, height: 60 }, label: 'retry' },
{ id: 'outside', position: { x: 80, y: 150 }, size: { width: 120, height: 60 }, label: 'outside' },
];
edges = [
{ id: 'e1', source: 'n1', target: 'n2' },
{ id: 'e2', source: 'n1', target: 'n3' },
];
async ngAfterViewInit(): Promise<void> {
const engine = this.canvas().activeEngine();
if (!engine) return;
const diagram = engine.getDiagram();
if (!diagram) return;
const pipeline = await engine.addGroup({ name: 'Pipeline' });
await engine.addToGroup(pipeline.id, 'n1');
await engine.addToGroup(pipeline.id, 'n2');
await engine.addToGroup(pipeline.id, 'n3');
const retryHandler = await engine.addGroup({ name: 'Retry handler' });
await engine.addToGroup(retryHandler.id, 'n3');
pipeline.addMember(retryHandler.id, diagram);
retryHandler.fitToContents(diagram);
pipeline.fitToContents(diagram);
}
}
tsximport { component$, $ } from '@builder.io/qwik';
import { GrafloriaFlow, type DiagramInstance } from '@grafloria/qwik';
const nodes = [
{ id: 'n1', position: { x: 400, y: 150 }, size: { width: 120, height: 60 }, label: 'stage 1' },
{ id: 'n2', position: { x: 560, y: 150 }, size: { width: 120, height: 60 }, label: 'stage 2' },
{ id: 'n3', position: { x: 480, y: 275 }, size: { width: 120, height: 60 }, label: 'retry' },
{ id: 'outside', position: { x: 80, y: 150 }, size: { width: 120, height: 60 }, label: 'outside' },
];
const edges = [{ id: 'e1', source: 'n1', target: 'n2' }, { id: 'e2', source: 'n1', target: 'n3' }];
export default component$(() => (
<div style={{ height: '400px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} onInit$={$(async (instance: DiagramInstance) => {
const engine = instance.getEngine();
const diagram = engine.getDiagram();
if (!diagram) return;
const pipeline = await engine.addGroup({ name: 'Pipeline' });
await engine.addToGroup(pipeline.id, 'n1');
await engine.addToGroup(pipeline.id, 'n2');
await engine.addToGroup(pipeline.id, 'n3');
const retryHandler = await engine.addGroup({ name: 'Retry handler' });
await engine.addToGroup(retryHandler.id, 'n3');
pipeline.addMember(retryHandler.id, diagram);
retryHandler.fitToContents(diagram);
pipeline.fitToContents(diagram);
instance.renderNow();
})} />
</div>
));
vue<script setup lang="ts"> import { GrafloriaFlow } from '@grafloria/vue'; import type { DiagramInstance } from '@grafloria/vue'; const nodes = [ { id: 'n1', position: { x: 400, y: 150 }, size: { width: 120, height: 60 }, label: 'stage 1' }, { id: 'n2', position: { x: 560, y: 150 }, size: { width: 120, height: 60 }, label: 'stage 2' }, { id: 'n3', position: { x: 480, y: 275 }, size: { width: 120, height: 60 }, label: 'retry' }, { id: 'outside', position: { x: 80, y: 150 }, size: { width: 120, height: 60 }, label: 'outside' }, ]; const edges = [{ id: 'e1', source: 'n1', target: 'n2' }, { id: 'e2', source: 'n1', target: 'n3' }]; async function onInit(instance: DiagramInstance): Promise<void> { const engine = instance.getEngine(); const diagram = engine.getDiagram(); if (!diagram) return; const pipeline = await engine.addGroup({ name: 'Pipeline' }); await engine.addToGroup(pipeline.id, 'n1'); await engine.addToGroup(pipeline.id, 'n2'); await engine.addToGroup(pipeline.id, 'n3'); const retryHandler = await engine.addGroup({ name: 'Retry handler' }); await engine.addToGroup(retryHandler.id, 'n3'); pipeline.addMember(retryHandler.id, diagram); retryHandler.fitToContents(diagram); pipeline.fitToContents(diagram); instance.renderNow(); } </script> <template> <div style="height:400px"><GrafloriaFlow :default-nodes="nodes" :default-edges="edges" @init="onInit" /></div> </template>
tsximport { GrafloriaFlow } from '@grafloria/react';
import type { DiagramInstance } from '@grafloria/react';
const nodes = [
{ id: 'n1', position: { x: 400, y: 150 }, size: { width: 120, height: 60 }, label: 'stage 1' },
{ id: 'n2', position: { x: 560, y: 150 }, size: { width: 120, height: 60 }, label: 'stage 2' },
{ id: 'n3', position: { x: 480, y: 275 }, size: { width: 120, height: 60 }, label: 'retry' },
{ id: 'outside', position: { x: 80, y: 150 }, size: { width: 120, height: 60 }, label: 'outside' },
];
const edges = [{ id: 'e1', source: 'n1', target: 'n2' }, { id: 'e2', source: 'n1', target: 'n3' }];
export default function NestedGroups() {
const onInit = async (instance: DiagramInstance): Promise<void> => {
const engine = instance.getEngine();
const diagram = engine.getDiagram();
if (!diagram) return;
const pipeline = await engine.addGroup({ name: 'Pipeline' });
await engine.addToGroup(pipeline.id, 'n1');
await engine.addToGroup(pipeline.id, 'n2');
await engine.addToGroup(pipeline.id, 'n3');
const retryHandler = await engine.addGroup({ name: 'Retry handler' });
await engine.addToGroup(retryHandler.id, 'n3');
pipeline.addMember(retryHandler.id, diagram);
retryHandler.fitToContents(diagram);
pipeline.fitToContents(diagram);
instance.renderNow();
};
return <div style={{ height: '400px' }}><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} onInit={onInit} /></div>;
}
The group membership is explicit: moving a node visually does not detach it. Use removeFromGroup(groupId, entityId) when the relationship itself must change. fitToContents() uses member bounds plus the group's padding and header; fit descendants before their parent when nesting.
See the sub-flow live demo.
Collapse and expand without breaking relationships
For a group with external links, call collapseGroup(). Members become hidden and boundary links are represented by a collapsed proxy; parallel boundary links can be represented by one aggregated proxy. expandGroup() restores the members, geometry, and original links.
Add controls to the mounted instance. The buttons show the collapsed placeholder and then restore the complete graph.
jsimport { render } from '@grafloria/element';
async function main() {
const host = document.getElementById('app');
if (!host) throw new Error('Missing #app');
host.style.height = '400px';
const api = render({
nodes: [
{ id: 'ext', position: { x: 60, y: 120 }, size: { width: 120, height: 60 }, label: 'external' },
{ id: 'm1', position: { x: 420, y: 80 }, size: { width: 120, height: 60 }, label: 'member 1' },
{ id: 'm2', position: { x: 420, y: 200 }, size: { width: 120, height: 60 }, label: 'member 2' },
{ id: 'm3', position: { x: 420, y: 320 }, size: { width: 120, height: 60 }, label: 'member 3' },
],
edges: [
{ id: 'a', source: 'ext', target: 'm1' },
{ id: 'b', source: 'ext', target: 'm2' },
],
}, host);
const collapseButton = document.createElement('button');
collapseButton.textContent = 'collapse group';
const expandButton = document.createElement('button');
expandButton.textContent = 'expand group';
document.body.prepend(collapseButton, expandButton);
const service = await api.getEngine().addGroup({ name: 'Service' });
service.setFrame({ x: 400, y: 60, width: 180, height: 340 });
for (const id of ['m1', 'm2', 'm3']) await api.getEngine().addToGroup(service.id, id);
collapseButton.addEventListener('click', () => void api.getEngine().collapseGroup(service.id, {
proxyLabel: (item) => `${item.count}×`,
}));
expandButton.addEventListener('click', () => void api.getEngine().expandGroup(service.id));
api.renderNow();
}
void main();
The same instance calls work in Angular, Qwik, Vue, and React: keep the instance from each binding's ready or init callback, then call getEngine(). The collapse operation is asynchronous, so await it before reading the model or triggering a synchronous repaint.
See the collapse and expand live demo.
Make drag-and-drop change membership
Interactive membership is separate from explicit addToGroup(). Enable it on the engine when dropping a node into a frame must join that group:
tsimport { render } from '@grafloria/element';
const host = document.getElementById('app');
if (!host) throw new Error('Missing #app');
host.style.height = '400px';
const api = render({
nodes: [{ id: 'invoice', position: { x: 100, y: 100 }, label: 'invoice' }],
edges: [],
}, host);
const engine = api.getEngine();
engine.setInteractionConfig({
enableGroupMembershipOnDrop: true,
enableGroupDrag: true,
});
With these settings, dropping invoice into Billing makes it a member; dragging the frame moves the member with it; dropping the node on empty canvas removes the membership. Use a membership predicate when a group must reject some candidates.
See the drop-to-contain live demo.
Options that matter
| Option or call | Type | Default | What it does |
|---|---|---|---|
GroupModel.padding | GroupPadding | — | Adds space inside the frame when it fits around members. |
fitToContents() mode | GroupFitMode | exact | Reconciles the fitted rectangle by snapping to content, growing only, or shrinking only. |
fitToContents() deepRecursive | boolean | — | Fits descendant groups deepest-first before fitting the current group. |
collapseGroup() proxyLabel | callback receiving { count: number } | — | Supplies the label used by the collapse example for an aggregated proxy. |
enableGroupMembershipOnDrop | boolean | true | Controls whether a drop changes group membership. |
enableGroupDrag | boolean | true | Controls whether dragging a group moves its contents with it. |
Pitfalls
- Fit the child before the parent. Otherwise the parent can calculate bounds from a child whose frame has not yet been fitted.
- Do not use frame overlap as your membership test. Membership is stored in the group, and a node can remain a member after it is dragged within the frame.
- Do not call
collapseGroup()on a group with an unmounted or missing diagram; the engine operation requires a current diagram. - Keep the container's CSS height non-zero. A mounted diagram in a heightless container has no drawable area.
Live demos and related pages
- Sub-flow — create and nest groups.
- Collapse and expand — preserve boundary relationships through collapse.
- Drop to contain — make a pointer drop change membership.
- Nested containers — lay out cross-boundary edges.
- Layout and routing
- Commands and undo
- Model and document
Was this page helpful?