Mastering Astryx / lesson 11 of 13

Drag-and-Drop Interactions & Astryx Ref Forwarding

Encapsulate pointer listeners, element hit-testing, threshold calculations, and drop reordering in a custom hook — and wire it into the board.

Play
Transcript

[00:00] This is our Kanban application. And what we want to do is make these guys draggable and be able to drop them into different columns. So they would be moving between states, like from to do to in progress, from in progress to in review, and from in review to done.

[00:20] We’ve done a bunch of setup in this application that’s actually gonna make this really easy, but unfortunately I’m gonna have to drop in a massive amount of code because I didn’t wanna bring in a third party library right now. So I’m gonna create a new folder called hooks, and we are gonna drop in our roll your own use Kanban drag hook. All right, you ready for it? Here you go. This is 200 lines of code. Again, don’t worry about it. All of this code will be available to you. You don’t have to pause and start trying to see or highlight the video or anything like that. All of this will be available to you.

[01:03] But I do wanna point out that a lot of this is possible because of the contract we get. And in this case, very specifically from the card component. A lot of this would have been way more complicated, just like getting access to the underlying DOM elements, and knowing what to do on card pointer down. A lot of this, so we don’t have any Astryx code in this, but this is structured to work with the Astryx component and the contract that they provide us. I’m not gonna go crazy deep into that. You can certainly read about it on their website. But I will say that, yeah, this was nowhere near as complicated as it would have been if it was some other design framework.

[01:57] You could definitely, without a doubt, use a third-party library for this. I just didn’t wanna include anything else, which is also why I’m not bothering going through every line of code here. The code will be available to you. There will be a link in the description where you can go grab this and peruse it and drop it into your application. There’s a bunch of comments in here about the, specifically the card component and the contract we have with it on its ref and on pointer events. So just know that all that stuff’s there and I’m not gonna waste your time going through every line of this, but this guy should work.

[02:42] So what we’re gonna do is we’re gonna jump over to our Kanban. We’re gonna bring in our use Kanban drag hook. We’re no longer gonna need that column ID. We are gonna need to get our items into state, which means I need this work item back up. I need work item, not column ID. Now this temporary move item is gonna go away. We’re gonna get all of our state out of our use Kanban drag items. So we’ve got, you know, drag items by column, get column ref, get card ref, and on card pointer down. So these are all the things that we’re able to achieve with the hook. And again, with the contract that we have with the card component.

[03:32] And then our move item is actually super simple. It’s just a set items. We’re just moving these guys around inside of the array that we already have. So we’re updating, you know, it’s column ID. It’s pretty straightforward. And then instead of initial items, we’re gonna update this to items. Since we have that in our state now. And then down here, we are gonna add a drag status. Cause we’re gonna circle back and we’re gonna have a little indicator of what you’ve done in the, in the board toolbar. So we’ll come back to that. But all that’s doing is setting the status that you’re dragging this ticket from one column to another column. Okay.

[04:10] We’re going to come down here and we are no longer going to be able to filter on the initial items. Cause those items may have changed. So all of this needs to be based on the items that we have in state and which column they’re in, in our current state. So right off the bat, we can get rid of the filter. Cause that’s not going to make any sense any longer. And count will become items by column length, rather than just what’s in this particular column that we’re on. And then content ref, which we’re going to implement now is get column ref by column ID. So that we know which column we’re currently in. Then items dot length. It’s going to become items by column so that we’re always working with the current column. We’ll implement our card ref, which is just get card ref, same as get column ref. And we’ll implement our own pointer down, which just points to on card pointer down. So I think I did all that right, but my tool bar is going to be broken.

[05:13] And everything is in every column. So I messed it up. Let me pull out this drag status for a second and make sure that’s not breaking everything. No, that’s not it. So I’ve just got some formatting issues here. This can’t be items. It needs to be items by column. And there we go. We’ve got our items back where they belong.

[05:43] And I do need that column ID. Now, if we come over here, I can grab this guy, come over here and drop it there. Now, we don’t get a cool ghost effect. And we’ll deal with that later. But I could also drag it up, drag it down, drag in and nothing to review. And now we get our nothing in progress and to do is empty. And if I move them all over here, we get our empty states. Everything is working pretty good. Now we’re going to do that drag status up in the toolbar, which we started off right here. So jump over to the toolbar.

