Component library

Drag it. Type in it. Or never touch the mouse.

This is the real <WidgetBoard> from NebulaKit, with this site's own widgets registered in it. Rearrange it however you like — your layout is saved in this browser, and nothing here is a screenshot.

Try it

0 layout writes

0 live title updates

Overview

Clock

Monday, Sep 14 · your time

Page views

last 7 days

24,802

+12%

Activity

Signups

last 7 days

312

-4%

Uptime

rolling 30 days

99.98%

0%

Scratch

Scratch pad

The clock rewrites its own title every second, and the layout has been written 0 times — only when you actually moved something. That split is deliberate; see rule four below.

It works without a mouse

Tab to any widget's drag handle and the whole board opens up. Every move is announced to a screen reader as it happens.

Space or Enter
Pick the widget up, and put it down again
Move it within its column
Move it to the column either side
Esc
Cancel, and put it back where it started

On a phone, hold a handle for a moment before dragging — a swipe stays a swipe, so the page still scrolls. Drag near the top or bottom edge and the page scrolls with you.

What it costs you to use

A board is one component and a list of columns:

<WidgetBoard
  bind:widgets
  {columns}
  {live}
  on:change={(e) => save(e.detail.widgets)}
  on:live={(e) => (live = { ...live, [e.detail.id]: e.detail.value })}
/>

Registering a widget is a manifest entry, a line in the component map, and the component itself. It never means editing the board. This site registers 3 of them:

  • notes A scratch pad. Keeps its own local state and nothing else.
  • stat A number, its change, and a sparkline in the chart palette.
  • clock Ticks every second and reports a live title, never a stored one.

The board is not the only way in. The two actions underneath it work on any markup, so a sortable list or a nav reorder needs no board at all:

<ul use:dropzone={{ group: 'list' }}>
  {#each items as item (item.id)}
    <li use:draggable={{ id: item.id, group: 'list', onDrop }}>
      <button data-drag-handle>Drag</button> {item.name}
    </li>
  {/each}
</ul>

Four rules, each one paid for

This did not come out of a library. It came out of a dashboard that shipped a widget board and then spent months finding out how it was wrong. Every rule below is a defect somebody already paid for.

  1. Order is derived, never patched

    Reordering is a pure function: remove, clamp, splice, renumber. The version that adjusted each widget's position with a chain of conditionals gave two widgets the same slot, and they took turns winning — so a widget would jump back, or swap with a neighbour, or refuse to move.

  2. Carry identity, never position

    A drop reports the column's id, which the actions stamp themselves. The version that reported the column's index agreed with the id only until someone moved a column; after that, drops landed in the wrong one — or vanished until reload.

  3. Hit-test in the space you mutate

    While you drag, the widget is still in the page — only a ghost moves. The insertion point is worked out with it taken out of the list, which is the same list the reorder writes into. Skip that and every downward move lands one slot too high.

  4. Persisted state must be inert

    Live values go through a separate channel, never into stored state. The version that put a live price in a widget's title rewrote the entire dashboard on every tick: about 650 writes a day against a 1,000-a-day account limit, from one open tab.

Read the rest

The documentation page covers it in the context of the rest of the template. The full reference — the layer map, the component contract, and every rule in detail — is docs/WIDGET_BOARD.md in the template.