# Arrange dashboard containers

Use a cell grid when widgets need column spans and gravity packing. Use a split layout when widgets need to cover the board and resize through percentage dividers. Both layouts use the same dashboard data; you can switch the mounted board without replacing its widgets.

The example starts with two KPI cards, an Operations section, and a tab container showing Filters. Its controls switch Grid/Split and Fit/Grow, mirror the board, toggle gravity, and change the section caption or active tab.

## 1. Declare the board

Install the packages for the binding you use in your framework project.

JavaScript:

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

Angular:

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

Qwik:

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

React:

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

Vue:

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

Put the following shared file beside your component or browser entry point. This example adds an inner grid and tab pages to the [`DashboardWidgetSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-dashboardwidgetspec) entries in a [`DashboardViewSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-interfaces); see [Build a dashboard](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/build-a-dashboard) for board and widget declarations.

The kit draws the `kpi` kind from your data, so this layout needs no chart renderer. [`DashboardOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-dashboardoptions) supplies the shared geometry. These options omit `width`: the board uses fluid sizing at zoom 1 and follows its container. `sizing: 'fit'` keeps its height bounded.

```ts title="board.ts"
import type {
  DashboardOptions,
  DashboardViewSpec,
  DashboardWidgetSpec,
} from '@grafloria/element';

const widgets: DashboardWidgetSpec[] = [
  {
    id: 'revenue', kind: 'kpi', span: 6, rows: 1,
    data: { label: 'Revenue', value: '$6.81M', delta: 12.4 },
  },
  {
    id: 'customers', kind: 'kpi', span: 6, rows: 1,
    data: { label: 'Customers', value: '1,284', delta: 8.1 },
  },
  {
    id: 'ops', title: 'Operations', span: 8, rows: 3,
    columns: 4, sizing: 'fit',
    caption: { subtitle: 'Today', description: 'Operational KPIs' },
    widgets: [
      {
        id: 'orders', kind: 'kpi', span: 2, rows: 1,
        data: { label: 'Orders', value: '312' },
      },
      {
        id: 'churn', kind: 'kpi', span: 2, rows: 1,
        data: { label: 'Churn', value: '1.9%' },
      },
    ],
  },
  {
    id: 'side', title: 'Side panel', span: 4, rows: 3,
    layout: 'tabs', active: 'filters', tabs: { stretch: true },
    widgets: [
      {
        id: 'filters', title: 'Filters', columns: 1, sizing: 'fit',
        widgets: [
          {
            id: 'region', kind: 'kpi', span: 1, rows: 1,
            data: { label: 'Region', value: 'EMEA' },
          },
        ],
      },
      {
        id: 'alerts', title: 'Alerts', columns: 1, sizing: 'fit',
        widgets: [
          {
            id: 'open-alerts', kind: 'kpi', span: 1, rows: 1,
            data: { label: 'Open alerts', value: '3' },
          },
        ],
      },
    ],
  },
];

export const views: DashboardViewSpec[] = [{ id: 'main', widgets }];
export const options: Partial<DashboardOptions> = {
  columns: 12,
  gap: 10,
  sizing: 'fit',
  responsive: { columnWidth: 80 },
  dragHandle: { grip: true, position: 'right', placement: 'inside' },
};
```

At narrower widths, `responsive` derives fewer columns, capped by `columns`. Widening again restores the cached wide layout. Omitted `x` and `y` place widgets in declaration order, wrapping at the column count.

## 2. Mount it and bind the switches

Use [`dashboard()`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-functions) and [`render()`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core) in JavaScript. `render()` mounts the spec and initializes its live [`DashboardHandle`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-dashboardhandle). Its return value is the renderer's [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance); keep that for teardown, and use the dashboard handle for board operations.

For framework components, use [`GrafloriaDashboardComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-grafloriadashboardcomponent) in Angular, [`GrafloriaDashboard`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriadashboard) in Qwik, [`GrafloriaDashboard`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#grafloriadashboard) in React, or [`GrafloriaDashboard`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriadashboard) in Vue. Each binding supplies the handle through its ready event. Bind `layout` and `sizing` as props or inputs: changes call the live handle rather than remounting the board.

Choose one tab below. The framework examples are components to render in your application; the JavaScript entry point runs in the browser. All import `./board` from step 1.

:::code-group
```ts title="JavaScript"
import { dashboard, render } from '@grafloria/element';
import { options, views } from './board';

export function mountDashboard(parent: HTMLElement): () => void {
  const wrapper = document.createElement('section');
  const toolbar = document.createElement('div');
  const host = document.createElement('div');
  host.style.height = '480px';
  wrapper.append(toolbar, host);
  parent.append(wrapper);

  const spec = dashboard({ ...options, views });
  const instance = render(spec, host);
  const handle = spec.handle;
  const button = (label: string, action: () => void): void => {
    const control = document.createElement('button');
    control.textContent = label;
    control.addEventListener('click', action);
    toolbar.append(control);
  };
  button('Grid', () => handle.setLayout('grid'));
  button('Split', () => handle.setLayout('split'));
  button('Fit', () => handle.setSizing('fit'));
  button('Grow', () => handle.setSizing('grow'));
  button('RTL', () => handle.setRtl(!handle.getRtl()));
  button('Float', () => handle.setFloat(!handle.getFloat()));
  button('Caption chip', () => {
    handle.setCaption('ops', { text: 'Operations', position: 'tab', icon: '▤' });
  });
  button('Alerts tab', () => { handle.activateTab('side', 'alerts'); });
  button('Frame board', () => handle.fit());

  return () => {
    instance.dispose();
    wrapper.remove();
  };
}

mountDashboard(document.body);
```
```ts title="Angular"
import { Component, signal } from '@angular/core';
import { GrafloriaDashboardComponent } from '@grafloria/angular';
import type { DashboardHandle } from '@grafloria/element';
import { options, views } from './board';

@Component({
  selector: 'app-arrange-dashboard',
  standalone: true,
  imports: [GrafloriaDashboardComponent],
  template: `
    <div>
      <button (click)="layout.set('grid')">Grid</button>
      <button (click)="layout.set('split')">Split</button>
      <button (click)="sizing.set('fit')">Fit</button>
      <button (click)="sizing.set('grow')">Grow</button>
      <button (click)="handle?.setRtl(!handle?.getRtl())">RTL</button>
      <button (click)="handle?.setFloat(!handle?.getFloat())">Float</button>
      <button (click)="captionChip()">Caption chip</button>
      <button (click)="handle?.activateTab('side', 'alerts')">Alerts tab</button>
      <button (click)="handle?.fit()">Frame board</button>
    </div>
    <grafloria-dashboard
      [views]="views" [options]="options"
      [layout]="layout()" [sizing]="sizing()"
      (ready)="handle = $event"
      style="display:block; height:480px" />
  `,
})
export class ArrangeDashboardComponent {
  readonly views = views;
  readonly options = options;
  readonly layout = signal<'grid' | 'split'>('grid');
  readonly sizing = signal<'fit' | 'grow'>('fit');
  handle?: DashboardHandle;

  captionChip(): void {
    this.handle?.setCaption('ops', {
      text: 'Operations', position: 'tab', icon: '▤',
    });
  }
}
```
```tsx title="Qwik"
import { component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaDashboard } from '@grafloria/qwik';
import type { DashboardHandle } from '@grafloria/element';
import { options, views } from './board';

export default component$(() => {
  const handle = useSignal<NoSerialize<DashboardHandle>>();
  const layout = useSignal<'grid' | 'split'>('grid');
  const sizing = useSignal<'fit' | 'grow'>('fit');

  return (
    <section>
      <div>
        <button onClick$={() => { layout.value = 'grid'; }}>Grid</button>
        <button onClick$={() => { layout.value = 'split'; }}>Split</button>
        <button onClick$={() => { sizing.value = 'fit'; }}>Fit</button>
        <button onClick$={() => { sizing.value = 'grow'; }}>Grow</button>
        <button onClick$={() => handle.value?.setRtl(!handle.value.getRtl())}>RTL</button>
        <button onClick$={() => handle.value?.setFloat(!handle.value.getFloat())}>Float</button>
        <button onClick$={() => handle.value?.setCaption('ops', {
          text: 'Operations', position: 'tab', icon: '▤',
        })}>Caption chip</button>
        <button onClick$={() => handle.value?.activateTab('side', 'alerts')}>Alerts tab</button>
        <button onClick$={() => handle.value?.fit()}>Frame board</button>
      </div>
      <GrafloriaDashboard
        views={views} options={options} layout={layout.value} sizing={sizing.value}
        onReady$={(value) => { handle.value = noSerialize(value); }}
        style={{ height: '480px' }} />
    </section>
  );
});
```
```tsx title="React"
import { useState } from 'react';
import { GrafloriaDashboard } from '@grafloria/react';
import type { DashboardHandle } from '@grafloria/element';
import { options, views } from './board';

export default function ArrangeDashboard() {
  const [handle, setHandle] = useState<DashboardHandle | null>(null);
  const [layout, setLayout] = useState<'grid' | 'split'>('grid');
  const [sizing, setSizing] = useState<'fit' | 'grow'>('fit');

  return (
    <section>
      <div>
        <button onClick={() => setLayout('grid')}>Grid</button>
        <button onClick={() => setLayout('split')}>Split</button>
        <button onClick={() => setSizing('fit')}>Fit</button>
        <button onClick={() => setSizing('grow')}>Grow</button>
        <button onClick={() => handle?.setRtl(!handle.getRtl())}>RTL</button>
        <button onClick={() => handle?.setFloat(!handle.getFloat())}>Float</button>
        <button onClick={() => handle?.setCaption('ops', {
          text: 'Operations', position: 'tab', icon: '▤',
        })}>Caption chip</button>
        <button onClick={() => handle?.activateTab('side', 'alerts')}>Alerts tab</button>
        <button onClick={() => handle?.fit()}>Frame board</button>
      </div>
      <GrafloriaDashboard
        views={views} options={options} layout={layout} sizing={sizing}
        onReady={setHandle} style={{ height: '480px' }} />
    </section>
  );
}
```
```vue title="Vue"
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import { GrafloriaDashboard } from '@grafloria/vue';
import type { DashboardHandle } from '@grafloria/element';
import { options, views } from './board';

const handle = shallowRef<DashboardHandle | null>(null);
const layout = ref<'grid' | 'split'>('grid');
const sizing = ref<'fit' | 'grow'>('fit');

function captionChip(): void {
  handle.value?.setCaption('ops', {
    text: 'Operations', position: 'tab', icon: '▤',
  });
}
</script>

<template>
  <section>
    <div>
      <button @click="layout = 'grid'">Grid</button>
      <button @click="layout = 'split'">Split</button>
      <button @click="sizing = 'fit'">Fit</button>
      <button @click="sizing = 'grow'">Grow</button>
      <button @click="handle?.setRtl(true)">RTL</button>
      <button @click="handle?.setFloat(!handle.getFloat())">Float</button>
      <button @click="captionChip">Caption chip</button>
      <button @click="handle?.activateTab('side', 'alerts')">Alerts tab</button>
      <button @click="handle?.fit()">Frame board</button>
    </div>
    <GrafloriaDashboard
      :views="views" :options="options" :layout="layout" :sizing="sizing"
      @ready="handle = $event" style="height:480px" />
  </section>
</template>
```
:::

The JavaScript sample starts with Revenue and Customers above Operations and the Filters page.

![JavaScript board with the layout toolbar, Operations caption, and Filters and Alerts tabs.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/f927465021a1ebb8756258f4b66a2257.png)

The Angular component shows Orders and Churn inside Operations, beside Region in the active Filters page.

![Angular board with two top-level KPI cards, the Operations section, and Filters selected.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/6e5fb9d219e192e5d3fda918533e746d.png)

The Qwik component shows the same initial grid with the toolbar above it.

The React component starts with the Operations subtitle Today and the Filters page showing EMEA.

The Vue component also starts in the grid, with Filters selected and Alerts available beside it.

Press **Split** to replace grid resize corners with dividers; drag a divider to change pane proportions. Press **Grid** to project the split tree back into cells. These controls change the view's layout, not the Operations section's inner grid or the side panel's tabs. To switch a section or page separately, pass its id as the second argument to `setLayout()`, such as `setLayout('split', 'ops')`.

Press **Grow** in grid mode to keep row heights and let the board extend downward; **Fit** keeps the board height and squeezes rows. Split layout always fits, so it does not become a growing grid when you press Grow. **Frame board** calls `fit()` to reframe the current view's camera—it does not change the sizing mode.

The ready callback gives Qwik a live handle in the browser; `noSerialize()` keeps it out of resumable state. The framework components dispose their renderer on unmount. In JavaScript, keep the function returned by `mountDashboard()` and call it when your application removes the panel.

## 3. Tune the geometry and interaction

### Fixed size or fluid size

For a fixed design surface, replace the shared `options` export with `mode: 'fixed', width: 1180, height: 660` alongside your other options. The camera frames those authored dimensions. An explicit `width` also implies fixed mode unless you set `mode` yourself. A fluid board takes its dimensions from the container instead; see [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas) for container sizing.

The following options govern the board, not the renderer's zoom-to-content operation:

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `mode` | `'fluid' \| 'fixed'` | `'fluid'`; explicit `width` implies `'fixed'` | Uses container pixels at zoom 1, or an authored world size framed by the camera. |
| `width`, `height` | `number` | `1180`, `660` | Defines the fixed board size in pixels. |
| `layout` | `'grid' \| 'split'` | `'grid'` | Packs cells or fills the board through a splitter tree. Split always fits. |
| `sizing` | `'fit' \| 'grow'` | `'grow'` fluid; `'fit'` fixed | Squeezes rows within the height, or keeps row height and extends downward. |
| `rowHeight` | `number` | `130` | Sets row height in growing grids, in pixels. |
| `overflow` | `'bounded' \| 'scroll'` | `'bounded'` | Enforces fit capacity, or opts into an extending frame and camera panning. |
| `squeeze` | `boolean` | `true` | Squeezes bounded fit rows toward the row floor before refusing growth. `false` freezes the current row height for gestures. |
| `columns` | `number` | `12` | Sets the grid's column count; views and containers can override it. |
| `gap` | `number` | `8` | Sets widget gaps and board padding in pixels. |
| `float` | `boolean` | `false` | Allows gaps when true; otherwise gravity packs widgets upward. |
| `rtl` | `boolean` | `false` | Mirrors pixels so cell `x=0` appears on the right; cells stay unchanged. |
| `responsive` | `DashboardOptions['responsive']` | Not set | Derives live columns from width, using column-width rules or breakpoints. |
| `dragHandle` | `DashboardOptions['dragHandle']` | `false` | Chooses the whole card, caption, selector, or painted grip as the drag zone. |
| `static` | `boolean` | `false` | Disables pointer dragging, resizing, and handles; API edits still work. |

A bounded fit grid refuses a drop, resize, or `addWidget()` that needs more rows than its capacity. `addWidget()` returns `undefined` when the widget does not fit. An already oversized board—loaded from a document or switched from Grow—still squeezes to the frame rather than scrolling. With `overflow: 'scroll'`, you explicitly opt out of that bound.

### Gravity, RTL, and columns

Use **Float** in grid mode to toggle whether gaps are legal. Turning float off repacks widgets upward. In the Vue sample, **RTL** calls `setRtl(true)` to mirror widget pixels without rewriting their cells; repeated clicks keep RTL enabled rather than reversing the mirror.

For explicit column controls, call `setColumns(6, 'moveScale', 'main')` on the ready handle. It returns `void`; read `getColumns('main')` for the live count. The optional reflow argument uses [`GridColumnLayout`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-grid-pack):

| Reflow mode | Result |
| --- | --- |
| `'moveScale'` (default) | Scales both horizontal position and span by the new/old column ratio. |
| `'move'` | Scales position; keeps spans, clamped to fit. |
| `'scale'` | Scales spans; keeps positions, clamped to fit. |
| `'none'` | Keeps positions and spans; clamps only what no longer fits. |

One column forces a single stack in every mode. A manual `setColumns()` call pins the grid's column count instead of continuing to follow the width observer. Keep the sample's responsive rule for automatic resizing, or use explicit column controls for a user-selected count; do not expect both to drive the same grid simultaneously.

### Sections, captions, tabs, and grips

Operations has four inner columns regardless of its outer span. Its `sizing: 'fit'` refuses child growth beyond the pane; a container's default `'grow'` instead grows its slab in the parent when a child needs another row. `maxRows` defines the inner grid's designed row count, while `limits.maxRows` limits a widget's own resize. Containers can nest; nesting beyond two levels is not exercised by the library's gates.

[`SectionCaption`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-types) accepts `true` for the section title, a string for explicit text, `false` for no caption, or [`SectionCaptionOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-sectioncaptionoptions). The initial subtitle reserves a second caption line. **Caption chip** changes it to a title-sized chip at the section's leading corner; both `position: 'inside'` and `'tab'` reserve space inside the section. `setCaption()` returns `false` for a non-container; a successful change repaints and records one undo step.

For caption behavior, `show: 'design'` removes the band and its reserve in static mode; `show: 'hover'` overlays content without reserving space. Caption action buttons call `onCaptionAction` instead of selecting the section.

[`TabsOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-tabsoptions) controls the strip's alignment, height, and stretching. The sample stretches Filters and Alerts across the strip. **Alerts tab** calls `activateTab('side', 'alerts')`: it shows that page and persists the active id. The call returns `false` for an invalid container or page. Use `setLayout('split', 'alerts')` to change that page's inner layout, not the tab container itself. `setLayout()` accepts only grid and split; declare a tab container with `layout: 'tabs'` in its widget spec.

[`DragGripOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-interfaces) places a painted grip at the left, center, or right of the card's top edge, inside the header or outside as a tab. The sample chooses the right side inside the header. Click a card to select it: only the selected card shows its grip. Call `focusWidget('revenue')` on the ready handle to select it and move keyboard focus there; the method returns `false` for an unknown id.

To make the entire caption the drag target, call `setDragHandle(true)`; use `false` for the whole card, or a selector string for your own handle element. Static mode paints no grip. These handle switches apply across views.

## See it running

Open the [Fluid dashboard demo](https://grafloria.com/demos/dashboard/fluid-board.html) to try Fit/Grow, Grid/Split, captions, tabs, and drag grips. The [Grid options demo](https://grafloria.com/demos/dashboard/grid-options.html) explores gravity, RTL, responsive columns, and nested boards.

## Related

- [Build a dashboard](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/build-a-dashboard): widget content and the initial board.
- [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history): user-facing edits and undo.
- [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents): persist the live document instead of projecting framework data.