[06:23] We’re going to add drag status to our props. We’ll have that there as well. Now, right after this badge, we’re just going to drop in a quick text that should have that drag status. Let’s try that out. Load this guy up. You can see broke something. Please use new operator. We do not have the text component in this component. All right. We’ll try that one more time. Okay. You can see up there. So when I grab this, this is a task T1 and it’s in to do zero. And now if I drop it here, it’ll be an in progress one. But if I go up here, it’s in progress zero. Meaning the position in the column. So that’s one and that’s zero. You can see it’s tracking as I move along. So cool. We now have drag and drop. Uh, and in our next, uh, yeah, this one’s gone on long enough in our next lesson. We are going to make it look a lot better. So we’ll get a little ghost and a shadow and a preview of where it’s going to land stuff like that. Uh, and, uh, and then I think we’re, I think we’re looking pretty good.

Time to make the board interactive. In this lesson we extract the drag-and-drop interaction engine into a custom hook — src/app/hooks/useKanbanDrag.ts — and wire it into KanbanBoard so cards can be dragged between columns and reordered. The floating drag clone and ghost slot arrive in lesson 12.

Files this lesson: src/app/hooks/useKanbanDrag.ts (new), src/app/components/BoardToolbar.tsx (extended with live drag status), src/app/components/KanbanBoard.tsx (rewritten). page.tsx stays untouched.

Astryx this lesson: no new components — this is the integration lesson. Concepts: how Astryx primitives forward their DOM nodes (ref access), and how a custom interaction engine feeds state back into the UI.

Key Logic

  1. Element Registries: Uses useRef(new Map()) to track live DOM coordinates of columns and cards without causing re-render churn.
  2. DRAG_THRESHOLD (5px): Ensures short presses or clicks on nested controls (like the MoreMenu action dropdown) fire normal click handlers without triggering drag mode.
  3. computeTarget: Iterates through column bounding boxes (getBoundingClientRect) to determine the target column and vertical card insertion index.
  4. commitDrag: Inserts the dragged item into its target position while maintaining relative ordering of surrounding items.

Astryx Ref Access: The Contract Behind Custom Interactions

Why can the hook hit-test columns and cards from anywhere in the tree? Because Astryx primitives forward their underlying DOM elements:

Forwarded refs let a custom engine read live geometry (getBoundingClientRect) without reaching outside the component tree. This is the Astryx contract for building custom interactions: primitives give you typed props and styles, while refs are the escape hatch for raw coordinates when you need them (hit-testing, measuring, drag geometry).

Implementation: useKanbanDrag.ts

Create src/app/hooks/useKanbanDrag.ts:

