Board card
Playground
TSX
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import {
BoardCard,
BoardCardHeading,
BoardCardContent,
Badge,
Icon,
type BoardCardState,
} from '@mbsw/ui';
import {
CalendarDays,
ChevronRight,
FolderKanban,
MessageSquare,
Paperclip,
Target,
Users,
type LucideIcon,
} from 'lucide-react';
function Property({
icon,
label,
children,
}: {
icon: LucideIcon;
label: string;
children: React.ReactNode;
}) {
return (
<span
role="group"
aria-label={label}
title={label}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '3px 6px',
border: '1px solid var(--riido-border-strong)',
borderRadius: 'var(--riido-primitive-radius-radius-xs)',
fontSize: 12,
lineHeight: '16px',
}}
>
<Icon icon={icon} size="xs" decorative />
{children}
</span>
);
}
export default function Demo() {
const settings = {
"title": "Design system guide",
"description": "New user onboarding",
"state": "default"
};
const area = useRef<HTMLDivElement>(null);
const card = useRef<HTMLDivElement>(null);
const pointer = useRef<{ id: number; x: number; y: number } | null>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [delta, setDelta] = useState({ x: 0, y: 0 });
const [active, setActive] = useState<'pointer' | 'keyboard' | null>(null);
const [pointerFocus, setPointerFocus] = useState(false);
const [announcement, setAnnouncement] = useState('');
const instructions = useId();
const dragging = active !== null || settings.state === 'dragging';
function constrain(x: number, y: number) {
// Leave space for the package's rotated overlay at the preview edges.
const limitX = Math.max(
0,
((area.current?.clientWidth ?? 0) - (card.current?.offsetWidth ?? 0)) /
2 -
12,
);
const limitY = Math.max(
0,
((area.current?.clientHeight ?? 0) - (card.current?.offsetHeight ?? 0)) /
2 -
12,
);
return {
x: Math.max(-limitX, Math.min(limitX, x)),
y: Math.max(-limitY, Math.min(limitY, y)),
};
}
function move(x: number, y: number) {
const next = constrain(position.x + x, position.y + y);
setDelta({ x: next.x - position.x, y: next.y - position.y });
if (active === 'keyboard')
setAnnouncement(
"Position relative to center: {x}px horizontally, {y}px vertically."
.replace('{x}', String(Math.round(next.x)))
.replace('{y}', String(Math.round(next.y))),
);
}
function finish(cancel = false) {
if (!cancel)
setPosition(constrain(position.x + delta.x, position.y + delta.y));
pointer.current = null;
setDelta({ x: 0, y: 0 });
setActive(null);
setAnnouncement(cancel ? "Move cancelled." : "Card dropped.");
}
useEffect(() => {
const observer = new ResizeObserver(() => {
setPosition((current) => constrain(current.x, current.y));
pointer.current = null;
setDelta({ x: 0, y: 0 });
setActive(null);
});
if (area.current) observer.observe(area.current);
if (card.current) observer.observe(card.current);
return () => observer.disconnect();
}, []);
return (
<div
ref={area}
style={{
width: '100%',
height: '100vh',
display: 'grid',
placeItems: 'center',
}}
>
<span
id={instructions}
style={{
position: 'absolute',
width: 1,
height: 1,
overflow: 'hidden',
clipPath: 'inset(50%)',
}}
>
{"Drag the card to move it. Press Space or Enter to pick it up, use arrow keys to move, and press again to drop. Press Escape to cancel."}
</span>
<span
role="status"
aria-live="polite"
style={{
position: 'absolute',
width: 1,
height: 1,
overflow: 'hidden',
clipPath: 'inset(50%)',
}}
>
{announcement}
</span>
<div
style={{
position: 'relative',
width: 320,
maxWidth: 'calc(100% - 24px)',
transform: `translate(${position.x}px, ${position.y}px)`,
}}
>
{dragging && (
<BoardCard
state="dragging"
aria-hidden="true"
inert
style={{ position: 'absolute', inset: 0, height: '100%' }}
/>
)}
<BoardCard
ref={card}
role="button"
tabIndex={0}
aria-roledescription={"Draggable card"}
aria-describedby={instructions}
state={dragging ? 'overlay' : (settings.state as BoardCardState)}
aria-label={`WORK-128 ${settings.title}`}
style={{
width: '100%',
position: 'relative',
transform: `translate(${delta.x}px, ${delta.y}px)`,
cursor: active ? 'grabbing' : 'grab',
touchAction: 'none',
userSelect: 'none',
// Keep keyboard focus for Escape, but hide its ring for pointer input.
outline: pointerFocus ? 'none' : undefined,
}}
onPointerDown={(event) => {
if (event.button !== 0 || active === 'keyboard' || pointer.current)
return;
setPointerFocus(true);
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
pointer.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
};
}}
onPointerMove={(event) => {
const start = pointer.current;
if (!start || event.pointerId !== start.id) return;
const x = event.clientX - start.x;
const y = event.clientY - start.y;
if (!active && Math.hypot(x, y) < 4) return;
if (!active) setAnnouncement("Card picked up. Release to finish moving.");
setActive('pointer');
move(x, y);
}}
onPointerUp={(event) => {
if (pointer.current?.id === event.pointerId) finish();
}}
onPointerCancel={() => {
if (pointer.current) finish(true);
}}
onLostPointerCapture={() => {
if (pointer.current) finish(true);
}}
onBlur={() => {
setPointerFocus(false);
if (active === 'keyboard') finish(true);
}}
onKeyDown={(event) => {
setPointerFocus(false);
if (event.key === 'Escape' && (active || pointer.current)) {
event.preventDefault();
event.stopPropagation();
finish(true);
} else if (
(event.key === ' ' || event.key === 'Enter') &&
active !== 'pointer'
) {
event.preventDefault();
if (event.repeat) return;
if (active === 'keyboard') finish();
else {
setActive('keyboard');
setAnnouncement("Card picked up. Move with arrow keys and press Space to drop.");
}
} else if (
active === 'keyboard' &&
['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(
event.key,
)
) {
event.preventDefault();
const step = event.shiftKey ? 24 : 8;
move(
delta.x +
(event.key === 'ArrowLeft'
? -step
: event.key === 'ArrowRight'
? step
: 0),
delta.y +
(event.key === 'ArrowUp'
? -step
: event.key === 'ArrowDown'
? step
: 0),
);
}
}}
>
<BoardCardHeading>
<div style={{ display: 'grid', gap: 4, width: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 8px',
fontSize: 12,
lineHeight: '16px',
}}
>
<span style={{ color: 'var(--riido-foreground-secondary)' }}>
WORK-128
</span>
<span
role="group"
aria-label={"Parent task WORK-100"}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 2,
color: 'var(--riido-foreground-secondary)',
background: 'var(--riido-primary-subtle)',
borderRadius: 'var(--riido-primitive-radius-radius-full)',
padding: '2px 6px',
}}
>
<Icon icon={ChevronRight} size="2xs" decorative />
WORK-100
</span>
</div>
<div
style={{
padding: '2px 8px',
fontSize: 14,
lineHeight: '20px',
fontWeight: 500,
}}
>
{settings.title}
</div>
</div>
</BoardCardHeading>
<BoardCardContent>
<div style={{ display: 'grid', gap: 8, padding: '4px 8px' }}>
<span
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
color: 'var(--riido-foreground-secondary)',
fontSize: 12,
lineHeight: '20px',
}}
>
<Icon icon={FolderKanban} size="xs" decorative />
{settings.description}
</span>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 6,
}}
>
<Property icon={CalendarDays} label={"Task dates October 1 to October 8"}>
10/01 → 10/08
</Property>
<Property icon={Users} label={"2 participants"}>
{"2 people"}
</Property>
<Badge
size="md"
shape="square"
style={{
backgroundColor: 'var(--riido-primitive-colors-iris-400)',
height: 24,
minWidth: 48,
paddingInline: 6,
color: 'var(--riido-foreground-on-brand)',
}}
>
{"Design"}
</Badge>
<Property icon={Target} label={"Estimated size 3 points"}>
3
</Property>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
color: 'var(--riido-foreground-tertiary)',
fontSize: 12,
lineHeight: '16px',
}}
>
<span
role="img"
aria-label={"3 comments"}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
>
<Icon icon={MessageSquare} size="xs" decorative />3
</span>
<span
role="img"
aria-label={"2 attachments"}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
>
<Icon icon={Paperclip} size="xs" decorative />2
</span>
</div>
</div>
</BoardCardContent>
</BoardCard>
</div>
</div>
);
}
Properties
State
Content
Title
Project
Usage
The state prop changes the card appearance: dragging shows a placeholder at the original position, overlay shows the tilted card with its content, and ghost shows a translucent copy. Try dragging the card in Playground to see the placeholder and moving overlay together. Press Space or Enter to pick it up, use the arrow keys to move it and press the same key to drop it. Escape cancels. Reset settings returns the card to its starting position. The example implements dragging; BoardCard does not.
TSX
'use client';
import { BoardCard, BoardCardHeading, BoardCardContent } from '@mbsw/ui';
export default function Example() {
return (
<BoardCard>
<BoardCardHeading>{"Design system guide"}</BoardCardHeading>
<BoardCardContent>{"New user onboarding"}</BoardCardContent>
</BoardCard>
);
}
The Playground Code tab includes the full example with your current settings. See Installation for the CSS imports.