# Build a dashboard

Declare widgets as data, mount the board, then use its handle for view changes, layout changes, programmatic moves and persistence. The result is a live board whose built-in widget painters draw KPI, line, donut, bar, funnel and table widgets; users can drag and resize it when the board is not static.

## When to use this

Use the dashboard kit when your page needs a grid of independently movable widgets, more than one board view, or a saved layout. Give the mounted element a height: a dashboard in a zero-height container has no visible board.

## 1. Declare the board

[`dashboard`](https://bench-grafloria-56.atloria.app/p/bench-grafloria-56-62cg8vR5rF/developer/grafloria-element-dashboard-kit-functions#dashboard) takes [`DashboardOptions`](https://bench-grafloria-56.atloria.app/p/bench-grafloria-56-62cg8vR5rF/developer/grafloria-element-dashboard-kit-dashboardoptions#dashboardoptions) and returns a `DashboardSpec`. Each widget is a [`DashboardWidgetSpec`](https://bench-grafloria-56.atloria.app/p/bench-grafloria-56-62cg8vR5rF/developer/grafloria-element-dashboard-kit-dashboardwidgetspec#dashboardwidgetspec); each view follows [`DashboardViewSpec`](https://bench-grafloria-56.atloria.app/p/bench-grafloria-56-62cg8vR5rF/developer/grafloria-element-dashboard-kit-interfaces#dashboardviewspec). Omit `renderWidget` to use the shipped painters.

```ts title="board.ts"
import { dashboard, render } from '@grafloria/element';

const spec = dashboard({
  columns: 12,
  sizing: 'grow',
  views: [
    {
      id: 'overview',
      name: 'Overview',
      widgets: [
        {
          id: 'revenue',
          kind: 'kpi',
          span: 3,
          rows: 1,
          data: { label: 'Revenue', value: '$6.81M', delta: 12.4 },
        },
        {
          id: 'trend',
          kind: 'line',
          span: 9,
          rows: 2,
          title: 'Revenue trend',
          data: {
            series: [{ name: 'Revenue', values: [420, 455, 512] }],
            labels: ['Jan', 'Feb', 'Mar'],
          },
        },
      ],
    },
    {
      id: 'sales',
      name: 'Sales',
      widgets: [
        {
          id: 'regions',
          kind: 'donut',
          span: 6,
          rows: 2,
          title: 'By region',
          data: { slices: [{ label: 'EMEA', value: 1920 }, { label: 'APAC', value: 1340 }] },
        },
      ],
    },
  ],
});

const canvas = document.createElement('div');
canvas.id = 'dashboard';
canvas.style.display = 'block';
canvas.style.width = '1180px';
canvas.style.height = '660px';
document.body.append(canvas);
const instance = render(spec, canvas);
const handle = spec.handle;

handle.showView('sales');
handle.setLayout('grid');
handle.widget('regions')?.resize(8, 3);
localStorage.setItem('dashboard', JSON.stringify(handle.toJSON()));

window.addEventListener('unload', () => instance.dispose());
```

The `render()` call mounts the spec and returns a live diagram instance. The handle exposes the dashboard facade: `showView()` changes the on-camera view, `setLayout()` changes the active view between the cell grid and split layout, `widget()` finds one widget, and `resize()` accepts cell span and row count. The last line saves plain data, not a renderer object.

## 2. Mount it in each framework

The same `views` data drives all bindings. The examples below mount a board with a real height, switch between grid and split layouts, move and resize a widget through the typed handle, and save the snapshot.

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

const views = [{ id: 'main', name: 'Main', widgets: [
  { id: 'revenue', kind: 'kpi', span: 4, rows: 1, data: { label: 'Revenue', value: '$6.81M' } },
  { id: 'trend', kind: 'line', span: 8, rows: 2, title: 'Trend', data: { series: [[1, 2, 3]], labels: ['A', 'B', 'C'] } },
]}];
const spec = dashboard({ views, sizing: 'grow' });
const canvas = document.createElement('div');
canvas.id = 'dashboard';
canvas.style.display = 'block';
canvas.style.width = '1180px';
canvas.style.height = '660px';
document.body.append(canvas);
const instance = render(spec, canvas);
const handle = spec.handle;
handle.setLayout('split');
handle.widget('trend')?.moveTo(2, 0);
void handle.widget('trend')?.resize(6, 2);
localStorage.setItem('dashboard', JSON.stringify(handle.toJSON()));
window.addEventListener('unload', () => instance.dispose());
```
```tsx title="React"
import { useState } from 'react';
import { GrafloriaDashboard } from '@grafloria/react';
import type { DashboardHandle, DashboardViewSpec } from '@grafloria/element';

const views: DashboardViewSpec[] = [{ id: 'main', name: 'Main', widgets: [
  { id: 'revenue', kind: 'kpi', span: 4, rows: 1, data: { label: 'Revenue', value: '$6.81M' } },
  { id: 'trend', kind: 'line', span: 8, rows: 2, title: 'Trend', data: { series: [[1, 2, 3]], labels: ['A', 'B', 'C'] } },
]}];

export default function DashboardPage() {
  const [handle, setHandle] = useState<DashboardHandle>();
  const [layout, setLayout] = useState<'grid' | 'split'>('grid');
  const [status, setStatus] = useState('Ready');
  async function moveTrend() { setStatus('Moving trend…'); const accepted = await handle?.widget('trend')?.moveTo(2, 0); setStatus(accepted ? 'Trend moved to column 2' : 'Move refused'); }
  async function resizeTrend() { setStatus('Resizing trend…'); const accepted = await handle?.widget('trend')?.resize(6, 2); setStatus(accepted ? 'Trend resized to 6 columns by 2 rows' : 'Resize refused'); }
  return <div style={{ height: 500 }}>
    <button onClick={() => { const next = layout === 'grid' ? 'split' : 'grid'; setLayout(next); handle?.setLayout(next); }}>Switch layout</button>
    <button onClick={moveTrend}>Move trend</button>
    <button onClick={resizeTrend}>Resize trend</button>
    <button onClick={() => localStorage.setItem('dashboard', JSON.stringify(handle?.toJSON()))}>Save</button>
    <output>{status}</output>
    <GrafloriaDashboard views={views} layout={layout} onReady={setHandle} style={{ display: 'block', height: 440 }} />
  </div>;
}
```
```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 layout = ref<'grid' | 'split'>('grid');
const status = ref('Ready');
const views: DashboardViewSpec[] = [{ id: 'main', name: 'Main', widgets: [
  { id: 'revenue', kind: 'kpi', span: 4, rows: 1, data: { label: 'Revenue', value: '$6.81M' } },
  { id: 'trend', kind: 'line', span: 8, rows: 2, title: 'Trend', data: { series: [[1, 2, 3]], labels: ['A', 'B', 'C'] } },
]}];
function switchLayout() { layout.value = layout.value === 'grid' ? 'split' : 'grid'; handle.value?.setLayout(layout.value); }
function save() { localStorage.setItem('dashboard', JSON.stringify(handle.value?.toJSON())); }
async function moveTrend() { status.value = 'Moving trend…'; const accepted = await handle.value?.widget('trend')?.moveTo(2, 0); status.value = accepted ? 'Trend moved to column 2' : 'Move refused'; }
async function resizeTrend() { status.value = 'Resizing trend…'; const accepted = await handle.value?.widget('trend')?.resize(6, 2); status.value = accepted ? 'Trend resized to 6 columns by 2 rows' : 'Resize refused'; }
</script>
<template>
  <div style="height:500px">
    <button @click="switchLayout">Switch layout</button>
    <button @click="moveTrend">Move trend</button>
    <button @click="resizeTrend">Resize trend</button>
    <button @click="save">Save</button>
    <output>{{ status }}</output>
    <GrafloriaDashboard :views="views" :layout="layout" @ready="handle = $event" style="display:block;height:440px" />
  </div>
</template>
```
```ts title="Angular"
import { Component } from '@angular/core';
import { GrafloriaDashboardComponent } from '@grafloria/angular';
import type { DashboardHandle, DashboardViewSpec } from '@grafloria/element';

@Component({
  standalone: true,
  imports: [GrafloriaDashboardComponent],
  template: `
    <button (click)="switchLayout()">Switch layout</button>
    <button (click)="moveTrend()">Move trend</button>
    <button (click)="resizeTrend()">Resize trend</button>
    <button (click)="save()">Save</button>
    <output>{{ status }}</output>
    <grafloria-dashboard [views]="views" [layout]="layout" (ready)="handle = $event"
      style="display:block;height:440px" />
  `,
})
export class DashboardPage {
  handle?: DashboardHandle;
  layout: 'grid' | 'split' = 'grid';
  status = 'Ready';
  views: DashboardViewSpec[] = [{ id: 'main', name: 'Main', widgets: [
    { id: 'revenue', kind: 'kpi', span: 4, rows: 1, data: { label: 'Revenue', value: '$6.81M' } },
    { id: 'trend', kind: 'line', span: 8, rows: 2, title: 'Trend', data: { series: [[1, 2, 3]], labels: ['A', 'B', 'C'] } },
  ]}];
  switchLayout() { this.layout = this.layout === 'grid' ? 'split' : 'grid'; this.handle?.setLayout(this.layout); }
  async moveTrend() { this.status = 'Moving trend…'; const accepted = await this.handle?.widget('trend')?.moveTo(2, 0); this.status = accepted ? 'Trend moved to column 2' : 'Move refused'; }
  async resizeTrend() { this.status = 'Resizing trend…'; const accepted = await this.handle?.widget('trend')?.resize(6, 2); this.status = accepted ? 'Trend resized to 6 columns by 2 rows' : 'Resize refused'; }
  save() { localStorage.setItem('dashboard', JSON.stringify(this.handle?.toJSON())); }
}
```
```tsx title="Qwik"
// In a Qwik component, follow the dashboard demo's binding:
// import { GrafloriaDashboard } from '@grafloria/qwik';
// import { $, noSerialize, useSignal } from '@builder.io/qwik';
//
// <GrafloriaDashboard
//   views={views}
//   layout={layout.value}
//   sizing={sizing.value}
//   onReady$={$((value) => { handle.value = noSerialize(value); })}
// />
//
// The toolbar handlers call the same live handle as the other bindings:
// handle.value?.setLayout('split');
// await handle.value?.widget('trend')?.moveTo(2, 0);
// await handle.value?.widget('trend')?.resize(6, 2);
// localStorage.setItem('dashboard', JSON.stringify(handle.value?.toJSON()));
```
:::

In every binding, the board paints the declared cards inside the sized container. Pointer drag and corner resize use the same live grid as `moveTo()` and `resize()`; a committed change is reported by `onLayoutChange` (or the Angular/Vue output). Use that callback instead of maintaining a second copy of the layout.

## 3. Persist and restore the board

[`DashboardSnapshot`](https://bench-grafloria-56.atloria.app/p/bench-grafloria-56-62cg8vR5rF/developer/grafloria-element-dashboard-kit-types#dashboardsnapshot) is the whole board as plain data. Save `handle.toJSON()` after a user change. To restore it in JavaScript, pass the parsed snapshot back to [`dashboard`](https://bench-grafloria-56.atloria.app/p/bench-grafloria-56-62cg8vR5rF/developer/grafloria-element-dashboard-kit-functions#dashboard), supplying the rendering callback if you use custom widgets.

```ts
import { dashboard, render } from '@grafloria/element';
import type { DashboardSnapshot } from '@grafloria/element';

const canvas = document.createElement('div');
canvas.id = 'dashboard';
canvas.style.display = 'block';
canvas.style.width = '1180px';
canvas.style.height = '660px';
document.body.append(canvas);
const source = dashboard({ widgets: [
  { id: 'revenue', kind: 'kpi', span: 4, rows: 1, data: { label: 'Revenue', value: '$6.81M' } },
] });
const snapshot: DashboardSnapshot = source.handle.toJSON();
localStorage.setItem('dashboard', JSON.stringify(snapshot));
const saved = localStorage.getItem('dashboard');
if (!saved) throw new Error('Dashboard snapshot was not saved');
const restored = dashboard(JSON.parse(saved) as DashboardSnapshot);
render(restored, canvas);
```

For a controlled framework board, keep the snapshot in application state and provide its `views` and options to the component. Do not recreate the board for each gesture; use the ready handle and the layout-change callback.

## Options that matter

| 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 widget gap and board padding in pixels. |
| `sizing` | `'fit' \| 'grow'` | `'grow'` | `grow` keeps row height and extends the board; `fit` keeps the board height and squeezes rows. |
| `layout` | `'grid' \| 'split'` | `'grid'` | Selects the cell grid or splitter layout. |
| `rowHeight` | `number` | `130` | Sets row height in `grow` mode. |
| `float` | `boolean` | `false` | When false, gravity packs widgets upward; when true, gaps are allowed. |
| `rtl` | `boolean` | `false` | Mirrors pixels so column zero renders at the right edge without changing cells. |
| `responsive` | `DashboardResponsiveOptions` | — | Derives the live column count from board width. |
| `static` | `boolean` | `false` | Disables pointer drag, resize and handles while API and keyboard access remain available. |
| `views` / `widgets` | `DashboardViewSpec[]` / `DashboardWidgetSpec[]` | — | Use `views` for tabs, or `widgets` as shorthand for one unnamed view; they are mutually exclusive. |

## Pitfalls

- Give the host a height. The kit cannot paint a useful board into a zero-height element.
- `fit` is bounded: a drop, resize or `addWidget()` that needs another row is refused. Use `grow` or `overflow: 'scroll'` when the board must accommodate more rows.
- `static` is a viewing mode. It removes drag, resize and handles; it does not remove API operations.
- `width` and `height` are fixed-board dimensions. A fluid board follows its container instead.
- A widget with `pinned: true` refuses the mover and survives reflow. Use `movable: false` or `resizable: false` when only one gesture must be disabled.

## Live demos

- [Dashboard builder](https://grafloria.com/demos/dashboard/dashboard-builder.html) shows tabbed views, built-in widget kinds, palette additions, pinning, fit/grow, layout switching and versioned saves.

- [Fluid dashboard](https://grafloria.com/demos/dashboard/fluid-board.html) shows fluid sizing, grid and split layouts, drag-handle modes and live additions.

## Related

- [Dashboards in plain JavaScript](https://grafloria.com/learn/javascript-dashboards/)
- [How Grafloria works](https://bench-grafloria-56.atloria.app/p/bench-grafloria-56-62cg8vR5rF/developer/how-grafloria-works)
- [Commands and shared history](https://bench-grafloria-56.atloria.app/p/bench-grafloria-56-62cg8vR5rF/developer/commands-and-shared-history)
