Skip to content
D
Documentation

Build a dashboard

how-to
5 min readUpdated

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:

KindData used in this exampleWhat renders
kpilabel, value, sparkHeadline value and sparkline in this initial layout
lineNamed series and labelsLines over a shared x axis
barbars with labels and valuesCategorical columns
donutslices, centerLabelParts of a whole with a legend
funnelOrdered stagesStage bars scaled against the first stage
tablecolumns, rowsA 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.

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 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:

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
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);

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.

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

OptionTypeDefaultWhat it does
columnsnumber12Sets the column count
gapnumber8Sets 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 fluidFluid uses container pixels; fixed uses an authored board size and camera
sizing'fit' | 'grow'Grow for fluid; fit for fixedFit squeezes rows into the board height; grow keeps row height and extends downward
rowHeightnumber130Sets row height in grow mode
staticbooleanfalseDisables pointer drag, resize and handles
Widget span / rowsnumber3 / 1Sizes a widget in cells
Widget movable / resizablebooleantrue / trueRestricts 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 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.

Try the dashboard builder for a larger palette and multi-view toolbar. Its source shows the same data-first authoring pattern.

Was this page helpful?

Build a dashboard — Grafloria