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
- Element Registries: Uses
useRef(new Map())to track live DOM coordinates of columns and cards without causing re-render churn. DRAG_THRESHOLD(5px): Ensures short presses or clicks on nested controls (like theMoreMenuaction dropdown) fire normal click handlers without triggering drag mode.computeTarget: Iterates through column bounding boxes (getBoundingClientRect) to determine the target column and vertical card insertion index.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:
Cardforwards its rootdiv— that’s whatBoardCardhands togetCardRef.LayoutContentforwards its scrollable content node — that’s whatBoardColumnhands togetColumnRef.
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:
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:
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:
'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
src/app/hooks/useKanbanDrag.ts— the full drag engine: registries, hit-testing, threshold, reordering.- A board with working drag-and-drop driven entirely by hook state and Astryx forwarded refs.
In the next lesson, we will build the floating drag overlay and ghost landing slots.