# Build a dashboard

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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-dashboardwidgetspec#dashboardwidgetspec) for each widget and [`DashboardViewSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-interfaces#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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-dashboardoptions#dashboardoptions) configures the board. [`DashboardHandle`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-dashboardhandle#dashboardhandle) is the live façade; `widget()` returns a [`WidgetHandle`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-widgethandle#widgethandle), or `undefined` for an unknown id. Use these handles for edits rather than rebuilding node models.

```ts title="board.ts"
import 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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-functions#dashboard) returns the spec and [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#render) mounts it. Delegate all non-note kinds to [`defaultWidgetRenderer`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-constants#defaultwidgetrenderer).

React's [`GrafloriaDashboard`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#grafloriadashboard) maps kinds to components through `widgetTypes`. Its `WidgetProps` type comes from the [React package reference](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react). Vue's [`GrafloriaDashboard`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriadashboard) uses `#widget-<kind>` slots. Angular's [`GrafloriaDashboardComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-grafloriadashboardcomponent#grafloriadashboardcomponent) uses a `grafloriaWidget` template declared with `GrafloriaWidgetDefDirective` from the [Angular package](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-overview). Qwik's [`GrafloriaDashboard`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriadashboard) maps kinds to self-contained Qwik components; its `WidgetProps` type comes from the [Qwik package reference](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik).

Install the packages for your framework.

JavaScript:

```bash
npm install @grafloria/element @grafloria/engine @grafloria/renderer
```

React:

```bash
npm install @grafloria/react @grafloria/element @grafloria/engine @grafloria/renderer react react-dom
```

Vue:

```bash
npm install @grafloria/vue @grafloria/element @grafloria/engine @grafloria/renderer vue
```

Angular:

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

Qwik:

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

:::code-group
```ts title="JavaScript"
// 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 title="React"
// 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 title="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 title="Angular"
// 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 title="Qwik"
// 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.

![JavaScript dashboard with the five toolbar buttons, Revenue, the review note, charts and Top reps table.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/ce19d8675245335d25c3cd6f1c0c6697.png)

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](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-types#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 `x` and `y` to 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.
- `renderWidget` is a mount hook, not a per-frame callback. Use `update()` for a new payload or `repaint()` after changing data. See [JavaScript elements and content](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-elements-and-content) for host lifetime details.
- Check the boolean returned by `moveTo()` or `resize()` when adding those controls: `true` means 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](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/arrange-dashboard-containers).

## Live demo and related pages

Try the [dashboard builder](https://grafloria.com/demos/dashboard/dashboard-builder.html) for a larger palette and multi-view toolbar. Its [source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/dashboard/dashboard-builder.html) shows the same data-first authoring pattern.

- [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history) explains the shared undo stack.
- [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas) covers container sizing and themes.
- [Documents and kits](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/documents-and-kits) covers the shared document format beyond a dashboard snapshot.
