# Group and nest nodes

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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec), links with [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec), and zones with [`GroupSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance#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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) supplies the live model and [`DiagramEngine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine#diagramengine). `addGroup()` returns a [`GroupModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-groupmodel#groupmodel); `addToGroup()` adds each selected node through an undoable command. The shipped [`SwimlaneService`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-interaction#swimlaneservice) creates ordinary groups tiled into lane bands—no custom layout is needed.

```ts title="groups.ts"
import 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.children` ignores group IDs, so declaring `children: ['team']` does not embed an existing Team group. Until it is fixed, use `await engine.addToGroup('billing', latestId)` after creating Team, as `nestLatest()` does.

## 2. Mount it in your framework

Choose the install command for your existing project's framework.

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
```
Qwik:

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

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

JavaScript mounts with [`render()`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#render). React's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#grafloriaflow), Vue's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriaflow), and Qwik's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriaflow) deliver the instance through their initialization callbacks. Qwik stores the live actions with `noSerialize()`.

In Angular, use [`GrafloriaDiagramComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-classes#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.

:::code-group
```ts title="JavaScript"
import { 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);
```
```tsx title="React"
import { 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 title="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>
```
```tsx title="Qwik"
import { 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>;
});
```
```ts title="Angular"
import { 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.

![JavaScript: the nested Team frame, Archive zone, and three Delivery lanes beneath the six toolbar buttons.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/85903f2e5819ca1d142044f58cf627a5.png)

React renders the selected Review and Approve nodes inside Team.

![React: Review and Approve have blue selection outlines inside Team and Billing.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/d79336b530518c10e813164d27103168.png)

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, not `GroupModel.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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent#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.

```ts title="selection.component.ts"
import { 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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-interaction#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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-interaction#collapseoptions).`proxyLabel` | `(info: ProxyLabelInfo) => string` | Count for multiple crossings; no synthetic label for one | Labels aggregated crossing links using [`ProxyLabelInfo`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-interaction#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](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history). For saving membership and collapsed state, use the live document format described in [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents).

## Live demos and related guides

Try [Selection grouping](https://grafloria.com/demos/grouping/selection-grouping.html), [Drop to contain](https://grafloria.com/demos/grouping/drop-to-contain.html), [Collapse & expand](https://grafloria.com/demos/grouping/collapse-expand.html), and [Swimlanes](https://grafloria.com/demos/grouping/swimlanes.html). The [swimlane demo source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/grouping/swimlanes.html) also shows lane counts and resizing.

- [Lay out a diagram](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/lay-out-a-diagram) explains composing layouts and zone directions.
- [Configure editing gestures](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/configure-editing-gestures) covers interaction settings beyond membership.
- [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas) covers canvas styling and container sizing.
