# Build dashboards

Declare dashboard views and widgets as data, mount the board in your framework, then switch views or layout and save the live board as a snapshot.

## When to use the dashboard kit

Use [`GrafloriaDashboard`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik) (React and Vue) or [`GrafloriaDashboardComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-grafloriadashboardcomponent) (Angular) when people arrange widgets in grid cells. A dashboard is data first: views contain widget specs, and the kit supplies the grid, drag and resize behavior, and undo. The same headless model drives every framework binding. Plain JavaScript builds a spec with [`dashboard()`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-functions) and mounts it with [`render()`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core); framework bindings expose a live [`DashboardHandle`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardhandle), whose snapshot has the [`DashboardSnapshot`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-types) shape. Widget declarations use [`DashboardWidgetSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardwidgetspec).

The built-in widget renderers draw `kpi`, `line`, `bar`, `donut`, `funnel`, and `table` widgets from their `data`; start with those before supplying custom rendering. The examples below use KPI, line, and donut data.

See the [live dashboard builder](https://grafloria.com/demos/dashboard/dashboard-builder.html) for a multi-view board with built-in widgets. The [demo source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/dashboard/dashboard-builder.html) shows its full builder UI.

## Declare and mount the board

Each view has an `id` and a `widgets` array; each widget needs an `id` and can name a renderer `kind`, cell `span` and `rows`, and renderer data. With no explicit `x` and `y`, widgets flow in declaration order. `span` defaults to 3 columns and `rows` to 1. The framework components mount the board from these specs; give their host a height so the rendered dashboard has room.

The following examples declare two real views and mount the same board in each supported framework. The initial board displays KPI cards and a donut in Overview; the Revenue view has its own line and KPI widgets.

:::code-group
```js title="JavaScript"
import { dashboard, render } from '@grafloria/element';

const root = document.createElement('main');
const nav = document.createElement('nav');
const overviewButton = document.createElement('button');
overviewButton.textContent = 'Overview';
const revenueButton = document.createElement('button');
revenueButton.textContent = 'Revenue';
const layoutButton = document.createElement('button');
layoutButton.textContent = 'Switch layout';
const saveButton = document.createElement('button');
saveButton.textContent = 'Save board';
nav.append(overviewButton, revenueButton, layoutButton, saveButton);
const canvas = document.createElement('div');
canvas.style.height = '560px';
root.append(nav, canvas);
document.body.append(root);

const views = [
  { id: 'overview', name: 'Overview', widgets: [
    { id: 'sales-kpi', kind: 'kpi', span: 3, rows: 1,
      data: { label: 'Sales', value: '$6.8M', delta: 12.4, spark: [4.2, 4.5, 5.1, 6.8] } },
    { id: 'customers-kpi', kind: 'kpi', span: 3, rows: 1,
      data: { label: 'Customers', value: '1,284', delta: 8.1, spark: [980, 1090, 1150, 1284] } },
    { id: 'region-mix', kind: 'donut', span: 6, rows: 2, title: 'Sales by region',
      data: { slices: [{ label: 'EMEA', value: 2.9 }, { label: 'Americas', value: 2.4 }, { label: 'APAC', value: 1.5 }] } },
  ] },
  { id: 'revenue', name: 'Revenue', widgets: [
    { id: 'revenue-line', kind: 'line', span: 8, rows: 2, title: 'Revenue trend',
      data: { series: [{ name: 'Revenue', values: [4.2, 4.5, 5.1, 6.8] }], labels: ['Jan', 'Feb', 'Mar', 'Apr'] } },
    { id: 'revenue-kpi', kind: 'kpi', span: 4, rows: 1,
      data: { label: 'Quarter total', value: '$6.8M', delta: 12.4 } },
  ] },
];

const saved = localStorage.getItem('sales-dashboard');
const spec = dashboard(saved ? JSON.parse(saved) : { columns: 12, views });
const instance = render(spec, canvas);
const handle = spec.handle;
overviewButton.addEventListener('click', () => handle.showView('overview'));
revenueButton.addEventListener('click', () => handle.showView('revenue'));
layoutButton.addEventListener('click', () => {
  const next = handle.getLayout() === 'grid' ? 'split' : 'grid';
  handle.setLayout(next);
});
saveButton.addEventListener('click', () => {
  localStorage.setItem('sales-dashboard', JSON.stringify(handle.toJSON()));
});
window.addEventListener('pagehide', () => instance.dispose(), { once: true });
```
```ts title="Angular"
import { Component } from '@angular/core';
import { GrafloriaDashboardComponent } from '@grafloria/angular';
import type { DashboardHandle, DashboardViewSpec } from '@grafloria/element';

@Component({
  selector: 'app-sales-dashboard',
  standalone: true,
  imports: [GrafloriaDashboardComponent],
  template: `
    <nav>
      <button (click)="tab = 'overview'">Overview</button>
      <button (click)="tab = 'revenue'">Revenue</button>
      <button (click)="switchLayout()">Switch layout</button>
      <button (click)="save()">Save board</button>
    </nav>
    <grafloria-dashboard [views]="views" [options]="options" [(activeView)]="tab"
      (ready)="handle = $event" style="display:block; height:560px" />
  `,
})
export class SalesDashboardComponent {
  tab: string | undefined = 'overview';
  handle?: DashboardHandle;
  options = { columns: 12, gap: 8 };
  views: DashboardViewSpec[] = [
    { id: 'overview', name: 'Overview', widgets: [
      { id: 'sales-kpi', kind: 'kpi', span: 3, rows: 1,
        data: { label: 'Sales', value: '$6.8M', delta: 12.4, spark: [4.2, 4.5, 5.1, 6.8] } },
      { id: 'customers-kpi', kind: 'kpi', span: 3, rows: 1,
        data: { label: 'Customers', value: '1,284', delta: 8.1, spark: [980, 1090, 1150, 1284] } },
      { id: 'region-mix', kind: 'donut', span: 6, rows: 2, title: 'Sales by region',
        data: { slices: [{ label: 'EMEA', value: 2.9 }, { label: 'Americas', value: 2.4 }, { label: 'APAC', value: 1.5 }] } },
    ] },
    { id: 'revenue', name: 'Revenue', widgets: [
      { id: 'revenue-line', kind: 'line', span: 8, rows: 2, title: 'Revenue trend',
        data: { series: [{ name: 'Revenue', values: [4.2, 4.5, 5.1, 6.8] }], labels: ['Jan', 'Feb', 'Mar', 'Apr'] } },
      { id: 'revenue-kpi', kind: 'kpi', span: 4, rows: 1,
        data: { label: 'Quarter total', value: '$6.8M', delta: 12.4 } },
    ] },
  ];

  switchLayout(): void {
    const handle = this.handle;
    if (handle) handle.setLayout(handle.getLayout() === 'grid' ? 'split' : 'grid');
  }

  save(): void {
    const snapshot = this.handle?.toJSON();
    if (snapshot) localStorage.setItem('sales-dashboard', JSON.stringify(snapshot));
  }
}
```
```tsx title="React"
import { useState } from 'react';
import { GrafloriaDashboard } from '@grafloria/react';
import type { DashboardHandle, DashboardViewSpec } from '@grafloria/element';

const views: DashboardViewSpec[] = [
  { id: 'overview', name: 'Overview', widgets: [
    { id: 'sales-kpi', kind: 'kpi', span: 3, rows: 1,
      data: { label: 'Sales', value: '$6.8M', delta: 12.4, spark: [4.2, 4.5, 5.1, 6.8] } },
    { id: 'customers-kpi', kind: 'kpi', span: 3, rows: 1,
      data: { label: 'Customers', value: '1,284', delta: 8.1, spark: [980, 1090, 1150, 1284] } },
    { id: 'region-mix', kind: 'donut', span: 6, rows: 2, title: 'Sales by region',
      data: { slices: [{ label: 'EMEA', value: 2.9 }, { label: 'Americas', value: 2.4 }, { label: 'APAC', value: 1.5 }] } },
  ] },
  { id: 'revenue', name: 'Revenue', widgets: [
    { id: 'revenue-line', kind: 'line', span: 8, rows: 2, title: 'Revenue trend',
      data: { series: [{ name: 'Revenue', values: [4.2, 4.5, 5.1, 6.8] }], labels: ['Jan', 'Feb', 'Mar', 'Apr'] } },
    { id: 'revenue-kpi', kind: 'kpi', span: 4, rows: 1,
      data: { label: 'Quarter total', value: '$6.8M', delta: 12.4 } },
  ] },
];

export default function SalesDashboard() {
  const [handle, setHandle] = useState<DashboardHandle>();
  const [tab, setTab] = useState('overview');
  const [layout, setLayout] = useState<'grid' | 'split'>('grid');

  return (
    <main>
      <nav>
        <button onClick={() => setTab('overview')}>Overview</button>
        <button onClick={() => setTab('revenue')}>Revenue</button>
        <button onClick={() => setLayout(layout === 'grid' ? 'split' : 'grid')}>Switch layout</button>
        <button onClick={() => {
          if (handle) localStorage.setItem('sales-dashboard', JSON.stringify(handle.toJSON()));
        }}>Save board</button>
      </nav>
      <GrafloriaDashboard views={views} activeView={tab} layout={layout} onReady={setHandle}
        style={{ display: 'block', height: 560 }} />
    </main>
  );
}
```
```vue title="Vue"
<script setup lang="ts">
import { ref } from 'vue';
import { GrafloriaDashboard } from '@grafloria/vue';
import type { DashboardHandle, DashboardViewSpec } from '@grafloria/element';

const handle = ref<DashboardHandle | null>(null);
const tab = ref('overview');
const layout = ref<'grid' | 'split'>('grid');
const views: DashboardViewSpec[] = [
  { id: 'overview', name: 'Overview', widgets: [
    { id: 'sales-kpi', kind: 'kpi', span: 3, rows: 1,
      data: { label: 'Sales', value: '$6.8M', delta: 12.4, spark: [4.2, 4.5, 5.1, 6.8] } },
    { id: 'customers-kpi', kind: 'kpi', span: 3, rows: 1,
      data: { label: 'Customers', value: '1,284', delta: 8.1, spark: [980, 1090, 1150, 1284] } },
    { id: 'region-mix', kind: 'donut', span: 6, rows: 2, title: 'Sales by region',
      data: { slices: [{ label: 'EMEA', value: 2.9 }, { label: 'Americas', value: 2.4 }, { label: 'APAC', value: 1.5 }] } },
  ] },
  { id: 'revenue', name: 'Revenue', widgets: [
    { id: 'revenue-line', kind: 'line', span: 8, rows: 2, title: 'Revenue trend',
      data: { series: [{ name: 'Revenue', values: [4.2, 4.5, 5.1, 6.8] }], labels: ['Jan', 'Feb', 'Mar', 'Apr'] } },
    { id: 'revenue-kpi', kind: 'kpi', span: 4, rows: 1,
      data: { label: 'Quarter total', value: '$6.8M', delta: 12.4 } },
  ] },
];

function switchLayout(): void {
  const current = handle.value;
  if (current) layout.value = current.getLayout() === 'grid' ? 'split' : 'grid';
}

function save(): void {
  const snapshot = handle.value?.toJSON();
  if (snapshot) localStorage.setItem('sales-dashboard', JSON.stringify(snapshot));
}
</script>

<template>
  <main>
    <nav>
      <button @click="tab = 'overview'">Overview</button>
      <button @click="tab = 'revenue'">Revenue</button>
      <button @click="switchLayout">Switch layout</button>
      <button @click="save">Save board</button>
    </nav>
    <GrafloriaDashboard :views="views" v-model:active-view="tab" :layout="layout"
      @ready="handle = $event" style="display:block; height:560px" />
  </main>
</template>
```
:::

The JavaScript, Angular, React, and Vue examples mount with Overview visible. Their built-in painters render the KPI, donut, and line cards from their data, and the board fits them into grid cells. Choose Revenue to frame the other view; Switch layout toggles the board between the cell grid and split layout. Save board stores the live snapshot in `localStorage`.

## Switch views and layout

The framework components keep view selection in their `activeView` prop or model. Their `layout` prop is a live switch: changing it applies a handle call without remounting. For JavaScript, get the live handle from the dashboard spec and call `showView(id)` or `setLayout('grid' | 'split')` directly. In every binding, choose a declared view id; `DashboardHandle` exposes `views` in declaration order and `activeView` as the current id.

The JavaScript sample uses [`dashboard()`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-functions) to create the render spec, then [`render()`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) to mount it. `spec.handle` is the mounted [`DashboardHandle`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardhandle): `setLayout()` switches the active view between grid and split, while `showView()` frames the requested view.

## Persist the live board

Call `toJSON()` on the handle after edits to capture the live board as plain data, then serialize that snapshot with your application's storage. The snapshot includes the current view layouts and board options; it excludes function callbacks. Restore it by passing the saved data back into `dashboard()` in JavaScript. Supply any non-serializable rendering callbacks again when rebuilding. In Angular, `snapshot()` returns the same data as `toJSON()`.

The JavaScript, Angular, React, and Vue examples demonstrate saving to browser `localStorage`. Use storage appropriate to your application when the board must survive a browser change or be shared between users. A saved snapshot has the [`DashboardSnapshot`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-types) shape.

## Options that shape the board

Pass board geometry and behavior through `options`. A view can also override its column count. Widget `span` and `rows` describe its cell size, while optional `x` and `y` place it explicitly.

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `columns` | `number` | `12` | Sets the column count for every view unless a view overrides it. |
| `gap` | `number` | `8` | Sets the gap between widgets and the board padding, in pixels. |
| `sizing` | `'fit' \| 'grow'` | `'grow'` on fluid boards; `'fit'` on fixed boards | `grow` keeps row height and extends the board; `fit` keeps the board height and squeezes rows. |
| `layout` | `'grid' \| 'split'` | `'grid'` | Chooses a cell grid or a splitter tree. Switch it live with the handle or component prop. |
| `rowHeight` | `number` | `130` | Sets row height in `grow` mode, in pixels. |
| `float` | `boolean` | `false` | With `false`, gravity packs widgets upward; with `true`, gaps can remain where widgets are dropped. |
| `width`, `height` | `number` | `1180 × 660` | Set a fixed board size. An explicit `width` selects fixed mode. |
| `static` | `boolean` | `false` | Turns off pointer dragging, resizing, and handles for a viewer board. |

On [`DashboardWidgetSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardwidgetspec), `id` identifies a widget, `kind` selects its renderer, `data` carries its payload, and `title` supplies a title. `pinned: true` prevents reflow from moving that widget. The six built-in kinds need no custom renderer; unknown kinds use a titled placeholder frame.

## Pitfalls

- Give the dashboard host a resolved height; otherwise its canvas has no space to draw into.
- `views` and the single-view `widgets` shorthand are mutually exclusive. Use `views` when people switch between boards.
- Framework wrappers mount the board once; changing data or `options` afterward does not rebuild it. Use live props for `activeView`, `layout`, and `sizing`, and use the handle for live board operations.
- React custom widgets use `widgetTypes`, not `options.renderWidget`; Vue uses `widget-<kind>` slots and Angular uses `grafloriaWidget` templates. This page uses built-in renderers instead.

## Live demo

Open the [dashboard builder](https://grafloria.com/demos/dashboard/dashboard-builder.html) to see its tabs, built-in widget cards, and dashboard layout. Read the [demo source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/dashboard/dashboard-builder.html) for the full palette and persistence controls.

## Related

- [Dashboard handle reference](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardhandle)
- [Dashboard options reference](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardoptions)
- [Dashboard widget spec reference](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardwidgetspec)
- [Dashboard kit functions](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-functions)
