Use groups when a frame needs real membership, not merely a rectangle behind some nodes. This example renders styled Billing and Archive zones, a nested Team container around two selected nodes, and a Delivery pool with three swimlanes. Its toolbar groups the current selection, fits and collapses the latest container, and adds lanes.
1. Define the data and group actions
Put this shared browser-side code in groups.ts. Describe nodes with NodeSpec, links with EdgeSpec, and zones with GroupSpec. A zone's children become members; without bounds, its frame fits those members with padding. A custom style replaces the theme's title-band frame with a captioned zone.
The mounted DiagramInstance supplies the live model and DiagramEngine. addGroup() returns a GroupModel; addToGroup() adds each selected node through an undoable command. The shipped SwimlaneService creates ordinary groups tiled into lane bands—no custom layout is needed.
tsimport type { DiagramInstance, EdgeSpec, GroupSpec, NodeSpec } from '@grafloria/renderer';
import { SwimlaneService } from '@grafloria/engine';
export const nodes: NodeSpec[] = [
{ id: 'n1', label: 'Review', position: { x: 100, y: 130 }, size: { width: 120, height: 50 }, selected: true },
{ id: 'n2', label: 'Approve', position: { x: 270, y: 130 }, size: { width: 120, height: 50 }, selected: true },
{ id: 'n3', label: 'Archived', position: { x: 600, y: 130 }, size: { width: 120, height: 50 } },
{ id: 'invoice', label: 'Loose invoice', position: { x: 600, y: 310 }, size: { width: 120, height: 50 } },
{ id: 'login', label: 'Login page', position: { x: 160, y: 470 }, size: { width: 140, height: 40 } },
{ id: 'search', label: 'Search API', position: { x: 160, y: 590 }, size: { width: 140, height: 40 } },
{ id: 'pdf', label: 'Export PDF', position: { x: 160, y: 720 }, size: { width: 140, height: 40 } },
];
export const edges: EdgeSpec[] = [
{ id: 'a', source: 'n3', target: 'n1' },
{ id: 'b', source: 'n3', target: 'n2' },
{ id: 'c', source: 'n1', target: 'n2' },
];
export const zones: GroupSpec[] = [
{
id: 'billing', label: 'Billing', bounds: { x: 40, y: 40, width: 440, height: 250 },
style: { fill: '#eff6ff', stroke: '#2563eb', borderRadius: 12, color: '#1d4ed8' },
labelPlacement: 'top-left',
},
{
id: 'archive', label: 'Archive', children: ['n3'], padding: 30,
style: { fill: '#f0fdf4', stroke: '#16a34a', strokeDasharray: '6 4', color: '#166534' },
},
];
export async function setupGroups(instance: DiagramInstance) {
const engine = instance.getEngine();
const diagram = instance.getModel();
engine.setInteractionConfig({ enableGroupMembershipOnDrop: true, enableGroupDrag: true });
instance.setGroups(zones);
let latestId: string | undefined;
async function groupSelected() {
const ids = diagram.getSelectedNodes().map(node => node.id);
if (ids.length === 0) return;
const group = await engine.addGroup({ name: 'Team' });
for (const id of ids) await engine.addToGroup(group.id, id);
group.fitToContents(diagram);
latestId = group.id;
instance.renderNow();
}
async function nestLatest() {
if (!latestId) return;
await engine.addToGroup('billing', latestId);
engine.getGroup('billing')?.fitToContents(diagram, { deepRecursive: true });
instance.renderNow();
}
function fitLatest() {
if (!latestId) return;
engine.getGroup(latestId)?.fitToContents(diagram, { mode: 'exact', deepRecursive: true });
instance.renderNow();
}
async function collapseLatest() {
if (!latestId) return;
await engine.collapseGroup(latestId, { proxyLabel: info => `${info.count}×` });
instance.renderNow();
}
async function expandLatest() {
if (!latestId) return;
await engine.expandGroup(latestId);
instance.renderNow();
}
await groupSelected();
await nestLatest();
const lanesService = new SwimlaneService(diagram);
const { pool, lanes } = lanesService.createPool({
name: 'Delivery', orientation: 'horizontal',
bounds: { x: 40, y: 430, width: 740, height: 360 },
headerSize: 40,
lanes: [
{ name: 'Backlog', weight: 1 },
{ name: 'In progress', weight: 2 },
{ name: 'Done', weight: 1 },
],
});
lanes[0]?.addMember('login', diagram);
lanes[1]?.addMember('search', diagram);
lanes[2]?.addMember('pdf', diagram);
function addLane() {
lanesService.addLane(pool, { name: 'Follow-up', weight: 1 });
instance.renderNow();
}
instance.renderNow();
instance.fitView(30);
return { groupSelected, nestLatest, fitLatest, collapseLatest, expandLatest, addLane };
}
The initial selection includes Review and Approve, not Archived. Setup groups that pair and embeds Team in Billing. deepRecursive: true fits descendant frames first, then fits the parent around their outer frames. Positions remain absolute world coordinates; nesting does not make node positions parent-relative.
Known issue:
GroupSpec.childrenignores group IDs, so declaringchildren: ['team']does not embed an existing Team group. Until it is fixed, useawait engine.addToGroup('billing', latestId)after creating Team, asnestLatest()does.
2. Mount it in your framework
Choose the install command for your existing project's framework.
JavaScript:
bashnpm install @grafloria/element @grafloria/renderer @grafloria/engine
React:
bashnpm install @grafloria/react @grafloria/element @grafloria/renderer @grafloria/engine react react-dom
Vue:
bashnpm install @grafloria/vue @grafloria/element @grafloria/renderer @grafloria/engine vue
Qwik:
bashnpm install @grafloria/qwik @grafloria/element @grafloria/renderer @grafloria/engine @builder.io/qwik
Angular:
bashnpm install @grafloria/angular @grafloria/element @grafloria/renderer @grafloria/engine @angular/common @angular/core @angular/forms @angular/platform-browser rxjs
JavaScript mounts with render(). React's GrafloriaFlow, Vue's GrafloriaFlow, and Qwik's GrafloriaFlow deliver the instance through their initialization callbacks. Qwik stores the live actions with noSerialize().
In Angular, use GrafloriaDiagramComponent for this example's frame-dragging interaction, following the drop-to-contain demo. Each component owns its diagram's teardown. The JavaScript mount returns an unmount function for your host to call when removing the view.
Save the chosen component as App.tsx, App.vue, or app.component.ts. Save the JavaScript entry as main.ts; it creates its own host and controls.
tsimport { render } from '@grafloria/element';
import { nodes, edges, setupGroups } from './groups';
export async function mountGroups(parent: HTMLElement) {
const view = document.createElement('section');
const toolbar = document.createElement('div');
const canvas = document.createElement('div');
canvas.style.height = '650px';
view.append(toolbar, canvas);
parent.append(view);
const instance = render({ nodes, edges }, canvas);
const actions = await setupGroups(instance);
const buttons = [
['Group selected', actions.groupSelected],
['Nest latest', actions.nestLatest],
['Fit latest', actions.fitLatest],
['Collapse latest', actions.collapseLatest],
['Expand latest', actions.expandLatest],
['Add lane', actions.addLane],
] as const;
for (const [label, action] of buttons) {
const button = document.createElement('button');
button.textContent = label;
button.addEventListener('click', () => { void action(); });
toolbar.append(button);
}
return () => { instance.dispose(); view.remove(); };
}
void mountGroups(document.body);
tsximport { useRef } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, setupGroups } from './groups';
export default function App() {
const actions = useRef<Awaited<ReturnType<typeof setupGroups>> | null>(null);
const live = useRef<DiagramInstance | null>(null);
async function onInit(instance: DiagramInstance) {
live.current = instance;
const next = await setupGroups(instance);
if (live.current === instance) actions.current = next;
}
return <section>
<div>
<button onClick={() => void actions.current?.groupSelected()}>Group selected</button>
<button onClick={() => void actions.current?.nestLatest()}>Nest latest</button>
<button onClick={() => actions.current?.fitLatest()}>Fit latest</button>
<button onClick={() => void actions.current?.collapseLatest()}>Collapse latest</button>
<button onClick={() => void actions.current?.expandLatest()}>Expand latest</button>
<button onClick={() => actions.current?.addLane()}>Add lane</button>
</div>
<div style={{ height: 650 }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} onInit={onInit} />
</div>
</section>;
}
vue<script setup lang="ts"> import { shallowRef } from 'vue'; import { GrafloriaFlow } from '@grafloria/vue'; import type { DiagramInstance } from '@grafloria/renderer'; import { nodes, edges, setupGroups } from './groups'; const actions = shallowRef<Awaited<ReturnType<typeof setupGroups>>>(); async function onInit(instance: DiagramInstance) { actions.value = await setupGroups(instance); } </script> <template> <section> <div> <button @click="actions?.groupSelected()">Group selected</button> <button @click="actions?.nestLatest()">Nest latest</button> <button @click="actions?.fitLatest()">Fit latest</button> <button @click="actions?.collapseLatest()">Collapse latest</button> <button @click="actions?.expandLatest()">Expand latest</button> <button @click="actions?.addLane()">Add lane</button> </div> <div style="height:650px"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" @init="onInit" /> </div> </section> </template>
tsximport { component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaFlow } from '@grafloria/qwik';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, setupGroups } from './groups';
export default component$(() => {
const actions = useSignal<NoSerialize<Awaited<ReturnType<typeof setupGroups>>>>();
return <section>
<div>
<button onClick$={async () => { await actions.value?.groupSelected(); }}>Group selected</button>
<button onClick$={async () => { await actions.value?.nestLatest(); }}>Nest latest</button>
<button onClick$={() => { actions.value?.fitLatest(); }}>Fit latest</button>
<button onClick$={async () => { await actions.value?.collapseLatest(); }}>Collapse latest</button>
<button onClick$={async () => { await actions.value?.expandLatest(); }}>Expand latest</button>
<button onClick$={() => { actions.value?.addLane(); }}>Add lane</button>
</div>
<div style={{ height: '650px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
onInit$={async (instance: DiagramInstance) => {
actions.value = noSerialize(await setupGroups(instance));
}} />
</div>
</section>;
});
tsimport { Component } from '@angular/core';
import { GrafloriaDiagramComponent } from '@grafloria/angular';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, setupGroups } from './groups';
@Component({
selector: 'app-root',
standalone: true,
imports: [GrafloriaDiagramComponent],
template: `
<div>
<button (click)="actions?.groupSelected()">Group selected</button>
<button (click)="actions?.nestLatest()">Nest latest</button>
<button (click)="actions?.fitLatest()">Fit latest</button>
<button (click)="actions?.collapseLatest()">Collapse latest</button>
<button (click)="actions?.expandLatest()">Expand latest</button>
<button (click)="actions?.addLane()">Add lane</button>
</div>
<grafloria-diagram [spec]="spec" (ready)="onReady($event)"
style="display:block;height:650px" />
`,
})
export class AppComponent {
readonly spec = { nodes, edges };
actions?: Awaited<ReturnType<typeof setupGroups>>;
async onReady(instance: DiagramInstance) {
this.actions = await setupGroups(instance);
}
}
The JavaScript mount shows Team nested in Billing, Archive to the right, and Delivery below the loose invoice.
React renders the selected Review and Approve nodes inside Team.
Vue renders the same container structure and lane tickets.
Qwik renders the loose invoice outside both upper zones.
Angular's diagram host renders the styled zones and nested frame.
3. Use the containers
- Ctrl-click nodes or marquee a selection, then press Group selected. A fitted Team frame wraps exactly the selected nodes. With no selected nodes, the button does nothing.
- Press Nest latest to embed that container in Billing. Press Fit latest after moving its members to recompute its frame, including nested descendants.
- Drag Loose invoice into Billing or Archive and release. It joins the target; drag an empty part of that frame and its members travel with it. Drop the invoice on empty canvas to detach it, or into the other frame to transfer membership. The innermost group wins a drop when frames overlap.
- Press Collapse latest. Review and Approve hide behind a placeholder for the initial Team; their two incoming crossings from Archived aggregate into a proxy labelled
2×. Press Expand latest to restore the original members and links. Use the engine calls, notGroupModel.collapse()alone, for this reversible link transformation. - Drag Search API between Delivery lanes. Lanes constrain tickets to the pool, but allow transfer to sibling lanes. Press Add lane to add Follow-up and redistribute the bands. In progress starts with twice the height of either other lane because its weight is
2.
Group a selection on the Angular canvas
If you use DiagramCanvasComponent, access its activeEngine() after the view initializes. This smaller example groups Review and Approve on the mounted canvas, following the selection-grouping demo. Use the diagram host above for dragging group frames.
tsimport { AfterViewInit, Component, viewChild } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
@Component({
selector: 'app-selection',
standalone: true,
imports: [DiagramCanvasComponent],
template: `
<grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges"
style="display:block;height:400px" />
`,
})
export class SelectionComponent implements AfterViewInit {
readonly canvas = viewChild.required(DiagramCanvasComponent);
nodes: NodeSpec[] = [
{ id: 'review', label: 'Review', position: { x: 100, y: 140 }, size: { width: 120, height: 50 }, selected: true },
{ id: 'approve', label: 'Approve', position: { x: 300, y: 140 }, size: { width: 120, height: 50 }, selected: true },
{ id: 'other', label: 'Leave out', position: { x: 550, y: 280 }, size: { width: 120, height: 50 } },
];
edges: EdgeSpec[] = [{ source: 'review', target: 'approve' }];
async ngAfterViewInit() {
const engine = this.canvas().activeEngine();
const diagram = engine?.getDiagram();
if (!engine || !diagram) return;
const ids = diagram.getSelectedNodes().map(node => node.id);
const group = await engine.addGroup({ name: 'Team' });
for (const id of ids) await engine.addToGroup(group.id, id);
group.fitToContents(diagram);
this.canvas().scheduleRender();
}
}
Options that matter
| Option | Type | Default | What it does |
|---|---|---|---|
GroupSpec.bounds | { x: number; y: number; width: number; height: number } | Not set | Supplies the authored frame rather than initially fitting it. |
GroupSpec.padding | number | 20 | Adds space around members in a fitted zone. Caption room can add extra space. |
GroupSpec.labelPlacement | 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right' | 'top-left' | Positions the zone caption. |
enableGroupMembershipOnDrop | boolean | true | Enables drag-end membership changes. |
enableGroupDrag | boolean | true | Enables moving frames with their members. |
GroupModel.fitMode | 'exact' | 'grow-only' | 'shrink-only' | 'exact' | Controls whether fitting can grow, shrink, or do both. |
GroupModel.constrainChildren | boolean | false | Keeps direct member nodes inside the inner extent; swimlanes set it to true. |
LaneSpec.weight | number | 1 | Shares remaining cross-axis space proportionally. |
LaneSpec.fixedSize | number | Not set | Pins a lane's cross-axis size instead of using its weight. |
CollapseOptions.proxyLabel | (info: ProxyLabelInfo) => string | Count for multiple crossings; no synthetic label for one | Labels aggregated crossing links using ProxyLabelInfo. |
Pitfalls
setGroups() reconciles the entire group set, not a patch. Include every group you want to retain; omitted groups are removed while their nodes remain. After setup, the example uses engine methods rather than resending zones, so Team and the swimlane groups stay in the model.
An authored frame can grow when a new member lies outside it. Use constrainChildren when the frame is an extent that members must stay within. Fit a nonempty group: fitToContents() does nothing when it has no positioned members.
The grouping toolbar performs several engine commands: creating the group and adding each member are separate history operations. For command composition and undo controls, see Commands and history. For saving membership and collapsed state, use the live document format described in Save and restore documents.
Live demos and related guides
Try Selection grouping, Drop to contain, Collapse & expand, and Swimlanes. The swimlane demo source also shows lane counts and resizing.
- Lay out a diagram explains composing layouts and zone directions.
- Configure editing gestures covers interaction settings beyond membership.
- Theme a canvas covers canvas styling and container sizing.
Was this page helpful?