BrickclayDesign System

Kanban

A drag-and-drop board. Columns own their cards, drag one between columns or reorder it within one, add or remove cards, and every change reports back exactly what happened.

bk-kanbanData displayAngular

Preview

A working preview of Kanban in the Brickclay style. Try it.

To do2
Audit brand colorsDesign
Fleet API specData
In progress2
Matrix chartData
Header lockupDesign
Done1
Button tokensDesign
Interact with the preview to see events here.

Playground

Drag cards between columns or reorder them within one, then watch the payload below update live.

off (default): cross-column drops commit immediately, itemMoveBetweenColumns never fires.

warn: the operation succeeds, but the column header flags it and columnLimitExceeded fires for you to surface your own messaging.

LAST EMITTED PAYLOADS

Move, add, or remove an item to see the payload here.

RECENT OUTPUTS

columnsChanged / columnLimitExceeded / itemMoveBetweenColumns will show up here.

Data model

Every card needs four fields, id, label, sortOrder, parentId, everything else is yours.

SHAPE (this demo's KanbanTask extends ColumnItem)

interface ColumnItem {
  id: string;
  label: string;
  sortOrder: number;   // position within its column; the board renumbers this after every reorder
  parentId: string;    // the owning column's id; the board keeps this in sync on every cross-column move
  [key: string]: unknown;  // tag, hours, assignee, … - entirely yours
}

interface KanbanColumn<T extends ColumnItem = ColumnItem> {
  id: string;
  label: string;
  limit?: number;
  sortOrder: number;
  items: T[];            // the column owns its items - no separate flat array anywhere
  allowAdd?: boolean;     // false disables adding to just this column - built-in "+" and addItem() both
  allowRemove?: boolean;  // false disables removing from just this column - built-in button and removeItem() both
}

COLUMNS (live, the nested tree itself, columns own their items)

[
  {
    "id": "backlog",
    "label": "Backlog",
    "sortOrder": 1,
    "items": [
      {
        "id": "1",
        "label": "Fix typo",
        "tag": "Docs",
        "tagColor": "Gray",
        "hours": 1,
        "assignee": "Marcus",
        "sortOrder": 1,
        "parentId": "backlog"
      },
      {
        "id": "2",
        "label": "Update favicon",
        "tag": "Design",
        "tagColor": "Cyan",
        "hours": 1,
        "assignee": "Priya",
        "sortOrder": 2,
        "parentId": "backlog"
      },
      {
        "id": "3",
        "label": "Bug: dropdown closes on scroll",
        "tag": "Bug",
        "tagColor": "Error",
        "hours": 1,
        "assignee": "Dana",
        "sortOrder": 3,
        "parentId": "backlog"
      },
      {
        "id": "4",
        "label": "Research competitor pricing pages",
        "tag": "Research",
        "tagColor": "Purple",
        "hours": 2,
        "assignee": "Marcus",
        "sortOrder": 4,
        "parentId": "backlog"
      },
      {
        "id": "5",
        "label": "Plan Q4 roadmap review meeting",
        "tag": "Planning",
        "tagColor": "Orange",
        "hours": 2,
        "assignee": "Priya",
        "sortOrder": 5,
        "parentId": "backlog"
      },
      {
        "id": "6",
        "label": "Add dark mode toggle to settings page",
        "tag": "Frontend",
        "tagColor": "Primary",
        "hours": 4,
        "assignee": "Dana",
        "sortOrder": 6,
        "parentId": "backlog"
      },
      {
        "id": "7",
        "label": "Investigate flaky test in CI pipeline",
        "tag": "Infra",
        "tagColor": "Warning",
        "hours": 3,
        "assignee": "Marcus",
        "sortOrder": 7,
        "parentId": "backlog"
      },
      {
        "id": "8",
        "label": "Write onboarding email sequence for new signups",
        "tag": "Marketing",
        "tagColor": "Cyan",
        "hours": 5,
        "assignee": "Priya",
        "sortOrder": 8,
        "parentId": "backlog"
      },
      {
        "id": "9",
        "label": "Draft press release for v2 launch, coordinating with legal and comms on final wording and timing",
        "tag": "Marketing",
        "tagColor": "Cyan",
        "hours": 6,
        "assignee": "Dana",
        "sortOrder": 9,
        "parentId": "backlog"
      },
      {
        "id": "10",
        "label": "Refactor legacy auth module to use the new session store, cleaning up the old cookie-based fallback path along the way",
        "tag": "Frontend",
        "tagColor": "Primary",
        "hours": 12,
        "assignee": "Marcus",
        "sortOrder": 10,
        "parentId": "backlog"
      }
    ]
  },
  {
    "id": "todo",
    "label": "To do",
    "sortOrder": 2,
    "items": [
      {
        "id": "11",
        "label": "Audit onboarding funnel drop-off",
        "tag": "Research",
        "tagColor": "Purple",
        "hours": 3,
        "assignee": "Priya",
        "sortOrder": 1,
        "parentId": "todo"
      },
      {
        "id": "12",
        "label": "Draft Q3 roadmap deck",
        "tag": "Planning",
        "tagColor": "Cyan",
        "hours": 5,
        "assignee": "Marcus",
        "sortOrder": 2,
        "parentId": "todo"
      }
    ]
  },
  {
    "id": "design",
    "label": "Design",
    "limit": 3,
    "sortOrder": 3,
    "items": [
      {
        "id": "13",
        "label": "Spec the kanban component",
        "tag": "Design system",
        "tagColor": "Primary",
        "hours": 8,
        "assignee": "Priya",
        "sortOrder": 1,
        "parentId": "design"
      },
      {
        "id": "14",
        "label": "New empty-state illustration",
        "tag": "Design",
        "tagColor": "Cyan",
        "hours": 3,
        "assignee": "Dana",
        "sortOrder": 2,
        "parentId": "design"
      }
    ]
  },
  {
    "id": "in-progress",
    "label": "In progress",
    "limit": 3,
    "sortOrder": 4,
    "items": [
      {
        "id": "15",
        "label": "Wire up drag-and-drop",
        "tag": "Frontend",
        "tagColor": "Primary",
        "hours": 6,
        "assignee": "Dana",
        "sortOrder": 1,
        "parentId": "in-progress"
      },
      {
        "id": "16",
        "label": "Column WIP limit styling",
        "tag": "Frontend",
        "tagColor": "Primary",
        "hours": 2,
        "assignee": "Dana",
        "sortOrder": 2,
        "parentId": "in-progress"
      },
      {
        "id": "17",
        "label": "API reference page",
        "tag": "Docs",
        "tagColor": "Gray",
        "hours": 2,
        "assignee": "Marcus",
        "sortOrder": 3,
        "parentId": "in-progress"
      }
    ]
  },
  {
    "id": "code-review",
    "label": "Code review",
    "limit": 3,
    "sortOrder": 5,
    "items": [
      {
        "id": "18",
        "label": "Review column-drag PR",
        "tag": "Frontend",
        "tagColor": "Primary",
        "hours": 1,
        "assignee": "Priya",
        "sortOrder": 1,
        "parentId": "code-review"
      },
      {
        "id": "19",
        "label": "Review scroll-mode CSS",
        "tag": "Frontend",
        "tagColor": "Primary",
        "hours": 1,
        "assignee": "Marcus",
        "sortOrder": 2,
        "parentId": "code-review"
      }
    ]
  },
  {
    "id": "qa",
    "label": "QA",
    "sortOrder": 6,
    "items": [
      {
        "id": "20",
        "label": "Regression pass on drag-and-drop",
        "tag": "QA",
        "tagColor": "Warning",
        "hours": 3,
        "assignee": "Dana",
        "sortOrder": 1,
        "parentId": "qa"
      }
    ]
  },
  {
    "id": "staging",
    "label": "Staging",
    "sortOrder": 7,
    "items": []
  },
  {
    "id": "blocked",
    "label": "Blocked",
    "limit": 3,
    "sortOrder": 8,
    "items": [
      {
        "id": "21",
        "label": "Waiting on design sign-off for avatar group",
        "tag": "Blocked",
        "tagColor": "Error",
        "hours": 1,
        "assignee": "Priya",
        "sortOrder": 1,
        "parentId": "blocked"
      }
    ]
  },
  {
    "id": "product-review",
    "label": "Product review",
    "sortOrder": 9,
    "items": [
      {
        "id": "22",
        "label": "Accessibility pass on badges",
        "tag": "A11y",
        "tagColor": "Success",
        "hours": 3,
        "assignee": "Priya",
        "sortOrder": 1,
        "parentId": "product-review"
      }
    ]
  },
  {
    "id": "done",
    "label": "Done",
    "sortOrder": 10,
    "items": [
      {
        "id": "23",
        "label": "Ship v1 of avatar group",
        "tag": "Frontend",
        "tagColor": "Primary",
        "hours": 4,
        "assignee": "Dana",
        "sortOrder": 1,
        "parentId": "done"
      },
      {
        "id": "24",
        "label": "Migrate table demo to signals",
        "tag": "Frontend",
        "tagColor": "Primary",
        "hours": 5,
        "assignee": "Marcus",
        "sortOrder": 2,
        "parentId": "done"
      }
    ]
  }
]

Sorting

Manual drag order, or auto-sorted by due date, priority, or created date.

Manual: drag freely. Due date / Priority / Created date: dragging is off, and the list re-sorts by that field within each column.

Click handling

Click the card, the hours pill, or the avatar, each reports separately.

Click a card, the hours pill, or the avatar.

Add & remove

Toggle the built-in "+" and remove buttons on and off below.

Last columnsChanged payload will appear here, add or remove an item below.

Empty message

Set one message for every empty column, or override it per column.

Corner cases

Seven minimal boards, each already sitting right at the edge of one specific behavior, no setup needed, just try the one thing each card describes.

WIP limit on an add, not just a drag

This column is already at its limit (2/2). Click "+" and watch what wipLimitBehavior does to the add, exactly like it would to a drop.

Moving an item into an empty column

Drag an item from "Source" into "Starts empty", with nothing already there, it just lands at index 0 and the empty-state placeholder disappears immediately.

Removing the last item

Remove the only card here, the empty-column placeholder (its own emptyMessage) should appear immediately, same as a column that started empty.

Your own id vs. a generated one

Toggle, then click "+" a couple of times, addItem() uses an id you supply as-is; otherwise one is generated for you.

Custom remove link, built-in button off

showRemoveItem is not set here, no built-in "×" button, yet each card's own "Remove" link still works, wired straight to the removeItem the card context hands cardTemplate.

movementRestriction isolation

within: reordering inside A or B works, dragging between them is rejected. between: the exact reverse. validateMove is off here, so an allowed cross-column drop just commits, see the dedicated corner case below for the validation gate itself.

Cross-column validation (itemMoveBetweenColumns)

Drag a card from "Ready for QA" to "Approved", a real window.confirm() pops up before anything moves. OK it and the move goes through; Cancel it and the card visually stays in "Ready for QA", because resolve was called with { success: false } and nothing was ever mutated in the first place.

API reference

Import BkKanban and the [bkKanbanClickTarget] directive from the library's public-api.

Inputs

InputTypeDefaultNotes
columnsKanbanColumn<T>[]– requiredThe single source of truth, each column owns its own items. No separate cards array.
cardTemplateTemplateRef<BkKanbanCardContext<T>>– requiredRenders an item body. Receives { $implicit: card, card, column, index, removeItem }.
movementRestriction'within' | 'between' | 'both' | 'none''both'One control for both whether card dragging is on and how far, restrict to reordering, cross-column moves, allow either, or 'none' to turn card dragging off entirely (what used to be a separate dragEnabled: false).
wipLimitBehavior'block' | 'warn' | 'none''warn'What an over-limit add/drop does. columnLimitExceeded fires in all three. Never applies to a same-column reorder.
trackBy(card: T) => stringnumbercard.id | Identity for the card @for loop.
columnHeaderActionsTemplateTemplateRef<BkKanbanColumnActionsContext<T>>noneFixed header slot per column. Its addItem() is the same logic the built-in "+" button calls.
emptyStateTemplateTemplateRef<BkKanbanEmptyStateContext<T>>built-in "No card"Overrides the default empty-column placeholder entirely, takes priority over emptyMessage and columns[].emptyMessage.
emptyMessagestring'No card'Board-wide empty-column placeholder text. A column's own emptyMessage overrides this just for that column.
dragPreviewMode'live' | 'on-drop''live'Whether a column flags an over-limit drop while dragging, or only on release.
dragColumnbooleanfalseLets columns be reordered by dragging their header. Off by default, a bigger commitment than card drag.
columnReorderPayload'summary' | 'complete''complete'A column reorder always reports every column in the board (not just the ones that moved), 'summary' shrinks each entry to { id, sortOrder }; 'complete' sends the full column including items.
columnScrollMode'column' | 'wrapper''column''column' scrolls each column's card list independently under a fixed header; 'wrapper' scrolls the whole board vertically, every column together.
showAddItembooleanfalseShows a built-in "+" button in every column header (unless that column sets allowAdd: false), calling the same addItem logic as a custom columnHeaderActionsTemplate.
showRemoveItembooleanfalseShows a built-in remove button on every card (unless that card's column sets allowRemove: false). Click/drag isolation is handled for you.
validateMovebooleanfalseOff: a cross-column drop commits immediately, same as a same-column reorder, itemMoveBetweenColumns never fires. On: gates every cross-column drop behind that event instead.
boardClassstring''Extra class(es) on .bk-kanban-board, decoration only, one level outside the scroll container.
columnWidthstring'300px'One common width applied to every column, any CSS width (300px, 20rem, 25vw). The 300px default matches what was previously hardcoded, so an existing consumer sees no change unless they set this.

Outputs

OutputTypeDefaultNotes
columnsChanged{ columns: (KanbanColumn<T>{ id, sortOrder })[] }– | Fires after any operation that actually changed the board. Only the affected column(s) (a column reorder is the one exception, every column comes back), see the metadata field present on each to tell which operation happened.
itemMoveBetweenColumns{ item, sourceColumn, destinationColumn, targetItem, targetIndex, resolve }–Only fires when validateMove is true. Fires for a cross-column drag before anything is mutated, must be bound and resolve() called, { success: true } to commit the move or { success: false } to leave the board untouched, or the move never completes.
itemClicked{ item, target }–target is 'card', or the name given to the [bkKanbanClickTarget] the click landed inside.
columnLimitExceeded{ columnId, limit, attemptedCount }–An add or cross-column drop would exceed a limit, fires regardless of wipLimitBehavior.
columnActionTriggered{ columnId }–Reserved, see BkKanbanColumnActionsContext.trigger(). Most consumers bind their own handler instead.

KanbanColumn change metadata present only on a columnsChanged entry

FieldTypeDefaultNotes
sentChildIdstring–Cross-column move, source side: the id of the item that just left.
receivedChildIdstring–Cross-column move, destination side: the id of the item that just arrived.
newAddedItemIdstring–A just-added item's id.
newItemCreatedboolean–Set alongside newAddedItemId, always true, lets you branch on "an item was created" without also checking newAddedItemId for meaning.
removedItemIdstring–A just-removed item's id.

[bkKanbanClickTarget]

SelectorTypeDefaultNotes
[bkKanbanClickTarget]string– requiredMarks an element inside cardTemplate as its own click region, e.g. [bkKanbanClickTarget]="'hours'".

-