src/app/hooks/useKanbanDrag.ts
import {
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type PointerEvent as ReactPointerEvent,
type SetStateAction,
} from 'react';
import { DRAG_THRESHOLD } from '../data';
import type { ColumnId, DragState, DropTarget, WorkItem } from '../types';
export function useKanbanDrag(
items: WorkItem[],
setItems: Dispatch<SetStateAction<WorkItem[]>>,
) {
const [drag, setDrag] = useState<DragState | null>(null);
// Live DOM registries for hit-testing. These nodes arrive through the
// forwarded-refs contract: LayoutContent hands us its scrollable node,
// Card hands us its root div (see getColumnRef / getCardRef below).
const columnEls = useRef(new Map<ColumnId, HTMLElement>());
const cardEls = useRef(new Map<string, HTMLElement>());
const columnRefCbs = useRef(
new Map<ColumnId, (el: HTMLDivElement | null) => void>(),
);
const cardRefCbs = useRef(
new Map<string, (el: HTMLDivElement | null) => void>(),
);
const teardownRef = useRef<(() => void) | null>(null);
// Stable ref callbacks so registering an element never churns across renders.
const getColumnRef = (id: ColumnId) => {
let cb = columnRefCbs.current.get(id);
if (!cb) {
cb = el => {
if (el) columnEls.current.set(id, el);
else columnEls.current.delete(id);
};
columnRefCbs.current.set(id, cb);
}
return cb;
};
const getCardRef = (id: string) => {
let cb = cardRefCbs.current.get(id);
if (!cb) {
cb = el => {
if (el) cardEls.current.set(id, el);
else cardEls.current.delete(id);
};
cardRefCbs.current.set(id, cb);
}
return cb;
};
const itemsByColumn = useMemo(() => {
const map: Record<ColumnId, WorkItem[]> = {
todo: [],
'in-progress': [],
'in-review': [],
done: [],
};
for (const item of items) {
map[item.column].push(item);
}
return map;
}, [items]);
// Hit-tests the pointer against the forwarded nodes in the registries.
// getBoundingClientRect() on Card/LayoutContent nodes is the raw geometry
// the ref contract gives us on demand — read at event time, never during render.
const computeTarget = (
px: number,
py: number,
draggedId: string,
): DropTarget | null => {
for (const [colId, el] of Array.from(columnEls.current.entries())) {
const r = el.getBoundingClientRect();
if (px < r.left || px > r.right || py < r.top || py > r.bottom) {
continue;
}
const ids = itemsByColumn[colId]
.filter(it => it.id !== draggedId)
.map(it => it.id);
let index = ids.length;
for (let i = 0; i < ids.length; i++) {
const cardEl = cardEls.current.get(ids[i]);
if (!cardEl) continue;
const cr = cardEl.getBoundingClientRect();
if (py < cr.top + cr.height / 2) {
index = i;
break;
}
}
return { column: colId, index };
}
return null;
};
// Rebuild the flat item list so the dragged card lands at the resolved slot
// while every other card keeps its relative order.
const commitDrag = (id: string, target: DropTarget) => {
setItems(prev => {
const moved = prev.find(it => it.id === id);
if (!moved) return prev;
const rest = prev.filter(it => it.id !== id);
const updated: WorkItem = { ...moved, column: target.column };
const colItems = rest.filter(it => it.column === target.column);
const anchor = colItems[target.index];
if (!anchor) return [...rest, updated];
const at = rest.indexOf(anchor);
return [...rest.slice(0, at), updated, ...rest.slice(at)];
});
};
const onCardPointerDown = (e: ReactPointerEvent, id: string) => {
if (e.button !== 0) return;
// Let the card's own controls (the actions menu) handle the press.
if (
(e.target as HTMLElement).closest(
'button, [role="menuitem"], [role="menu"]',
)
) {
return;
}
const el = cardEls.current.get(id);
if (!el) return;
const rect = el.getBoundingClientRect();
const startX = e.clientX;
const startY = e.clientY;
const offsetX = startX - rect.left;
const offsetY = startY - rect.top;
const { width, height } = rect;
let started = false;
let target: DropTarget | null = null;
const onMove = (ev: PointerEvent) => {
if (
!started &&
Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) <
DRAG_THRESHOLD
) {
return;
}
started = true;
target = computeTarget(ev.clientX, ev.clientY, id);
setDrag({
id,
width,
height,
offsetX,
offsetY,
pointerX: ev.clientX,
pointerY: ev.clientY,
target,
});
};
const onUp = () => {
teardownRef.current?.();
if (started && target) commitDrag(id, target);
setDrag(null);
};
const teardown = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
teardownRef.current = null;
};
teardownRef.current = teardown;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
const isDragging = drag !== null;
// Suppress selection while dragging and detach listeners on unmount.
useEffect(() => {
if (!isDragging) return;
const previous = document.body.style.userSelect;
document.body.style.userSelect = 'none';
return () => {
document.body.style.userSelect = previous;
};
}, [isDragging]);
useEffect(() => () => teardownRef.current?.(), []);
return {
drag,
itemsByColumn,
getColumnRef,
getCardRef,
onCardPointerDown,
};
}

The hook never imports an Astryx component — it only consumes the forwarded-refs contract. The Astryx work happens where refs are handed in (BoardCard, BoardColumn) and where drag state is consumed (the toolbar readout, and next lesson’s FloatingCard).

Live Drag Feedback in the Toolbar

Extend BoardToolbar with an optional dragStatus string — when a drag is active, the header shows where the pointer is aiming:

src/app/components/BoardToolbar.tsx
import { Toolbar } from '@astryxdesign/core/Toolbar';
import { Heading, Text } from '@astryxdesign/core/Text';
import { Badge } from '@astryxdesign/core/Badge';
import { Selector } from '@astryxdesign/core/Selector';
import { Divider } from '@astryxdesign/core/Divider';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Button } from '@astryxdesign/core/Button';
import { Icon } from '@astryxdesign/core/Icon';
import { HStack } from '@astryxdesign/core/Layout';
import { useTranslator } from '@astryxdesign/core/i18n';
import {
ArrowsUpDownIcon,
FunnelIcon,
MagnifyingGlassIcon,
MoonIcon,
SunIcon,
LanguageIcon,
PlusIcon,
} from '@heroicons/react/24/outline';
import { styles } from '../styles';
import { usePreferences } from '../providers';
interface BoardToolbarProps {
totalTasks: number;
sprint: string;
onSprintChange: (sprint: string) => void;
dragStatus?: string; // shown while a drag is in progress
}
export function BoardToolbar({
totalTasks,
sprint,
onSprintChange,
dragStatus,
}: BoardToolbarProps) {
const t = useTranslator();
const { mode, setMode, locale, setLocale } = usePreferences();
return (
<Toolbar
label="Board actions"
gap={2}
startContent={
<HStack gap={2} vAlign="center">
<Heading level={3}>{t('@app.board.title')}</Heading>
<Badge label={totalTasks} variant="neutral" />
{dragStatus ? (
<Text type="supporting" color="secondary" hasTabularNumbers>
{dragStatus}
</Text>
) : null}
</HStack>
}
endContent={
<HStack gap={2}>
{/* … sprint selector, icon buttons, theme + language toggles,
and the Add task button from lesson 10 — unchanged … */}
</HStack>
}
/>
);
}

