Use the dashboard kit when you need a board of charts that readers can rearrange and resize. Declare widgets as data; the kit owns the pack grid and its gestures. The examples below draw all six shipped widget kinds, add a custom note, and save committed layout changes in browser storage.
1. Declare the board and its toolbar actions
Put this shared file beside your framework component. Use DashboardWidgetSpec for each widget and DashboardViewSpec for each board. Pass either views or the single-view shorthand widgets, not both. Multiple views show one board at a time; your application supplies the tab buttons.
The six kind values select the shipped painters. You supply the numbers and formatting, not a charting dependency:
| Kind | Data used in this example | What renders |
|---|---|---|
kpi | label, value, spark | Headline value and sparkline in this initial layout |
line | Named series and labels | Lines over a shared x axis |
bar | bars with labels and values | Categorical columns |
donut | slices, centerLabel | Parts of a whole with a legend |
funnel | Ordered stages | Stage bars scaled against the first stage |
table | columns, rows | A table of strings and numbers |
DashboardOptions configures the board. DashboardHandle is the live façade; widget() returns a WidgetHandle, or undefined for an unknown id. Use these handles for edits rather than rebuilding node models.
tsimport type {
DashboardHandle,
DashboardOptions,
DashboardSnapshot,
DashboardViewSpec,
DashboardWidgetSpec,
} from '@grafloria/element';
const storageKey = 'sales-dashboard';
let nextWidgetId = 0;
export function initialBoard(): DashboardSnapshot {
const widgets: DashboardWidgetSpec[] = [
{ id: 'revenue', kind: 'kpi', span: 6, rows: 1,
data: { label: 'Revenue', value: '$6.8M',
spark: [42, 45, 51, 55, 61, 76] } },
{ id: 'note', kind: 'note', span: 6, rows: 1,
title: 'Quarterly review', data: { text: 'Review the pipeline on Friday.' } },
{ id: 'trend', kind: 'line', span: 6, rows: 2, title: 'Revenue trend',
data: { series: [{ name: 'Revenue', values: [42, 51, 76] }],
labels: ['Jan', 'Feb', 'Mar'] } },
{ id: 'regions', kind: 'bar', span: 6, rows: 2, title: 'Revenue by region',
data: { bars: [{ label: 'EMEA', value: 29 }, { label: 'AMER', value: 24 },
{ label: 'APAC', value: 15 }] } },
{ id: 'share', kind: 'donut', span: 6, rows: 2, title: 'Region share',
data: { slices: [{ label: 'EMEA', value: 29 }, { label: 'AMER', value: 24 },
{ label: 'APAC', value: 15 }], centerLabel: '$6.8M' } },
{ id: 'pipeline', kind: 'funnel', span: 6, rows: 2, title: 'Pipeline',
data: { stages: [{ label: 'Leads', value: 1200 },
{ label: 'Qualified', value: 820 }, { label: 'Won', value: 188 }] } },
{ id: 'reps', kind: 'table', span: 12, rows: 2, title: 'Top reps',
data: { columns: ['Rep', 'Deals', 'Revenue'],
rows: [['A. Farouk', 38, '$1.24M'], ['M. Haddad', 31, '$0.98M']] } },
];
const views: DashboardViewSpec[] = [{ id: 'overview', name: 'Overview', widgets }];
const options: DashboardOptions = {
columns: 12, gap: 8, mode: 'fluid', sizing: 'fit',
};
return { ...options, views };
}
export function loadBoard(): DashboardSnapshot {
if (typeof window === 'undefined') return initialBoard();
const text = localStorage.getItem(storageKey);
if (!text) return initialBoard();
try {
const saved: DashboardSnapshot = JSON.parse(text);
return saved;
} catch {
return initialBoard();
}
}
export function saveBoard(handle: DashboardHandle): void {
localStorage.setItem(storageKey, JSON.stringify(handle.toJSON()));
}
export const actions = ['Update revenue', 'Add KPI', 'Remove trend', 'Pin revenue', 'Save'] as const;
export type Action = typeof actions[number];
export function performAction(handle: DashboardHandle | undefined, action: Action): void {
if (!handle) return;
switch (action) {
case 'Update revenue':
handle.widget('revenue')?.update({
data: { label: 'Revenue', value: '$7.2M',
spark: [42, 45, 51, 55, 61, 80] },
});
break;
case 'Add KPI': {
let id: string;
do { id = `added-kpi-${++nextWidgetId}`; } while (handle.widget(id));
const added = handle.addWidget({
id, kind: 'kpi', span: 3, rows: 1,
data: { label: 'New customers', value: '128' },
});
if (!added) window.alert('The board cannot accept another widget.');
break;
}
case 'Remove trend':
handle.widget('trend')?.remove();
break;
case 'Pin revenue':
handle.widget('revenue')?.pin(true);
break;
case 'Save':
break;
}
saveBoard(handle);
}
update() replaces data and repaints the widget; it does not merge the previous payload. addWidget() creates the widget and returns its handle, or undefined when the board is unknown or a bounded fit board has no room. remove() removes the widget and records the survivors' re-pack as one undoable step. pin(true) keeps Revenue from moving or being pushed during reflow.
The toolbar saves after each action. In particular, update() repaints directly rather than issuing a layout command, so this example saves its data change explicitly. Layout gestures use the component's layout-change event in the next step.
2. Mount it in your framework
Run these examples in your own browser application. Each tab uses board.ts above, loads saved data before mounting, and holds the handle using its framework's idiom. The board starts with a Revenue KPI, five other shipped painters and a custom note. Use Add KPI to create a New customers card; the shared action allocates its id with an increasing counter and checks existing widgets before adding it. The other buttons update Revenue, remove the trend, pin Revenue and save.
For JavaScript, dashboard returns the spec and render mounts it. Delegate all non-note kinds to defaultWidgetRenderer.
React's GrafloriaDashboard maps kinds to components through widgetTypes. Its WidgetProps type comes from the React package reference. Vue's GrafloriaDashboard uses #widget-<kind> slots. Angular's GrafloriaDashboardComponent uses a grafloriaWidget template declared with GrafloriaWidgetDefDirective from the Angular package. Qwik's GrafloriaDashboard maps kinds to self-contained Qwik components; its WidgetProps type comes from the Qwik package reference.
Install the packages for your framework.
JavaScript:
bashnpm install @grafloria/element @grafloria/engine @grafloria/renderer
React:
bashnpm install @grafloria/react @grafloria/element @grafloria/engine @grafloria/renderer react react-dom
Vue:
bashnpm install @grafloria/vue @grafloria/element @grafloria/engine @grafloria/renderer vue
Angular:
bashnpm install @grafloria/angular @grafloria/element @grafloria/engine @grafloria/renderer @angular/common @angular/core @angular/forms @angular/platform-browser rxjs
Qwik:
bashnpm install @grafloria/qwik @grafloria/element @grafloria/engine @grafloria/renderer @builder.io/qwik
ts// dashboard-page.ts — call mountDashboard() with a mounted page element.
import { dashboard, render, defaultWidgetRenderer } from '@grafloria/element';
import { actions, performAction, loadBoard, saveBoard } from './board';
export function mountDashboard(page: HTMLElement): () => void {
const toolbar = document.createElement('nav');
const container = document.createElement('div');
container.style.height = '720px';
page.append(toolbar, container);
const spec = dashboard({
...loadBoard(),
renderWidget(widget, host) {
if (widget.kind !== 'note') {
defaultWidgetRenderer(widget, host);
return;
}
const note = document.createElement('p');
note.textContent = String(widget.data?.['text'] ?? '');
note.style.padding = '16px';
host.replaceChildren(note);
},
onLayoutChange() { saveBoard(spec.handle); },
});
const instance = render(spec, container);
for (const action of actions) {
const button = document.createElement('button');
button.textContent = action;
button.onclick = () => performAction(spec.handle, action);
toolbar.append(button);
}
return () => {
instance.dispose();
toolbar.remove();
container.remove();
};
}
const page = document.getElementById('app');
if (!page) throw new Error('Add an element with id="app" to your page.');
// Keep this callback for your router's unmount hook.
export const unmountDashboard = mountDashboard(page);
tsx// DashboardPage.tsx
import { useRef, useState } from 'react';
import { GrafloriaDashboard, type WidgetProps } from '@grafloria/react';
import type { DashboardHandle } from '@grafloria/element';
import { actions, performAction, loadBoard, saveBoard } from './board';
function Note({ data }: WidgetProps) {
return <p style={{ padding: 16 }}>{String(data['text'] ?? '')}</p>;
}
export default function DashboardPage() {
const [board] = useState(loadBoard);
const handle = useRef<DashboardHandle | undefined>(undefined);
return <>
<nav>{actions.map(action => <button key={action}
onClick={() => performAction(handle.current, action)}>{action}</button>)}</nav>
<div style={{ height: 720 }}>
<GrafloriaDashboard views={board.views} options={board}
widgetTypes={{ note: Note }}
onReady={value => { handle.current = value; }}
onLayoutChange={() => { if (handle.current) saveBoard(handle.current); }} />
</div>
</>;
}
vue<!-- DashboardPage.vue --> <script setup lang="ts"> import { shallowRef } from 'vue'; import { GrafloriaDashboard } from '@grafloria/vue'; import type { DashboardHandle } from '@grafloria/element'; import { actions, performAction, loadBoard, saveBoard } from './board'; const board = loadBoard(); const handle = shallowRef<DashboardHandle>(); function ready(value: DashboardHandle): void { handle.value = value; } function persist(): void { if (handle.value) saveBoard(handle.value); } </script> <template> <nav> <button v-for="action in actions" :key="action" @click="performAction(handle, action)">{{ action }}</button> </nav> <div style="height:720px"> <GrafloriaDashboard :views="board.views" :options="board" @ready="ready" @layout-change="persist"> <template #widget-note="{ data }"> <p style="padding:16px">{{ String(data.text ?? '') }}</p> </template> </GrafloriaDashboard> </div> </template>
ts// dashboard-page.component.ts
import { Component } from '@angular/core';
import { GrafloriaDashboardComponent, GrafloriaWidgetDefDirective } from '@grafloria/angular';
import type { DashboardHandle } from '@grafloria/element';
import { actions, performAction, loadBoard, saveBoard } from './board';
@Component({
selector: 'app-dashboard-page',
standalone: true,
imports: [GrafloriaDashboardComponent, GrafloriaWidgetDefDirective],
template: `
<nav>
@for (action of actions; track action) {
<button (click)="performAction(handle, action)">{{ action }}</button>
}
</nav>
<grafloria-dashboard [views]="board.views" [options]="board"
(ready)="handle = $event" (layoutChange)="persist()"
style="height:720px">
<ng-template grafloriaWidget="note" let-data="data">
<p style="padding:16px">{{ data['text'] }}</p>
</ng-template>
</grafloria-dashboard>
`,
})
export class DashboardPageComponent {
readonly board = loadBoard();
readonly actions = actions;
readonly performAction = performAction;
handle?: DashboardHandle;
persist(): void { if (this.handle) saveBoard(this.handle); }
}
tsx// dashboard-page.tsx
import { component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaDashboard, type WidgetProps } from '@grafloria/qwik';
import type { DashboardHandle, DashboardSnapshot } from '@grafloria/element';
import { actions, performAction, loadBoard, saveBoard } from './board';
const Note = component$<WidgetProps>(({ data }) =>
<p style={{ padding: '16px' }}>{String(data['text'] ?? '')}</p>
);
export default component$(() => {
const board = useSignal<DashboardSnapshot>();
const handle = useSignal<NoSerialize<DashboardHandle>>();
useVisibleTask$(() => { board.value = loadBoard(); });
return <>
<nav>{actions.map(action => <button key={action}
onClick$={() => performAction(handle.value, action)}>{action}</button>)}</nav>
<div style={{ height: '720px' }}>
{board.value && <GrafloriaDashboard views={board.value.views} options={board.value}
widgetTypes={{ note: Note }}
onReady$={value => { handle.value = noSerialize(value); }}
onLayoutChange$={() => { if (handle.value) saveBoard(handle.value); }} />}
</div>
</>;
});
The JavaScript sample starts with the six shipped widget kinds and a note beside Revenue.
React renders the same board with the note component alongside the KPI.
Vue supplies the review note through its widget slot.
Angular supplies the note through its widget template.
Qwik renders the board after loading browser storage.
This example omits the percentage change to match the short initial KPI layout. If you supply data.delta, the shipped styles hide it at body heights of 40px or less; the short, wide layout can still show the sparkline.
The framework components dispose their mounted instances on unmount. In JavaScript, call the returned cleanup callback from your application's unmount hook. Qwik loads browser storage in useVisibleTask$ and keeps the live handle with noSerialize(); see Qwik state and resumption.
3. Save committed changes and reopen the board
Drag or resize a widget: the layout-change callback receives the changed view id and its widget specs after the commit. Save handle.toJSON() there, as the samples do, to keep every view and the live board options, not only the changed view. The same reporting path follows layout commands, undo and redo; it suppresses duplicate widget layouts and does not report the initial mount as an edit.
DashboardSnapshot is dashboard input data. Reload the page to run loadBoard() and mount that snapshot again: JavaScript passes it to dashboard(), while each component receives its views and options. Changing a React views prop after mount is not a replacement operation—the binding mounts once and sends data edits through the handle.
The snapshot saves widget data, cells, membership and per-view layouts, plus live sizing and board switches. It does not save the active top-level view, widget selection, keyboard focus, camera position or command history. Reopening starts a new instance. Reattach custom renderers, framework components/templates and callbacks in application code, as these samples do; JSON storage does not preserve functions. loadBoard() reads data written by this application, not untrusted imports—validate externally supplied JSON before using it.
Options that matter
| Option | Type | Default | What it does |
|---|---|---|---|
columns | number | 12 | Sets the column count |
gap | number | 8 | Sets widget gaps and board padding in pixels |
layout | 'grid' | 'split' | 'grid' | Uses cells or a splitter tree; split always fits its pane |
mode | 'fluid' | 'fixed' | Fixed when width is supplied; otherwise fluid | Fluid uses container pixels; fixed uses an authored board size and camera |
sizing | 'fit' | 'grow' | Grow for fluid; fit for fixed | Fit squeezes rows into the board height; grow keeps row height and extends downward |
rowHeight | number | 130 | Sets row height in grow mode |
static | boolean | false | Disables pointer drag, resize and handles |
Widget span / rows | number | 3 / 1 | Sizes a widget in cells |
Widget movable / resizable | boolean | true / true | Restricts user movement/resizing; API movement/resizing remains available |
Pitfalls
- Omit
xandyto flow widgets in declaration order. Supply both for an explicit cell. - Missing or invalid painter data produces an empty state. Unknown kinds produce a titled placeholder. Register only your custom kinds; leave shipped kinds to the fallback painter.
renderWidgetis a mount hook, not a per-frame callback. Useupdate()for a new payload orrepaint()after changing data. See JavaScript elements and content for host lifetime details.- Check the boolean returned by
moveTo()orresize()when adding those controls:truemeans the board accepts the cell operation. They do not promise that every requested position or size is legal. - For container sizing, split layouts and nested tab pages, continue with Arrange dashboard containers.
Live demo and related pages
Try the dashboard builder for a larger palette and multi-view toolbar. Its source shows the same data-first authoring pattern.
- Commands and history explains the shared undo stack.
- Theme a canvas covers container sizing and themes.
- Documents and kits covers the shared document format beyond a dashboard snapshot.
Was this page helpful?