NbBoard is a kanban-style board with columns, optional swim lanes, and drag-and-drop support. Card rendering is fully customizable via the card slot.
Basic Usage
<template>
<NbBoard :columns="columns" :items="items" @move="onMove">
<template #card="{ item }">
<strong>{{ item.title }}</strong>
</template>
</NbBoard>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { IBoardColumn, IBoardItem, IBoardMoveEvent } from '@nubisco/ui'
const columns: IBoardColumn[] = [
{ id: 'todo', label: 'To Do' },
{ id: 'doing', label: 'In Progress' },
{ id: 'done', label: 'Done' },
]
const items = ref<IBoardItem[]>([
{ id: '1', columnId: 'todo', title: 'Design mockups' },
{ id: '2', columnId: 'doing', title: 'Build API' },
{ id: '3', columnId: 'done', title: 'Write specs' },
])
function onMove(e: IBoardMoveEvent) {
const moved = items.value.find((i) => i.id === e.itemId)
if (!moved) return
moved.columnId = e.toColumnId
if (e.toLaneId !== undefined) moved.laneId = e.toLaneId
// Reinsert at the reported position so same-column reorders stick.
const rest = items.value.filter((i) => i.id !== moved.id)
const cell = rest.filter(
(i) =>
i.columnId === e.toColumnId &&
(i.laneId ?? null) === (e.toLaneId ?? null),
)
const anchor = cell[e.toIndex]
const at = anchor ? rest.indexOf(anchor) : rest.length
rest.splice(at, 0, moved)
items.value = rest
}
</script>Column Colors
Each column can have an accent color that renders as a top border on the column header.
<script setup>
const columns = [
{ id: 'todo', label: 'To Do', color: '#6366f1' },
{ id: 'doing', label: 'In Progress', color: '#f59e0b' },
{ id: 'done', label: 'Done', color: '#22c55e' },
]
</script>Column Sizing
Columns default to minmax(200px, 1fr): they share the available width equally and never shrink below 200px. On wide screens with few columns that reads as overly wide tracks, so the track is themeable via --nb-board-column-track. Set it on the board (or any ancestor) to pin columns to a fixed or bounded width; the board scrolls horizontally when they no longer fit.
/* Trello-style fixed-width columns */
.my-board {
--nb-board-column-track: 272px;
}
/* Bounded: never narrower than 240px, never wider than 320px */
.my-board {
--nb-board-column-track: minmax(240px, 320px);
}
## Custom Column Header
The default header shows the column label and a subtle item count. The `column-header` slot replaces both while keeping the color accent, for headers that carry badges or a column menu.
```vue
<NbBoard :columns="columns" :items="items" @move="onMove">
<template #column-header="{ column, count }">
<span>{{ column.label }}</span>
<NbBadge>{{ count }}</NbBadge>
</template>
<template #card="{ item }">
<strong>{{ item.title }}</strong>
</template>
</NbBoard>Adding Items Per Column
The column-footer slot renders at the bottom of every column cell, scoped to its column (and lane, in lane mode). Boards do not know what creating an item means in the host application, so the slot carries the affordance and the host carries the behavior — typically opening a creation dialog preset to that column. When the slot is absent it leaves no footprint.
<template>
<NbBoard :columns="columns" :items="items" @move="onMove">
<template #card="{ item }">
<strong>{{ item.title }}</strong>
</template>
<template #column-footer="{ column }">
<NbButton
size="sm"
variant="ghost"
icon="plus"
@click="openCreateDialog(column.id)"
>
Add item
</NbButton>
</template>
</NbBoard>
</template>Drag and Drop
Drag and drop is built in. When a card is dropped somewhere new, in another cell or at a different position in its own column, the move event fires with the source and destination coordinates, the target index, and the ids of the neighbouring items. The component does not mutate items directly; update your data in the event handler.
toIndex counts positions in the destination cell with the moved item excluded, so it is the index the item should occupy after the move. beforeItemId and afterItemId name the items that end up directly above and below it (null at the edges), which is exactly what a fractional-position scheme needs.
During a drag operation:
- A drop line marks the position the card would land in, and the target cell highlights with a primary-tinted background and dashed outline.
- Cards show a grab cursor and lift with a subtle shadow on hover.
Keyboard
Every card is focusable and can be moved without a pointer, the same pick-up-move-drop model as NbReorderList.
| Key | Does |
|---|---|
| Tab | Moves to the next card |
| Space / Enter | Picks the card up, or drops it (which emits move) |
| ↑ / ↓ | Moves the held card within its cell, or moves focus when nothing is held; past the edge of a cell it continues into the neighbouring lane |
| ← / → | Moves the held card to the adjacent column, or moves focus when nothing is held |
| Esc | Cancels the pick-up |
Arrow keys browse until a card is picked up, and only then move it. A held card does not travel until it is dropped: the board shows a ghost drop line at the target position and emits the same move event a pointer drop would.
Every pick-up, move and drop is announced through a live region, because a keyboard user does not see the card travel.
Reordering Columns
Set reorderableColumns and column headers become draggable. Dropping a header on another column emits column-move with the column's destination index; as with cards, the board does not mutate columns itself.
<template>
<NbBoard
:columns="columns"
:items="items"
reorderable-columns
@move="onMove"
@column-move="onColumnMove"
>
<template #card="{ item }">
<strong>{{ item.title }}</strong>
</template>
</NbBoard>
</template>
<script setup lang="ts">
import type { IBoardColumnMoveEvent } from '@nubisco/ui'
function onColumnMove(e: IBoardColumnMoveEvent) {
const from = columns.value.findIndex((c) => c.id === e.columnId)
const [col] = columns.value.splice(from, 1)
columns.value.splice(e.toIndex, 0, col)
}
</script>