Blog / Engineering

Fluid boards, a split layout and a drag grip

September 7, 2026 · engineering · dashboards

dashboards layout

The dashboard kit in @grafloria/element went through a round of layout work this month. This is what changed, why each piece exists, and what broke on the way. If you only want to try it, the fluid dashboard demo has every switch in its toolbar.

The list we built against

People judge a dashboard designer in about four seconds: drag one widget and watch what the others do. So before writing code we measured a commercial designer that most teams in this market know, the DevExpress dashboard designer, in a scripted browser, and wrote down what its users take for granted:

  • The surface is always covered. One item fills the whole dashboard. Add a second and both take half. Add a third and it halves one side. There is never a hole.
  • Dividers are percentages. We dragged the divider between two rows of 409 pixels each and got 286 and 532; the other row's items did not move. Deleting an item hands its slot to its sibling.
  • You drag an item by its caption, not by its body, so a table inside a dashboard still scrolls and a chart legend still clicks.
  • The selected item shows a bar; the others stay clean. Nothing is painted on a widget nobody is touching.

None of this is exotic. It is what a designer feels like when the layout model is a splitter tree rather than a cell grid. Our kit was a cell grid, and a good one, so the question was whether the tree could live beside it instead of replacing it. The rest of this post is the answer, feature by feature.

Fluid by default, and "fit" means bounded

The first change is not about the tree at all. A dashboard used to be a picture the camera scaled to the container, which is how a diagram canvas thinks. A dashboard is not a diagram. It now lays out in real CSS pixels at zoom 1, the way gridstack or react-grid-layout would, whenever you do not author a width:

import { render, dashboard } from '@grafloria/element';

// No width → mode 'fluid': the board is 100% of its container at zoom 1.
// sizing 'grow' (the default): rows keep their height, the board extends, the wheel scrolls.
// sizing 'fit': the board keeps the container height and rows squeeze — it NEVER scrolls.
const spec = dashboard({ columns: 12, gap: 10, sizing: 'grow', widgets });
render(spec, host);
spec.handle.setSizing('fit');   // live, one call, no remount

"Fit never scrolls" sounds like a slogan until you make it a rule. Under fit the container height is a capacity: an add that would need one row too many is refused, the palette chip dims to say so, and nothing is ever painted past the board's edge. We had a version where fit extended the frame at a row floor. A user tried it, saw a scrollbar in a mode called fit, and was right.

The splitter tree, as a layout you can switch to

The tree is a second layout on the same board, not a second board. Each view carries its own, and switching keeps the picture: cells become a tree, a tree becomes cells.

const spec = dashboard({ layout: 'split', widgets });   // or per view: views[i].layout
spec.handle.setLayout('grid');    // live; the widgets stay where they are
spec.handle.setLayout('split');

// The rules from the list above, measured then matched:
//  · an add halves the LARGEST widget along its longer axis
//  · a removal hands its weight to the neighbour, so add → remove is a round trip
//  · a divider drag is a percentage with a 5% floor; Shift+arrow moves it from the keyboard
//  · a drop lands on a widget's edge — or on the outer edge of a whole group,
//    so a widget can go UNDER a full row of columns, not into one of them
const saved = spec.handle.toJSON();   // writes layout and the tree per view

Two details took longer than the tree itself. A split board has no corner resize handles, because size comes from the dividers; the grid binder had to shed its handles on the switch. And every layout change is one whole-tree write on the group, so one undo is always one thing, which is the property the cell grid already had and we were not willing to lose.

Drag by caption, or by a grip only the selected widget shows

This is the item people notice first and describe last. One option covers it:

dashboard({ dragHandle: false });            // the whole card drags (default)
dashboard({ dragHandle: true });             // the caption strip is the only handle
dashboard({ dragHandle: '.my-handle' });     // your own element inside the card
dashboard({ dragHandle: { grip: true, position: 'right', placement: 'outside' } });
// position: 'left' | 'center' | 'right' along the top edge
// placement: 'inside' the header band, or 'outside' as a tab above the card

spec.handle.setDragHandle({ grip: true });   // live, both layouts
spec.handle.focusWidget('trend');           // select from code: the grip appears on that widget

The grip shows on the selected widget only. A press selects, a click on the board clears, keyboard focus selects too, and the selected card gets a quiet ring. A static board, the viewer's mode, paints no grip at all. Inside the header band the grip is centred on the caption's text line and flush with the card padding at every size tier, and the header makes room beside it.

One engineering detail is worth writing down. An outside tab sits above the card's hit box, so the hit test under the pointer sees the gap, or the neighbour above. Both binders now resolve the widget from the grip element itself and skip edge-resize detection for a grip press. Without that, the tab looked right and did nothing.

Widgets that adapt instead of shrinking

Once tiles are real pixels, a KPI in a 46-pixel row is a real problem instead of a scaled one. Cards now have size tiers driven by container queries: a short, wide KPI row lays out horizontally, a small tile steps the figure down, a strip shows label and value on one line. Funnels and donuts fill their tile; charts relayout on the body's size only, never on the text, so a reload at a different width paints the same words. Corners went from 12 to 3 pixels on the way, because a designer surface reads cleaner square.

What we got wrong, and how we found it

  • Switching the layout rebuilt the view from its authored options, so a board switched to static, right-to-left or drag-by-grip live snapped back the moment its layout changed. A scenario that drives a real mouse through both layouts caught it; the unit tests had not.
  • Our Vue demo build compiled scoped styles and never stamped the scope id, so every scoped rule in that app was dead. The live page listed eight widgets and painted none. Element counts said pass; the screenshot said no. The probe now requires geometry.
  • A first version of the grip pressed 12 pixels from the card's top, which is padding, not caption. That is why the default handle became the whole caption strip, from the card's edge to the header's bottom, rather than the header text.

Try all of it in one place: the fluid dashboard demo has Fit / Grow, Grid / Split and the drag controls in its toolbar, an "About & how to test" panel with numbered checks, and real Angular, React and Vue apps behind the framework tabs. The option reference is Dashboards in plain JavaScript. Everything above is published as @grafloria/element 0.4.9 on npm, MIT.