That’s hook state flowing into the UI — the same pattern the floating drag clone will use in lesson 12.

Wiring It Up: KanbanBoard.tsx

Rewrite src/app/components/KanbanBoard.tsx to hold the items in state, call the hook, and feed its refs and callbacks into the components:

src/app/components/KanbanBoard.tsx
'use client';
import { useState } from 'react';
import { Section } from '@astryxdesign/core/Section';
import {
Layout,
LayoutHeader,
LayoutContent,
HStack,
VStack,
} from '@astryxdesign/core/Layout';
import { styles } from '../styles';
import { BoardCard } from './BoardCard';
import { BoardColumn } from './BoardColumn';
import { BoardToolbar } from './BoardToolbar';
import { useKanbanDrag } from '../hooks/useKanbanDrag';
import { COLUMNS, INITIAL_ITEMS } from '../data';
import type { ColumnId } from '../types';
import type { ColumnId, WorkItem } from '../types';
export default function KanbanBoard() {
const [items, setItems] = useState<WorkItem[]>(INITIAL_ITEMS);
const [sprint, setSprint] = useState('003');
// Temporary — real moves arrive with the drag hook in lesson 11.
const moveItem = (id: string, to: ColumnId) => {
alert(`Move task ${id} to ${to}`);
};
const { drag, itemsByColumn, getColumnRef, getCardRef, onCardPointerDown } =
useKanbanDrag(items, setItems);
const moveItem = (id: string, to: ColumnId) => {
setItems(prev =>
prev.map(item => (item.id === id ? { ...item, column: to } : item)),
);
};
return (
<Section height="100dvh">
<Layout
height="fill"
header={
<LayoutHeader hasDivider padding={4}>
<BoardToolbar
totalTasks={INITIAL_ITEMS.length}
totalTasks={items.length}
sprint={sprint}
onSprintChange={setSprint}
dragStatus={
drag
? `Dragging ${drag.id} → ${
drag.target
? `${drag.target.column} #${drag.target.index}`
: 'no target'
}`
: undefined
}
/>
</LayoutHeader>
}
content={
<LayoutContent padding={0}>
<HStack gap={4} xstyle={styles.boardColumns}>
{COLUMNS.map(col => {
const items = INITIAL_ITEMS.filter(it => it.column === col.id);
return (
{COLUMNS.map(col => (
<BoardColumn
key={col.id}
meta={col}
count={items.length}
count={itemsByColumn[col.id].length}
contentRef={() => {}}
contentRef={getColumnRef(col.id)}
>
{/* null children trigger BoardColumn's EmptyState fallback */}
{items.length > 0 ? (
{itemsByColumn[col.id].length > 0 ? (
<VStack gap={2}>
{items.map(item => (
{itemsByColumn[col.id].map(item => (
<BoardCard
key={item.id}
item={item}
cardRef={() => {}}
cardRef={getCardRef(item.id)}
onPointerDown={() => {}}
onPointerDown={onCardPointerDown}
onMove={moveItem}
/>
))}
</VStack>
) : null}
</BoardColumn>
);
})}
))}
</HStack>
</LayoutContent>
}
/>
</Section>
);
}

The two stubs from lessons 8–9 are now real: getCardRef/getColumnRef register DOM nodes in the hook, and onCardPointerDown drives the drag engine. The column counts and the header total now come from live items state.

See It in Action

Run npm run dev. Drag any card past the 5px threshold — the header readout updates live with the resolved drop target (column #index). Release the pointer to commit the move: cards reorder within a column and jump between columns. The menu still opens on click because the drag threshold ignores short presses. In lesson 12 we will add the floating clone and the ghost slot.

What You Built

In the next lesson, we will build the floating drag overlay and ghost landing slots.


Share this post on:

Previous
Application Toolbars with Selector & IconButton
Next
Floating Drag Overlays & Dynamic StyleX Functions