Skip to content

Mastering Astryx / lesson 12 of 13

Floating Drag Overlays & Dynamic StyleX Functions

Render floating drag overlays using fixed positioning and dynamic StyleX rules alongside landing ghost slots.

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

  1. Floating Card Overlay:

    • Fixed viewport position (position: 'fixed'styles.floating).
    • Follows pointer coordinates (transform: translate(x, y) — the floatingAt(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 BoardCardBody to 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 — no style={{ transform: ... }} escaping the design system. This is the Astryx way to express runtime geometry.

  2. 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 ghost rule adds a dashed outline so the slot stays visible (lesson 4’s conditional styles in action).

Implementation: FloatingCard.tsx

Create src/app/components/FloatingCard.tsx:

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>
);
}

Wiring It Up: KanbanBoard.tsx

Rewrite src/app/components/KanbanBoard.tsx. Two additions over lesson 11:

  1. 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.
  2. Mount FloatingCard (when a drag is active) at the very end, outside the Layout, so it floats above everything.
src/app/components/KanbanBoard.tsx
'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:

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

In the final lesson, we will review the complete application architecture and recap the Astryx vocabulary.


Share this post on:

Previous
Drag-and-Drop Interactions & Astryx Ref Forwarding
Next
Final Assembly: Composing the Astryx Application