Kanban
A drag-and-drop board. Cards move between columns or reorder within one.
Like DataGrid, Kanban is
fully controlled and data-driven — you own the columns array and
apply the next one in onChange (v-model in Vue).
Uses native HTML5 drag-and-drop with zero dependencies.
Basic
Pass a controlled array of columns, each with its own cards. When a
card is dropped, Kanban calls onChange with the next array — store it
in state to complete the move. Card ids must be unique across the whole board.
Drag a card to another column — or reorder within one.
import { useState } from "react";
import { Kanban } from "@usevyre/react";
import type { KanbanColumn } from "@usevyre/react";
const [columns, setColumns] = useState<KanbanColumn[]>([
{ id: "todo", title: "To Do", cards: [
{ id: "c1", title: "Spec the API", description: "Define value/onChange shape" },
{ id: "c2", title: "Write docs page" },
]},
{ id: "doing", title: "In Progress", cards: [
{ id: "c3", title: "Drag-and-drop logic", description: "Native HTML5 DnD" },
]},
{ id: "done", title: "Done", cards: [
{ id: "c4", title: "Project kickoff" },
{ id: "c5", title: "Design tokens" },
]},
]);
<Kanban value={columns} onChange={setColumns} /> <script setup>
import { ref } from "vue";
import { Kanban } from "@usevyre/vue";
const columns = ref([
{ id: "todo", title: "To Do", cards: [
{ id: "c1", title: "Spec the API", description: "Define value/onChange shape" },
{ id: "c2", title: "Write docs page" },
]},
{ id: "doing", title: "In Progress", cards: [
{ id: "c3", title: "Drag-and-drop logic", description: "Native HTML5 DnD" },
]},
{ id: "done", title: "Done", cards: [
{ id: "c4", title: "Project kickoff" },
{ id: "c5", title: "Design tokens" },
]},
]);
</script>
<template>
<Kanban v-model="columns" />
</template> Custom cards & click handler
Use renderCard (React render prop) or the #card scoped
slot (Vue) for custom card content, and onCardClick /
@card-click to open a detail view. Clicks are suppressed immediately
after a drag so dropping never triggers a click. Compose the body from useVyre
primitives — here Item with ItemMedia /
ItemContent / ItemActions — so no extra CSS is needed.
Alex
Sam
Riya
Click a card — or drag it to another column.
import {
Kanban, Item, ItemMedia, ItemContent,
ItemTitle, ItemDescription, ItemActions, Avatar, Badge,
} from "@usevyre/react";
<Kanban
value={columns}
onChange={setColumns}
onCardClick={(card) => openDetail(card.id)}
renderCard={(card) => (
<Item variant="plain" size="sm" style={{ padding: 0 }}>
<ItemMedia>
<Avatar fallback={card.description?.[0] ?? "?"} size="sm" />
</ItemMedia>
<ItemContent>
<ItemTitle>{card.title}</ItemTitle>
<ItemDescription>{card.description}</ItemDescription>
</ItemContent>
<ItemActions><Badge variant="teal">{card.id}</Badge></ItemActions>
</Item>
)}
/> <Kanban v-model="columns" @card-click="onCardClick">
<template #card="{ card }">
<Item variant="plain" size="sm" style="padding:0">
<ItemMedia>
<Avatar :fallback="card.description?.[0] ?? '?'" size="sm" />
</ItemMedia>
<ItemContent>
<ItemTitle>{{ card.title }}</ItemTitle>
<ItemDescription>{{ card.description }}</ItemDescription>
</ItemContent>
<ItemActions><Badge variant="teal">{{ card.id }}</Badge></ItemActions>
</Item>
</template>
</Kanban> Complex card content
A card only needs id and title — attach any extra
fields (assignee, tags, progress, priority…) to the card object and read them
back inside renderCard. Each card is automatically wrapped in a
Card, so you only supply the
body. This example composes ItemContent, TagGroup /
Tag, Progress and Avatar — all useVyre
components, no custom CSS — and stays fully draggable.
Cards composed from Item, TagGroup, Progress and Avatar — drag still works.
import {
Kanban, ItemContent, ItemTitle, ItemMedia, ItemActions,
Item, TagGroup, Tag, Badge, Progress, Avatar,
} from "@usevyre/react";
// A card only needs { id, title }. Attach any extra app fields
// and read them back in renderCard — no extra CSS needed, the
// content is composed from useVyre components.
interface TaskCard {
id: string; title: string;
assignee: string; initials: string;
tags: string[]; progress: number;
priority: "Low" | "Medium" | "High";
}
const priorityColor = {
High: "danger", Medium: "warning", Low: "default",
} as const;
const [columns, setColumns] = useState<KanbanColumn[]>([
{ id: "todo", title: "To Do", cards: [{
id: "t1", title: "Implement OAuth callback",
assignee: "Alex Kim", initials: "AK",
tags: ["backend", "security"], progress: 20, priority: "High",
} satisfies TaskCard] },
]);
<Kanban
value={columns}
onChange={setColumns}
onCardClick={(card) => openDetail(card.id)}
renderCard={(card) => {
const c = card as unknown as TaskCard;
return (
<ItemContent>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<ItemTitle style={{ flex: 1 }}>{c.title}</ItemTitle>
<Badge variant={priorityColor[c.priority]}>{c.priority}</Badge>
</div>
<TagGroup gap="sm">
{c.tags.map((t) => <Tag key={t}>{t}</Tag>)}
</TagGroup>
<Progress value={c.progress} size="sm" />
<Item variant="plain" size="sm" style={{ padding: 0 }}>
<ItemMedia><Avatar fallback={c.initials} size="sm" /></ItemMedia>
<ItemContent><ItemTitle>{c.assignee}</ItemTitle></ItemContent>
<ItemActions>{c.progress}%</ItemActions>
</Item>
</ItemContent>
);
}}
/> <script setup>
import {
Kanban, ItemContent, ItemTitle, ItemMedia, ItemActions,
Item, TagGroup, Tag, Badge, Progress, Avatar,
} from "@usevyre/vue";
const priorityColor = {
High: "danger", Medium: "warning", Low: "default",
};
// columns: ref of KanbanColumn[]; cards carry extra fields
// (assignee, tags, progress, priority) read in the #card slot.
</script>
<template>
<Kanban v-model="columns" @card-click="openDetail">
<template #card="{ card }">
<ItemContent>
<div style="display:flex; align-items:center; gap:8px">
<ItemTitle style="flex:1">{{ card.title }}</ItemTitle>
<Badge :variant="priorityColor[card.priority]">
{{ card.priority }}
</Badge>
</div>
<TagGroup gap="sm">
<Tag v-for="t in card.tags" :key="t">{{ t }}</Tag>
</TagGroup>
<Progress :value="card.progress" size="sm" />
<Item variant="plain" size="sm" style="padding:0">
<ItemMedia><Avatar :fallback="card.initials" size="sm" /></ItemMedia>
<ItemContent><ItemTitle>{{ card.assignee }}</ItemTitle></ItemContent>
<ItemActions>{{ card.progress }}%</ItemActions>
</Item>
</ItemContent>
</template>
</Kanban>
</template> Custom colors
Set color on a column to tint the whole column, or on an
individual card to tint just that card. Values are semantic and token-based,
so they adapt to light/dark themes automatically. Available:
accent, teal, success,
warning, danger (and the implicit default).
Columns and individual cards carry a semantic color.
import { Kanban } from "@usevyre/react";
import type { KanbanColumn } from "@usevyre/react";
// color is a semantic tint: "accent" | "teal" | "success"
// | "warning" | "danger" | "default"
const [columns, setColumns] = useState<KanbanColumn[]>([
{ id: "active", title: "Active", color: "teal", cards: [
{ id: "g2", title: "API rate limiting", color: "warning" },
]},
{ id: "blocked", title: "Blocked", color: "danger", cards: [
{ id: "g4", title: "Vendor SDK upgrade", color: "danger" },
]},
{ id: "shipped", title: "Shipped", color: "success", cards: [
{ id: "g5", title: "Onboarding flow", color: "success" },
]},
]);
<Kanban value={columns} onChange={setColumns} /> <script setup>
import { ref } from "vue";
import { Kanban } from "@usevyre/vue";
const columns = ref([
{ id: "active", title: "Active", color: "teal", cards: [
{ id: "g2", title: "API rate limiting", color: "warning" },
]},
{ id: "blocked", title: "Blocked", color: "danger", cards: [
{ id: "g4", title: "Vendor SDK upgrade", color: "danger" },
]},
{ id: "shipped", title: "Shipped", color: "success", cards: [
{ id: "g5", title: "Onboarding flow", color: "success" },
]},
]);
</script>
<template>
<Kanban v-model="columns" />
</template> Props
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value / v-model | KanbanColumn[] | — | Controlled board data. KanbanColumn = { id, title, cards, color? }. KanbanCard = { id, title, description?, color? }. Card ids must be unique across the whole board. |
onChange / @update:modelValue | (next: KanbanColumn[]) => void | — | Called with the next columns array after a drag move. Apply it back to your state — Kanban holds no internal data. |
renderCard | (card, column) => ReactNode | — | Custom card body, rendered inside the wrapping Card. React: render prop. Vue: #card scoped slot. Can return any components. |
onCardClick / @card-click | (card, column) => void | — | Fired on click / Enter / Space (suppressed right after a drag). |
className / class | string | — | Additional CSS class on the board root. |
Color values
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color (column & card) | "default" | "accent" | "teal" | "success" | "warning" | "danger" | "default" | Semantic background tint. Set on a KanbanColumn to tint the whole column, or on a KanbanCard to tint that card. Token-based — adapts to theme. |
Common AI mistakes
- Kanban without onChange (or ignoring it)→ Store columns in state and setColumns in onChange (v-model in Vue)
- Duplicate card ids across columns→ Use globally-unique card ids across the entire board
- Mutating value in place then calling onChange→ Pass the new array Kanban gives you straight to setState / v-model
- color="blue" (or any non-semantic value)→ Use one of: "default" | "accent" | "teal" | "success" | "warning" | "danger"
Quick examples
const [columns, setColumns] = useState([
{ id: "todo", title: "To Do", cards: [{ id: "1", title: "Spec API" }] },
{ id: "doing", title: "In Progress", cards: [] },
{ id: "done", title: "Done", cards: [{ id: "2", title: "Kickoff" }] },
]);
<Kanban value={columns} onChange={setColumns} /><Kanban
value={columns}
onChange={setColumns}
onCardClick={(card) => openDetail(card.id)}
renderCard={(card) => (
<><strong>{card.title}</strong><Badge>{card.id}</Badge></>
)}
/>