Skip to content

Mastering Astryx / lesson 8 of 13

Interactive Cards & the xstyle Prop

Wrap work items in an interactive BoardCard component with custom StyleX elevation and pointer press listeners.

The BoardCardBody from lesson 7 is the presentation. Now we give it a draggable shell: BoardCard — the interactive wrapper that applies custom hover elevation through the xstyle prop and forwards pointer press events for the drag engine in lesson 11.

Files this lesson: src/app/components/BoardCard.tsx (new), src/app/components/KanbanBoard.tsx (rewritten). page.tsx stays untouched.

Astryx this lesson: Card deep-dive — the xstyle prop for safe style overrides, StyleX pseudo-classes (:hover), and how Card forwards its DOM node and onPointerDown for custom interactions.

The Card Contract

Astryx Card gives us three things we need:

  1. xstyle — merges our custom StyleX rules (from styles.card in lesson 4) into the card without breaking its design-system styles.
  2. Forwarded refref exposes the underlying div, which the drag hook will use for hit-testing geometry.
  3. onPointerDown — a pointer event hook the drag engine will listen to.

The elevation rules live in styles.card (lesson 4): a grab cursor, userSelect: 'none', touchAction: 'none', a 120ms box-shadow transition, and the --shadow-med hover elevation — with the dark-mode light-ring override on top. The component itself stays thin.

Implementation: BoardCard.tsx

Create src/app/components/BoardCard.tsx:

src/app/components/BoardCard.tsx
import type { PointerEvent as ReactPointerEvent } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { BoardCardBody } from './BoardCardBody';
import { styles } from '../styles';
import type { ColumnId, WorkItem } from '../types';
interface BoardCardProps {
item: WorkItem;
cardRef: (el: HTMLDivElement | null) => void;
onPointerDown: (e: ReactPointerEvent, id: string) => void;
onMove: (id: string, to: ColumnId) => void;
}
export function BoardCard({
item,
cardRef,
onPointerDown,
onMove,
}: BoardCardProps) {
return (
<Card
ref={cardRef}
padding={3}
xstyle={styles.card}
onPointerDown={e => onPointerDown(e, item.id)}
>
<BoardCardBody item={item} onMove={onMove} />
</Card>
);
}

Using It: KanbanBoard.tsx

Rewrite src/app/components/KanbanBoard.tsx to render BoardCard per item instead of the inline Card + BoardCardBody combo. The cardRef and onPointerDown props are stubbed until the drag hook in lesson 11:

src/app/components/KanbanBoard.tsx
'use client';
import { Section } from '@astryxdesign/core/Section';
import {
Layout,
LayoutHeader,
LayoutContent,
HStack,
VStack,
} from '@astryxdesign/core/Layout';
import { Heading, Text } from '@astryxdesign/core/Text';
import { Card } from '@astryxdesign/core/Card';
import { Button } from '@astryxdesign/core/Button';
import { useTranslator } from '@astryxdesign/core/i18n';
import { usePreferences } from '../providers';
import { styles } from '../styles';
import { BoardCardBody } from './BoardCardBody';
import { BoardCard } from './BoardCard';
import { COLUMNS, INITIAL_ITEMS } from '../data';
import type { ColumnId } from '../types';
export default function KanbanBoard() {
const t = useTranslator();
const { mode, setMode, locale, setLocale } = usePreferences();
// Temporary — real moves arrive with the drag hook in lesson 11.
const moveItem = (id: string, to: ColumnId) => {
alert(`Move task ${id} to ${to}`);
};
return (
<Section height="100dvh">
<Layout
height="fill"
header={
<LayoutHeader hasDivider padding={4}>
<HStack hAlign="between" vAlign="center">
<Heading level={3}>{t('@app.board.title')}</Heading>
<HStack gap={2} vAlign="center">
<Text type="supporting" color="secondary" hasTabularNumbers>
{INITIAL_ITEMS.length} {t('@app.tasks')}
</Text>
<Button
label={t('@app.switchLanguage')}
variant="secondary"
onClick={() => setLocale(locale === 'en' ? 'fr' : 'en')}
/>
<Button
label={
mode === 'system'
? 'Light mode'
: mode === 'light'
? 'Dark mode'
: 'System mode'
}
variant="secondary"
onClick={() =>
setMode(
mode === 'system'
? 'light'
: mode === 'light'
? 'dark'
: 'system',
)
}
/>
</HStack>
</HStack>
</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 (
<Card
key={col.id}
variant="muted"
padding={0}
xstyle={styles.columnShell}
>
<VStack gap={3} padding={3}>
<Heading level={4}>{col.title}</Heading>
<Text
type="supporting"
color="secondary"
hasTabularNumbers
>
{items.length} {t('@app.tasks')}
</Text>
<VStack gap={2}>
{items.map(item => (
<Card
key={item.id}
padding={3}
xstyle={styles.card}
>
<BoardCardBody item={item} onMove={moveItem} />
</Card>
<BoardCard
key={item.id}
item={item}
cardRef={() => {}}
onPointerDown={() => {}}
onMove={moveItem}
/>
))}
</VStack>
</VStack>
</Card>
);
})}
</HStack>
</LayoutContent>
}
/>
</Section>
);
}

Note we no longer import Card for task cards (only for column shells) — BoardCard owns the card rendering.

See It in Action

Run npm run dev. The board looks the same as lesson 7 — but now every task is a dedicated BoardCard: hover elevation, grab cursor, no text selection on drag-attempt presses, and the MoreMenu still works inside (the onPointerDown stub does nothing yet). In lesson 11, these stubbed props come alive.

What You Built

In the next lesson, we will build the column shell with StatusDot, Tooltip, and EmptyState.


Share this post on:

Previous
Presenting Work Items: Card, Badge & MoreMenu
Next
Column Shells with StatusDot, Tooltip & EmptyState