Dragging moves cards, but there’s no visual feedback yet — the card stays in place until you release. In this lesson we build FloatingCard — the fixed-position drag clone that follows the pointer — and the dashed ghost slot that marks the landing position in the target column.
Files this lesson: src/app/components/FloatingCard.tsx (new), src/app/components/KanbanBoard.tsx (rewritten). page.tsx stays untouched.
Astryx this lesson: Card with dynamic StyleX functions (floating, floatingAt), VStack ghost slots. Concepts: fixed-position overlays via xstyle, pointer-event passthrough, and z-index layering.
Drag Feedback Mechanics
-
Floating Card Overlay:
- Fixed viewport position (
position: 'fixed'—styles.floating). - Follows pointer coordinates (
transform: translate(x, y)— thefloatingAt(x, y, width)dynamic function). - High elevation shadow (
var(--shadow-high)). - Ignored pointer events (
pointerEvents: 'none'), so underlying columns keep receiving hit-tests. - Re-uses
BoardCardBodyto guarantee visual identity with the originating card.
Why dynamic StyleX functions instead of inline styles?
floatingAt(x, y, width)is compiled to atomic CSS at build time, so runtime-driven positioning still gets StyleX’s type-safety and performance — nostyle={{ transform: ... }}escaping the design system. This is the Astryx way to express runtime geometry. - Fixed viewport position (
-
Ghost Landing Slot:
- Empty placeholder container (
VStack xstyle={styles.ghost(height)}). - Dynamically spliced into the active target column’s item list at the calculated
index. - On dark themes the
ghostrule adds a dashed outline so the slot stays visible (lesson 4’s conditional styles in action).
- Empty placeholder container (
Implementation: FloatingCard.tsx
Create src/app/components/FloatingCard.tsx:
import { Card } from '@astryxdesign/core/Card';import { BoardCardBody } from './BoardCardBody';import { styles } from '../styles';import type { DragState, WorkItem } from '../types';
interface FloatingCardProps { drag: DragState; item: WorkItem;}
export function FloatingCard({ drag, item }: FloatingCardProps) { return ( <Card padding={3} xstyle={[ styles.floating, styles.floatingAt( drag.pointerX - drag.offsetX, drag.pointerY - drag.offsetY, drag.width, ), ]} > <BoardCardBody item={item} onMove={() => {}} /> </Card> );}styles.floatingpins the clone to the viewport, lifts it with--shadow-high, and lets pointer events pass through.styles.floatingAt(x, y, width)positions it so the pointer stays where you grabbed the card (offsetX/offsetYpreserve the grab point).BoardCardBodyrenders the identical content — no visual pop when the drag starts or ends.
Wiring It Up: KanbanBoard.tsx
Rewrite src/app/components/KanbanBoard.tsx. Two additions over lesson 11:
renderColumnCards(colId)— the column’s cards minus the one being dragged, with the ghost slot spliced in at the drop index when this column is the drag target.- Mount
FloatingCard(when a drag is active) at the very end, outside theLayout, so it floats above everything.
'use client';
import { useState } from 'react';import { useState, type ReactNode } 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 { FloatingCard } from './FloatingCard';import { useKanbanDrag } from '../hooks/useKanbanDrag';import { COLUMNS, INITIAL_ITEMS } from '../data';import type { ColumnId, WorkItem } from '../types';
export default function KanbanBoard() { const [items, setItems] = useState<WorkItem[]>(INITIAL_ITEMS); const [sprint, setSprint] = useState('003');
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)), ); };
const renderColumnCards = (colId: ColumnId): ReactNode => { const colItems = itemsByColumn[colId]; // The column's cards, minus the one currently being dragged // (it's following the pointer, not sitting in the list). const visible = drag ? colItems.filter(it => it.id !== drag.id) : colItems; // Only the column the pointer is over gets the ghost slot. const ghostTarget = drag && drag.target && drag.target.column === colId ? drag : null;
// An empty, non-target column falls back to BoardColumn's EmptyState. if (visible.length === 0 && !ghostTarget) { return null; }
const nodes: ReactNode[] = visible.map(it => ( <BoardCard key={it.id} item={it} cardRef={getCardRef(it.id)} onPointerDown={onCardPointerDown} onMove={moveItem} /> ));
if (ghostTarget && ghostTarget.target) { const index = Math.min(ghostTarget.target.index, nodes.length); nodes.splice( index, 0, <VStack key="drag-ghost" xstyle={styles.ghost(ghostTarget.height)} />, ); }
return <VStack gap={2}>{nodes}</VStack>; };
const draggedItem = drag ? items.find(it => it.id === drag.id) : undefined;
return ( <Section height="100dvh"> <Layout height="fill" header={ <LayoutHeader hasDivider padding={4}> <BoardToolbar 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 => ( <BoardColumn key={col.id} meta={col} count={itemsByColumn[col.id].length} contentRef={getColumnRef(col.id)} > {/* null children trigger BoardColumn's EmptyState fallback */} {itemsByColumn[col.id].length > 0 ? ( <VStack gap={2}> {itemsByColumn[col.id].map(item => ( <BoardCard key={item.id} item={item} cardRef={getCardRef(item.id)} onPointerDown={onCardPointerDown} onMove={moveItem} /> ))} </VStack> ) : null} </BoardColumn> ))} {COLUMNS.map(meta => ( <BoardColumn key={meta.id} meta={meta} count={itemsByColumn[meta.id].length} contentRef={getColumnRef(meta.id)} > {renderColumnCards(meta.id)} </BoardColumn> ))} </HStack> </LayoutContent> } /> {drag && draggedItem ? ( <FloatingCard drag={drag} item={draggedItem} /> ) : null} </Section> );}Hold on to these three ideas in renderColumnCards — they are the heart of the drop experience:
visible— the column’s cards minus the dragged one.ghostTarget— only the column under the pointer gets the ghost, spliced at the drop index (clamped so it never exceeds the list).- Returning
null— an empty non-target column still shows itsEmptyState.
See It in Action
Run npm run dev. Drag a card: the original stays put while a floating clone — identical content, high elevation shadow — follows your pointer at exactly the grab offset. Over a column, the ghost slot (a muted, rounded box; dashed-outlined in dark mode) slides into the list where the card will land. Release to commit. The ghost’s height matches the dragged card, so the layout never jumps.
What You Built
FloatingCard.tsx— the fixed-position drag clone driven by the dynamicfloatingAtStyleX function.- Ghost landing slots in
renderColumnCards, wired to the hook’s live drop target.
In the final lesson, we will review the complete application architecture and recap the Astryx vocabulary.