Transcript
[00:00] So when we last left our application, we created this ability to drag things around, but it doesn’t look that great. It tracks it up there at the top where you’re about to go. You can drag them up and down and it’s pretty cool, but now we want to make it visually more appealing. And one thing I want to point out is in a previous lesson, way back when we dropped in our style TS using style X. And one really cool thing about style X, if you look right here are these functions. So these are like fully dynamic functions that we have in place that we can utilize as styles in our component. So we are going to create a new component. We’re going to call it like floating card. And we’re going to make this guy have a little ghost, like where is it about to land, move things out of the way, all of that good stuff. Okay, so let me switch back to my editor here.
[00:57] We’re going to create a new component called floating card TSX. And you’re going to be happy. This really doesn’t require a ton of code. Um, we’ve got our card, it’s got a basic style, but you can see here we’ve got styles floating, which is not a function. Um, and then we’ve got styles floating at, which is taking in this drag object, which is one of our props and our item. Uh, so we get our pointer X minus our, uh, pointer offset X kind of standard mathematics for figuring out where you are on the screen in relation to the, the DOM and the browser window. Uh, and then we drop in our board card body. So we’re no longer going to need that in our Kanban view. Uh, so this is done, uh, again, all of this code will be available to you. You don’t have to sweat it. Uh, I just don’t want to waste your time typing a bunch of stuff.
[01:49] So we are going to jump over to the Kanban board, uh, right off the bat. We’re going to need the type of react node. We’re going to need our floating card component. Now what we’re going to do is we’re going to come down here to our board card and we’re actually going to kill. I’m just going to take all of this and I’m going to replace it with this guy. So it’s still our board column, but now here we’re going to render column cards. So we’re going to have a function that does all this stuff that we were doing. So let’s go create that right here. After move item, I’m going to drop this in. So this is our render column card. So it takes in the column ID and it does the same stuff we were doing before. It gets the, uh, items by column. So give me all the items that are in there and then, uh, filter it based on whether we’re dragging or not. And this wants to be, so we’ll just do optional chaining there. Uh, so we get our ghost target. We make sure we’re hanging on to our empty state. And then each of our nodes is going to be a board card. So we’ve got all the same things we had before our ID, the item itself, the get card ref on card pointer down and move item. And then here, this also wants to be an optional chaining. And then if we need to, we can stick our ghost in the middle of all those nodes. So we do have a ghost style. So we’ve got ghost target height here. If I go to our styles, it just takes in a height and creates a, like a dummy card, a placeholder for. Uh, where we’re about to go or where we came from. And we just returned that V stack with the amount of nodes that are available. And then we just need to keep track of, uh, whether or not we’re dragging an item.
[03:40] So now all the way down here past, Ooh, did I break something now? I think that’s just weird coloring. So all the way down here, we’re going to drop in our floating card. So if we drag, if our state of drag is true and we have a dragged item, we need to drop in our floating card. So it’s actually a whole new card. So we’re going to save that and we’re going to see if I mess this all up. Okay. So render column cards. I did not close this expression. Save that there. Okay. Here we go. A moment of truth. We drag. Ha ha. Look at that. Pretty dope. Pretty dope. Bring this guy over here. Look, there’s our ghost in the middle. It goes there. Our empty states are working. Our little tracker up at the top is still working. Uh, all of this is looking pretty good. So that is, uh, where I’m going to leave off here.
[04:44] Um, you will definitely have access to all the source code for this project. Uh, feel free to do with it, whatever you like, expand on it. Um, I find the Astryx design system really easy to use. Um, maybe in terms of a react component system, uh, I would compare it to radix. Which I really, really like if you’ve never used radix. Um, but this comes with a lot of, a lot of extra stuff, a lot of really smart, um, kind of smart contracts that are available to you and super easy to look up and super easy to find. Um, they do claim this is actually written for AI agents, but I’ve really enjoyed going through and learning about it. And, uh, you know, I just like writing code. So, uh, uh, yeah, I’m a fan.
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.