Angular-native Form Builder
This is the advanced drag-and-drop story in ngbootstrap: a recursive Angular-native Form Builder built from the same primitives that also support simple list drag-drop.
Recursive form-builder canvas
This example uses ngbDndList, ngbDndItem, and ngbDndHandle to build a recursive form editor. The root canvas accepts panels and form controls. Panels can also accept text inputs, textareas, selects, checkboxes, dates, and nested panels.
When to use it
Use this pattern for builders that manage hierarchical data: form sections, dashboard widgets, page blocks, survey groups, workflow steps, fields, or nested configuration panels. If you only need sortable or cross-list movement, start with the Simple List Drag Drop example. The important part is that every rendered list binds to the exact array it owns.
How to use it
- Bind each container with
[ngbDndList]and the samedndGroup. - Bind each draggable panel or field with
[ngbDndItem],[dndIndex], and[dndSourceList]. - Use
ngbDndHandlewhen only a header or icon should start the drag. - Use keyboard reorder within the current list with
Space,ArrowUp/ArrowDown,Enter, andEscape. Nested cross-list and palette moves still use pointer drag/drop. - Refresh your app state from
(dndDropped)when nested arrays are mutated in place.
- Panel
- Text input
- Textarea
- Select
- Checkbox
- Date
Code snippet
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import {
NgbDndHandleDirective,
NgbDndItemDirective,
NgbDndListDirective,
} from '@angular-bootstrap/ngbootstrap';
type FormNode = {
id: string;
kind: 'panel' | 'text' | 'textarea' | 'select' | 'checkbox' | 'date';
title: string;
collapsed?: boolean;
children?: FormNode[];
};
@Component({
standalone: true,
imports: [CommonModule, NgbDndListDirective, NgbDndItemDirective, NgbDndHandleDirective],
template: `
<div [ngbDndList]="panels()" dndGroup="panels" dndChildrenKey="children" (dndDropped)="refreshCanvas()">
@for (panel of panels(); track panel.id; let i = $index) {
<section [ngbDndItem]="panel" [dndSourceList]="panels()" [dndIndex]="i" dndGroup="panels">
<button ngbDndHandle type="button">Drag</button>
<input [value]="panel.title" (input)="updateTitle(panel, $event)" />
<div [ngbDndList]="panel.children" dndGroup="panels" dndChildrenKey="children" (dndDropped)="refreshCanvas()">
<!-- Render child panels recursively. -->
</div>
</section>
}
</div>
<pre><code>{{ canvasJson() }}</code></pre>
`,
})
export class FormBuilderExample {
panels = signal<FormNode[]>([]);
refreshCanvas(): void {
queueMicrotask(() => this.panels.update((panels) => [...panels]));
}
updateTitle(panel: FormNode, event: Event): void {
panel.title = (event.target as HTMLInputElement).value;
this.panels.update((panels) => [...panels]);
}
canvasJson(): string {
return JSON.stringify(this.panels(), null, 2);
}
}