Examples
Each recipe below is complete enough to paste into a component. They assume the setup from Overview: runtime providers registered, stylesheet imported, icon font available.
A single field
The smallest useful case. One input, one signal, nothing else.
import { Component, signal } from '@angular/core';
import { NvbElement, NvbScope } from 'ngx-view-builder';
@Component({
selector: 'app-search-bar',
imports: [NvbScope, NvbElement],
template: `
<nvb-scope [(data)]="filters">
<nvb-element type="text" [config]="queryConfig" />
</nvb-scope>
`,
})
export class SearchBarComponent {
filters = signal<Record<string, unknown>>({ query: '' });
queryConfig = {
name: 'query',
label: 'Search',
placeholder: 'Order number, customer, VAT code',
};
}filters() always holds the current value, so a computed can filter a list off it without any subscription.
Reacting to one field
When you care about a single value rather than the whole object.
<nvb-scope [(data)]="form">
<nvb-element type="select" [config]="warehouseConfig" (valueChange)="onWarehouse($event)" />
</nvb-scope>warehouseConfig = {
name: 'warehouse',
label: 'Warehouse',
options: [
{ label: 'Vilnius', value: 'VNO' },
{ label: 'Kaunas', value: 'KUN' },
],
};
onWarehouse(event: INvbElementValueChange): void {
this.loadStock(event.value as string);
}The event also fires when an expression writes the value, which is usually what you want. If you only care about what a person typed, compare event.previousValue or listen on the scope instead.
A button that runs your code
Actions are declarative, so a button that should call your service gets an actionCode and no type. The library then does nothing on its own and simply reports it.
submitConfig = {
name: 'submit',
text: 'Create invoice',
icon: 'check',
tone: 'primary',
events: [{ trigger: 'click', actionCode: 'createInvoice' }],
};<nvb-element type="button" [config]="submitConfig" (action)="onAction($event)" />onAction(event: IElementActionRunEvent): void {
if (event.actionCode === 'createInvoice') {
this.invoices.create(this.form());
}
}To confirm first, keep it in the configuration rather than in your handler. The action only reaches you if the user agreed:
events: [
{
trigger: 'click',
actionCode: 'deleteInvoice',
confirmEnabled: true,
confirmTitle: 'Delete invoice',
confirmMessage: 'This cannot be undone.',
},
],A form section with conditional fields
The reason to put several elements in one scope: they can see each other.
<nvb-scope [(data)]="form">
<nvb-element type="panel" [config]="deliveryPanel">
<nvb-element type="selectButton" [config]="methodConfig" />
<nvb-element type="text" [config]="addressConfig" />
<nvb-element type="select" [config]="pickupPointConfig" />
</nvb-element>
</nvb-scope>deliveryPanel = { name: 'deliveryPanel', label: 'Delivery' };
methodConfig = {
name: 'method',
label: 'Method',
options: [
{ label: 'Courier', value: 'courier' },
{ label: 'Pickup point', value: 'pickup' },
],
};
addressConfig = {
name: 'address',
label: 'Address',
visibleIf: "method == 'courier'",
requireIf: "method == 'courier'",
resetOnHide: true,
};
pickupPointConfig = {
name: 'pickupPoint',
label: 'Pickup point',
visibleIf: "method == 'pickup'",
options: [
{ label: 'Vilnius, Gedimino 1', value: 'vno-1' },
{ label: 'Kaunas, Laisvės 10', value: 'kun-1' },
],
};resetOnHide matters here. Without it the address the user typed stays in the data after they switch to pickup, and you post a shipment with both.
Calculated totals
An expression writes into an element, and a validator guards the result.
<nvb-scope [(data)]="order">
<nvb-element type="number" [config]="qtyConfig" />
<nvb-element type="number" [config]="priceConfig" />
<nvb-element type="number" [config]="totalConfig" />
</nvb-scope>order = signal({ qty: 1, price: 49.9 });
qtyConfig = { name: 'qty', label: 'Quantity', min: 1 };
priceConfig = { name: 'price', label: 'Unit price' };
totalConfig = {
name: 'total',
label: 'Total',
readOnly: true,
expression: '{qty} * {price}',
validators: [{ type: 'max', value: 10000, message: 'Order too large for self service' }],
};Both operands are number elements, so they already hold numbers and the expression needs no conversion. toNumber() belongs where a value arrives as text, or where an empty field should count as zero.
A tabbed form
Each tab declares its own children.
<nvb-scope [(data)]="product">
<nvb-element type="tabs" [config]="tabsConfig">
<nvb-tab value="general">
<nvb-element type="text" [config]="nameConfig" />
<nvb-element type="textarea" [config]="descriptionConfig" />
</nvb-tab>
<nvb-tab value="pricing">
<nvb-element type="number" [config]="priceConfig" />
<nvb-element type="number" [config]="vatConfig" />
</nvb-tab>
</nvb-element>
</nvb-scope>tabsConfig = {
name: 'productTabs',
tabs: [
{ label: 'General', value: 'general' },
{ label: 'Pricing', value: 'pricing' },
],
};Values from a tab that is not open are still in the data and still visible to expressions, so a condition on the general tab can depend on the price.
Give tabs room. Below roughly 768 pixels of container width they switch to the stacked layout, which inside a narrow column looks like a bug but is the responsive behaviour.
A dialog
The trigger and the content are one element.
<nvb-scope [(data)]="form">
<nvb-element type="dialog" [config]="addressDialog">
<nvb-element type="text" [config]="streetConfig" />
<nvb-element type="text" [config]="cityConfig" />
</nvb-element>
</nvb-scope>addressDialog = { name: 'addressDialog', label: 'Edit address' };The dialog renders its own button, opens over the page and keeps its state under __dialog.addressDialog in the data, which you can read if you need to know whether it is open.
An editable line item table
dynamicTable builds its columns from the configuration, and every column is an element, so each cell is a real control.
order = signal({
lines: [
{ product: 'Keyboard', qty: 2, unit: 'pcs' },
{ product: 'Coffee', qty: 1, unit: 'kg' },
],
});
linesConfig = {
name: 'lines',
label: 'Order lines',
addRowButtonText: 'Add line',
confirmRowDeletion: true,
columns: [
{ type: 'text', name: 'product', label: 'Product' },
{ type: 'number', name: 'qty', label: 'Qty' },
{
type: 'select',
name: 'unit',
label: 'Unit',
options: [
{ label: 'pcs', value: 'pcs' },
{ label: 'kg', value: 'kg' },
],
},
],
};order().lines is an array of row objects, so posting it needs no transformation.
A read only data table
table without a data source reads its rows from the scope data. Sorting, search and paging run in the browser.
data = signal({
invoices: [
{ number: '2026-011', customer: 'Alfa UAB', issued: '2026-08-02', total: 1240.5 },
{ number: '2026-012', customer: 'Beta AB', issued: '2026-08-05', total: 890 },
],
});
invoicesConfig = {
name: 'invoices',
paginator: true,
pageSize: 10,
showQuickSearch: true,
quickSearchPlaceholder: 'Search invoices',
columnsConfig: [
{ key: 'number', label: 'Number', sortable: true, width: 120 },
{ key: 'customer', label: 'Customer', sortable: true },
{ key: 'issued', label: 'Issued', type: 'date', sortable: true },
{
key: 'total',
label: 'Total',
type: 'number',
align: 'right',
sortable: true,
numberMinFractionDigits: 2,
numberMaxFractionDigits: 2,
},
],
};Replacing the array in the signal replaces the rows, so this works just as well with data you fetched yourself.
Watch the property names. table uses columnsConfig with a key per column, dynamicTable uses columns with a name per column. They are not interchangeable.
Validation before saving
Validators live on the elements. To block a save, check the data yourself or let a validateForm action do it.
emailConfig = {
name: 'email',
label: 'Email',
required: true,
requiredMessage: 'We need an email to send the invoice',
validators: [{ type: 'email', message: 'That does not look like an email' }],
};
saveConfig = {
name: 'save',
text: 'Save',
tone: 'primary',
events: [{ trigger: 'click', actionCode: 'save', validateForm: true }],
};With validateForm: true the action stops when the form is invalid, so your (action) handler only runs on valid data.
A themed section
Themes are CSS variables on the scope element, which keeps them local to that part of the screen.
<nvb-scope [(data)]="form" [attr.style]="compactTheme">
<nvb-element type="text" [config]="codeConfig" />
</nvb-scope>compactTheme = [
'--nvb-control-height: 28px',
'--nvb-input-height: 28px',
'--nvb-input-font-size: 12px',
'--nvb-input-radius: 2px',
].join('; ');Written as an attribute string rather than an object binding, because Angular's style object binding does not pass CSS custom properties through. It has to sit on the scope element itself, not on a wrapper around it.
A translated form
<nvb-scope [(data)]="form" [language]="lang()" [translations]="texts">
<nvb-element type="text" [config]="emailConfig" />
</nvb-scope>lang = signal('lt');
texts = {
lt: {
'elements.email.label': 'El. paštas',
'elements.email.placeholder': 'jonas@imone.lt',
},
};Keys are structure paths, the same ones the builder exports. Anything without a translation keeps the text from the configuration, so a partial dictionary is fine.
Two independent forms on one screen
Each scope is its own namespace, so the same field name in both is two unrelated keys.
<nvb-scope [(data)]="sender">
<nvb-element type="text" [config]="addressConfig" />
</nvb-scope>
<nvb-scope [(data)]="receiver">
<nvb-element type="text" [config]="addressConfig" />
</nvb-scope>The same addressConfig object can be reused, because the name only has to be unique inside one scope. Expressions cannot cross the boundary either, which is exactly what you want here.
