EmKanban
A drag-and-drop board — columns of cards that reorder and move across columns by pointer or keyboard, with the board owning the state and announcing every move.
Usage
EmKanban is the board; each EmKanbanColumn is one column, and the cards inside it come from the column’s cards prop. That prop is a seed: the column onboards the array once when it mounts, and from then on the board’s internal registry is the source of truth — dragging a card mutates the board, not your array. When another store needs to follow along, listen to the move event, which fires with { id, from, to, fromIndex, toIndex } for every drop, including a reorder inside one column.
The column’s default slot is the card body. It is scoped — v-slot="{ card }" hands you the card’s ticket, and card.value is the value you seeded — so one slot renders every card in the column and the board stays a pure layout concern. While a drag is over a column, the board paints a 2px indicator bar in the slot the card will land in.
Anatomy
<script setup lang="ts">
import { EmKanban, EmKanbanColumn } from '@paper/emerald'
</script>
<template>
<EmKanban>
<EmKanbanColumn />
</EmKanban>
</template>EmKanbanCard exists and is exported, but you never write it — each column renders one around your slot content per card.
Composed on v0
EmKanban instantiates two v0 composables at the root and shares both with its parts through context under the emerald:kanban namespace: createKanban, which owns the columns registry and the transfer primitive, and useDragDrop, which owns the draggables, the drop zones, and the drag lifecycle through its default pointer and keyboard adapters.
The split follows from that. Each EmKanbanColumn registers itself into kanban.columns and registers its card list as a vertical drop zone that accepts card drags; each card registers a draggable. From there v0 does the mechanics — hit-testing the pointer against zones, resolving which slot a drop lands in from the zone’s geometry, and gating everything on disabled — while Emerald owns everything a design system should: the DOM and its list semantics, the drop-indicator bar (drawn from the zone’s indicator rect), the polite live region and its messages, returning focus to a card after a keyboard drop, and the move event.
One correction lives in Emerald rather than v0, and it is worth knowing about if you build your own board: on a same-column drop the zone resolves its index against a stack that still contains the dragged card, while transfer removes before inserting — so the column subtracts one when the card moves down its own column. The reactive card iteration comes from v0 too, via useProxyRegistry.
Examples
Props
EmKanban
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | 'Board' | Accessible name for the board’s list element |
disabled | boolean | false | Freezes the board — no drags start and transfers no-op |
namespace | string | 'emerald:kanban' | Context the parts resolve against. Only needed when nesting boards |
The default slot is the columns. move is the only event, firing after every successful drop with EmKanbanMovePayload — { id, from, to, fromIndex, toIndex }, where from and to are column ids.
The template ref exposes exactly { kanban, dnd, announce }: the createKanban context (columns registry plus transfer), the useDragDrop context, and the function that writes to the board’s live region. Use kanban for runtime mutation — kanban.columns.get(id) for a column ticket, column.items.register({ value }) to add a card.
EmKanbanColumn
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | Column heading. Required; also names the column in announcements |
note | string | — | One-line description under the title |
tone | 'neutral' | 'primary' | 'secondary' | 'info' | 'alert' | 'danger' | — | Severity accent on the column’s top rule |
cards | { id?: ID, value: T }[] | [] | Seed cards, onboarded once when the column mounts. Later changes to the array are ignored |
id | ID | auto-generated | Column id — the value the move payload’s from / to refer to |
namespace | string | 'emerald:kanban' | Context to resolve against |
The component is generic over T, the card value type. The default slot renders each card’s body and is scoped to { card } — an EmKanbanCardTicket<T> whose value is what you seeded. title, note and tone are reactive; cards is not.
EmKanbanCard
| Prop | Type | Default | Description |
|---|---|---|---|
card | EmKanbanCardTicket<T> | — | The ticket to register as a draggable. Required |
namespace | string | 'emerald:kanban' | Context to resolve against |
Internal part — the column renders one per card around your slot content. It is exported for advanced composition, but a board authored from EmKanban and EmKanbanColumn never writes it.
Accessibility
The board is a role="list" named by label; each column is a role="listitem" section labelled by its own heading — the title renders as an h2 — and the cards sit in a nested list of their own. Each card is a focusable article with tabindex="0", described by a visually hidden instructions node: “Press space or enter to pick up a card, the arrow keys to move it, space or enter to drop it, and escape to cancel.” The drop-indicator bar is aria-hidden.
Keyboard
Drag-and-drop is fully keyboard-operable through v0’s keyboard adapter, which listens at the document level and acts on the focused card:
| Key | Behavior |
|---|---|
| Space, Enter | Pick up the focused card; drop it when one is being carried |
| Arrow keys | Nudge the carried card 16px in that direction |
| Escape | Cancel — the card stays where it was |
Two honest caveats. The arrow keys move a drag point, not a slot: the carried card travels in 16px steps, so crossing a full card or a column boundary takes several presses — the indicator bar shows where the card currently stands, and the live region speaks only at pick-up, drop, and cancel, not on every step. And keys are ignored while a modifier is held or while focus sits in an editable control, so the board never swallows shortcuts or typing.
After a keyboard drop the card remounts under its new column and takes focus back, so the reader lands on the card they just moved rather than at the top of the document. A pointer drop does not move focus.
Announcements
The board carries one polite live region (role="status"), and every drag milestone writes to it: picking a card up announces its position and column, a drop announces the destination and new position — “Moved card to Doing, position 2 of 3” — and a cancel announces that the card stayed put. Arrow-key steps in between are silent; position mid-drag is conveyed visually by the indicator bar. The announce function on the template ref writes to the same region, so custom actions (an “add card” button, a programmatic transfer) can speak through the board instead of adding a second live region beside it.
Card naming
The card article has no accessible-name wiring of its own — its name comes from its content, i.e. from what you render in the column’s slot. Keep a short text line first in the card so a reader hears something meaningful when a card takes focus; a card that leads with icon-only content is an unnamed stop in the tab order.
Disabled
disabled stops drags and transfers but deliberately leaves the cards focusable and described. The board does not announce refused pick-ups, so pair a frozen board with visible text explaining why it is frozen.