Drag and dock
How dragging a tab or a whole tabset resolves to a stack or a directional split. The landing zones, the pipeline, and the gates. Pointer-only.
Two things can be dragged in dashfoo: a single tab, picked up from its button in the strip, and a whole tabset, picked up from the grip in its toolbar. Both share the same landing zones, and which outcome fires depends entirely on where the pointer is when you let go:
- Over a tab strip. The drop stacks into that tabset. A tab lands at a specific insertion index, with a thin insertion line marking the slot; a tabset merges all of its tabs onto the end of the target strip.
- Over a tabset body. The drop splits the tabset, creating a new region to the left, right, top, or bottom.
This guide explains how the pointer position resolves to one of those outcomes, the pipeline that carries a drag from the internal drag hooks to a committed model change, and the gates that turn each outcome on or off.
The geometry lives in @dashfoo/core
(resolveDockTarget)
and @dashfoo/react
(the drag adapter).
The drag adapter is the only module in the library that touches @dnd-kit.
It drives the framework-agnostic @dnd-kit/dom core imperatively (no
React bindings). Everything it touches downstream is pure and unit-tested.
Pointer-only. Drag-docking uses a pointer sensor; there is no keyboard drag. The arrow keys are already bound to roving-tabindex tab navigation, and dnd-kit's keyboard nudge model conflicts with that, so keyboard docking would need its own interaction design. All other chrome (select, close, rename, maximize, overflow) stays fully keyboard-operable.
The two landing zones
Every drop resolves to one DockLocation, the union the reducer understands:
type DockLocation = "center" | "split-left" | "split-right" | "split-top" | "split-bottom";center stacks. split-* splits. The pointer's position inside a tabset's rect
picks which one.
Over a tab strip: stack at an insertion index
When the pointer is inside the element marked data-dashfoo="tabstrip", the
drop is always a stack. The adapter gathers the strip's data-dashfoo="tab"
rects (excluding the dragged tab itself) and the pure math in
tab-insertion.ts
finds the first tab whose horizontal midpoint sits to the right of the pointer:
const insertionIndex = (rects: ReadonlyArray<Rect>, pointerX: number): number => {
const found = rects.findIndex((rect) => pointerX < rect.x + rect.width / 2);
return found === -1 ? rects.length : found;
};If the pointer is past every midpoint, the index is the end of the strip.
The dragged tab excludes itself from this measurement. Its own slot never counts toward the order, so the index is measured against the tabs it will land among, not the ones currently rendered. The same index is used at commit time; see Why the index needs no adjustment.
The resulting intent carries location: "center", the target tabset id, and the
computed index:
{ index: insertionIndex(tabRects(strip, draggedId), point.x), location: "center", targetId: id }The on-screen feedback is a thin vertical line (insertionLineRect) centered
on the slot boundary, spanning the strip's height and clamped so it never
leaves the strip. The slot is measured against whole tab items (label plus
close button), so the "after the last tab" position sits past the last close
button rather than between the label and the close.
For a tabset dragged by its grip, a strip drop still resolves to center, but
the index does not apply: the committed moveTabset appends every tab from the
dragged tabset to the end of the target strip. See
Dragging a whole tabset.
Over a tabset body: split in a direction
Below the strip, the tabset's own rect decides between stacking and splitting.
resolveDockTarget in @dashfoo/core measures the pointer's fractional distance
from each of the four edges. If the pointer is inside the central area, the drop
stacks; if it lands within the outer band (22% of the rect by default), it
splits toward the closest edge:
const resolveDockTarget = (pointer: Point, rect: Rect, opts?: BandOptions): DockLocation => {
const band = opts?.bandFraction ?? DEFAULT_BAND_FRACTION; // 0.22
// a zero-size tabset has no meaningful edges; dividing by its width/height
// yields NaN distances that would silently resolve to a bogus split.
if (rect.width <= 0 || rect.height <= 0) {
return "center";
}
const distances = edgeDistances(pointer, rect);
const min = Math.min(distances.left, distances.right, distances.top, distances.bottom);
if (min > band) {
return "center";
}
return `split-${closestEdge(distances)}`;
};In a corner, the nearer of the two edges wins. "center" stacks at the end of
the tabset; split-${edge} splits toward that edge. The result is already a
DockLocation, the same vocabulary zoneRect and dockZonePolygons speak, so
nothing has to convert it.
- center
- split-left
- split-right
- split-top
- split-bottom
A center drop over the body appends rather than inserting at a mid-strip
slot. The adapter sets index to the count of remaining tabs so the tab lands
last.
The indicator for a split highlights the matching half of the tabset:
split-left paints the left half, split-top the top half, and so on
(zoneRect).
To see or paint the whole partition at once, dockZonePolygons(rect) enumerates
the five hit regions as polygons: the inner center rect and four edge
trapezoids whose seams run diagonally from each rect corner to the matching
inner corner. It shares the band default with resolveDockTarget, so a map
painted from the polygons always agrees with the live hit-test. The demo's
Docking page uses it (with useDropIntent) for a drop-zone visualization that
paints every candidate region during a drag.
Dragging a whole tabset
Each tabset renders a grip button in its toolbar (data-dashfoo="tabset-grip",
aria-label="Move tabset"). Dragging the grip picks up the entire tabset, tabs
and all, and resolves against the same landing zones. The drag-preview chip is
labeled with the tabset's active tab name.
The grip is controlled by the draggableTabsets prop on <DashfooLayout>. It
defaults to true; set it to false and the grip is not rendered, so
whole-tabset dragging is off. The grip is also hidden while the tabset is
maximized.
A grip drop commits a moveTabset action (a tab drop commits moveNode). The
reducer treats the two locations differently:
centermerges: every tab from the dragged tabset is appended to the end of the target tabset, and the now-empty source tabset is removed. The insertion index is ignored.split-*moves: the whole tabset is detached and placed beside the target, reusing the parent row when the orientation already matches, otherwise wrapping both in a new row, the sameplaceBesideTargetpath a tab split uses.
Dropping a tabset onto itself is a no-op; the adapter suppresses the intent
before the machine ever sees it, and the reducer guards
sourceId === targetId independently.
Internally the grip registers with dnd-kit under the id grip-<tabsetId>, so
it never collides with the tabset's own registered id; the real tabset id rides
in the draggable's data and becomes the moveTabset source.
Dragging in from outside
Both drags above move nodes that already live in the model. External drag sources add a third kind: an element outside the layout (a widget list, a palette) that inserts a new tab when dropped on a tabset.
Wrap the layout and the sources in DashfooDragProvider so they share one drag
manager and one drag lifecycle, and register each source with
useExternalTabSource:
import { createTabId, tab } from "@dashfoo/core";
import { DashfooDragProvider, DashfooLayout, useExternalTabSource } from "@dashfoo/react";
const WidgetCard = ({ component, name }: { component: string; name: string }) => {
const { ref } = useExternalTabSource({
createTab: () => tab(component, name, { id: createTabId() }),
label: name,
});
return <div ref={ref}>{name}</div>;
};
const App = () => (
<DashfooDragProvider>
<WidgetCard component="metrics" name="Metrics" />
<DashfooLayout defaultModel={model} components={registry} />
</DashfooDragProvider>
);An external drag resolves against the same landing zones (stack on a strip or
body, split on an edge), but the drop commits an addNode action carrying the
TabNode that createTab returned, instead of a moveNode. createTab runs
once per drag (at drag start) and must mint a fresh id each call; the result is
validated against the tab schema, and an invalid node warns and cancels the
drag. A drop outside any tabset is a no-op.
The demo's docking page
pairs each draggable card with an "Add" button that calls addTab on the
imperative handle. Pointer drag is the only drag input, so the button is the
keyboard path.
How resolution layers
For a pointer over a tabset, the adapter's resolveIntent runs the checks in a
fixed order:
- No-op filter. A tabset dragged onto itself, or the sole tab of a tabset
dropped back onto that same tabset, resolves to no intent at all
(
shouldAllowDrop). The drop changes nothing once empties collapse, so the indicator never appears. - Tab strip. Inside the strip, return a
centerstack at the insertion index. - Tabset body. Otherwise resolve
center(append) orsplit-*from the tabset rect.
const resolveIntent = (
targetId: string,
element: HTMLElement,
point: Point,
draggedId?: string,
): DropIntent | null => {
const tabIds = [...element.querySelectorAll<HTMLElement>('[data-dashfoo="tab"]')].map(
(tab) => tab.dataset.tabId ?? "",
);
if (!shouldAllowDrop(draggedId, targetId, tabIds)) {
return null;
}
const intent = intentForTabset(targetId, element, point, draggedId);
// When splitting is disabled, a drop over the body stacks instead of splits.
if (!splitDock && intent.location.startsWith("split-")) {
return { location: "center", targetId };
}
return intent;
};The pipeline
A drag travels through five stages. The split is deliberate: the @dnd-kit/dom
pointer sensor owns input, dragDockMachine owns the interaction lifecycle and
never touches the document, and the reducer owns the model.
drag hooks → dnd-kit/dom → dragDockMachine → COMMIT → reducer
(tab + grip + (pointer + (OVER / DROP (moveNode | (action
external hit-test) lifecycle) moveTabset | applied)
draggables) addNode)1. The drag hooks mark the sources
Three internal hooks in
drag-hooks.tsx
wire the DOM to the drag system. None of them is exported from
@dashfoo/react. They are described here to explain the flow, not as API.
useTabDraggableregisters each tab button as a@dnd-kit/domDraggable, created imperatively in an effect and keyed by the tab id. The tab's name rides along as the drag-preview label.useTabsetDraggabledoes the same for the grip, under the dnd-kit idgrip-<tabsetId>with the real tabset id in the draggable's data.useTabsetDroppableregisters each tabset as a@dnd-kit/domDroppablewith a custom occlusion-aware collision detector: the winner is the tabset containing the topmost element under the pointer (elementFromPoint), so a tabset covered by a float never becomes a target. dnd-kit's built-in detectors are pure rect geometry and stay unused: they know nothing about paint order.
2. The adapter reads the pointer and hit-tests
DashfooDragProvider constructs one @dnd-kit/dom DragDropManager (minus the
Accessibility plugin and the keyboard sensor), starts one dragDockMachine
actor, and subscribes to the manager's monitor once for the whole tree. On
dragstart it reads the source's data to decide the subject kind (a grip
carries type: "tabset", an external source carries type: "external", a tab
carries its own id) and sends START. On dragmove and on every collision
pulse it takes the drop target dnd-kit's collision pass resolved, looks up the
layer that registered it, asks that layer to resolve an intent, and sends
OVER. On dragend it re-resolves from the final operation state, sends a last
OVER, then DROP.
Each Layout.DragLayer is one such layer: the main tree, plus one per float.
A layer registers only how its own tabsets turn a pointer into an intent and
where a committed action goes, keyed by a layer id that also namespaces its
droppable registrations. It holds no drag state, so a pointer move costs one
intent resolution rather than one per mounted layer. A Layout.DragLayer with
no provider above it mounts its own, so it still works standalone.
The data-dashfoo="drag-preview" chip is positioned by dnd-kit's Feedback
plugin in overlay mode: the chip element is handed to Feedback's overlay
accessor once at mount, so the dragged tab itself is never promoted or
placeholder-cloned and per-move positioning never re-renders React. The drop
animation is disabled: drops settle immediately.
The final recompute means the drop uses the authoritative final pointer, not
whatever the last dragmove happened to report, so the committed location
matches where the user released.
3. dragDockMachine runs the lifecycle
The XState machine has two states, idle and dragging, and its context is one
DragState rather than a pair of independent nullables:
type DragState =
{ kind: "idle" } | { drop: DropResolution | null; kind: "dragging"; subject: DragSubject };START stashes the subject (its kind is "tab", "tabset", or "external",
the last carrying the TabNode to insert) and enters dragging. Each OVER
assigns the live DropResolution ({ intent, scope }, where scope names the
layer that resolved it; the indicator reads the intent). A drop target with no
subject is not expressible, so "is this drop valid" and "what does it do" are one
total function:
const dropAction = (drag: DragState): { action: Action; scope: string } | null => {
if (drag.kind !== "dragging" || drag.drop === null) {
return null;
}
const { intent, scope } = drag.drop;
const { subject } = drag;
if (subject.kind === "external") {
return { action: { ...intent, tab: subject.tab, type: "addNode" }, scope };
}
if (subject.kind === "tabset") {
// no `index`: a tabset merges or docks beside, never into a strip slot
const { index, ...target } = intent;
return { action: { ...target, sourceId: subject.id, type: "moveTabset" }, scope };
}
return { action: { ...intent, sourceId: subject.id, type: "moveNode" }, scope };
};The DROP guard is dropAction(context.drag) !== null, and the emitted
COMMIT carries the same result, so the guard and the action cannot disagree.
CANCEL (or a canceled drop) returns to idle and clears the context. The
machine never mutates the model; it only emits the action.
4. COMMIT forwards the action
The provider subscribes to the machine's COMMIT emission and forwards the
action (moveNode for a tab drag, moveTabset for a grip drag, addNode for
an external source) to the layer named by the emission's scope, which sends it
to that layer's onCommit or, without one, to the Layout.Root above it.
DashfooLayout wires that straight to store.dispatch.
5. The reducer applies the move
The reducer deep-copies the model (structuredClone, so the input is never
mutated), then applies the action. For moveNode it removes the source tab
from wherever it lives and re-inserts it at the drop target: center stacks
into the target tabset at index; split-* creates a new tabset beside the
target (reusing the parent row when the orientation already matches, otherwise
wrapping both in a new row). For moveTabset it merges (center) or detaches
and re-places the whole tabset (split-*), as described in
Dragging a whole tabset. After the action,
self-healing invariants run (normalize) so the result is always a valid,
canonical model.
Why the index needs no adjustment
moveNode removes the source tab before inserting it:
const [removed] = source.container.children.splice(source.index, 1);
insertTab(draft, removed, { id: action.targetId, index: action.index, location: action.location });Because the drag adapter already excluded the dragged tab when measuring the
insertion index, action.index indexes the post-removal array directly. No
off-by-one correction is needed when a tab moves within its own strip.
Gates
These flags decide what a drag can do. Some are global, some per tab, some are layout props.
| Gate | Scope | Where it lives | Default | Effect when off |
|---|---|---|---|---|
editable | whole layout | <DashfooLayout> prop | on | No drags start, and the layout is not a drop target (see below). |
enableSplitDock | global | global.enableSplitDock | on | A drop over a tabset body stacks instead of splitting. |
draggableTabs | whole layout | <DashfooLayout> prop | on | No tab can be picked up; tab.enableDrag becomes moot. |
tabEnableDrag | global | global.tabEnableDrag | on | Tree-wide default behind tab.enableDrag. |
enableDrag | per tab | tab.enableDrag | on | That tab can't be picked up at all. |
draggableTabsets | whole layout | <DashfooLayout> prop | on | No grip is rendered; tabsets can't be dragged whole. |
The editable gate
editable={false} makes the layout static. On the source side every internal
draggable is disabled, so no drag can start from it. On the target side the
adapter gates at drag time: resolveIntent returns null for a non-editable
layout (no dock indicator and no drop target) and the COMMIT subscription
drops the action, so even a drag that started before editable flipped off, or
an external-source drag arriving through a shared DashfooDragProvider, cannot
land a structural change. A sibling editable layout under the same provider
still accepts those drags.
Global gate
DashfooLayout reads the global flag and passes it to DragProvider. It
defaults to enabled. The check is !== false, so an absent flag means on:
const splitDock = store.model.global.enableSplitDock !== false;When enableSplitDock is off, resolveIntent rewrites any split-* result back
to a center stack, so a drop over the body lands as a tab.
Per-tab gate
A tab opts out of dragging by setting enableDrag: false in the model. The tab
button passes that straight into the internal useTabDraggable hook as its
disabled argument, with the tab's name as the chip label:
const { ref } = useTabDraggable(tab.id, tab.enableDrag === false, tab.name);The hook returns only a ref. Dragging state is not part of its return value:
the views derive it from the machine's subject (useDragSubject, also
internal) by comparing the subject's id to the tab's, which drives the
data-dragging attribute on the source tab's data-dashfoo="tab-item" wrapper
while the overlay chip follows the pointer.
A disabled tab is rendered like any other but can't be picked up, so it can
never become the source of a moveNode.
Tabset grip gate
draggableTabsets is a <DashfooLayout> prop, default true. When false,
the grip button never renders, so moveTabset can never originate from a drag.
The grip also disappears while its tabset is maximized, since a maximized
tabset has nowhere to dock.
Styling the indicator
The drop indicator carries data-dashfoo="dock-indicator". Following the
headless contract, the adapter sets only position and size inline; every visual
property is an overridable CSS custom property with a neutral fallback:
| Variable | Default | Applies to |
|---|---|---|
--dashfoo-dock-fill | oklch(0.556 0 0 / 0.18) | insertion line + split band |
--dashfoo-dock-border | oklch(0.708 0 0 / 0.75) | insertion line + split band |
--dashfoo-dock-border-width | 1px | insertion line + split band |
--dashfoo-dock-radius | 6px | split band |
--dashfoo-dock-line-radius | 2px | insertion line |
--dashfoo-dock-transition | left 60ms, top 60ms, width 60ms, height 60ms | both forms |
--dashfoo-dock-transition controls how the indicator glides between
positions; set it to none to disable the movement, for example under
prefers-reduced-motion. Override these variables on your theme's
[data-dashfoo="dock-indicator"] rule to control the look. The
theming guide has worked recipes.
Rendering your own indicator
When CSS variables aren't enough, the drag is fully observable from the
outside. useDropIntent() returns the live drop intent: { targetId, location, index? }, or null when nothing is dragging or the pointer is over
no valid target. useDragSubject() returns what is being dragged. Both read the
provider's drag actor, so they work anywhere under a layout or anywhere under a
DashfooDragProvider, including a custom overlay mounted beside the layout
rather than inside it. Outside a provider they return null. Combine them with dockZonePolygons from @dashfoo/core
(the exact hit-region partition resolveDockTarget uses) and the
data-dashfoo="tabset" / "tabstrip" / "tab" markup to paint anything from
a custom highlight to a full drop-zone map. The demo's Docking page renders a
voronoi-style visualization of every candidate region this way.
See also
resolveDockTargetin@dashfoo/corefor the band math andBandOptions.tab-insertion.tsin@dashfoo/reactfor the insertion index, the insertion line, and the no-op drop filter.drag-adapter.tsxin@dashfoo/reactfor the manager, the hit-testing, and the indicator.dragDockMachinein@dashfoo/corefor the interaction lifecycle.- The
moveNode,moveTabset, andinsertTabpaths in the reducer for how a drop reshapes the model. - Controlled mode and history for what happens to the committed action after dispatch.