Kanban
A task-board Kanban built on a framework-free kernel: columns and swimlanes over a plain card array with *Expr field mapping, per-column virtualization over a fixed card height (10k cards stay smooth), WIP limits with live drag previews, and a polished drag & drop — 3px threshold, live placeholder, lifted tilt, edge auto-scroll, mid-drag Escape restore, exactly one commit through the cancelable cardMoving pipeline. A built-in edit dialog, context menu and toolbar (search, collapse, add) come out of the box. No WAI-ARIA APG kanban pattern exists, so the widget is a set of labeled column lists with a roving focus over the cards — whose own buttons and template controls stay Tab-reachable — and adds Ctrl+Arrow keyboard card moving with polite live-region announcements, which no reference library offers.
@oge-ui/kanban is a commercial package — free for evaluation and development, with no watermark and no runtime license checks. See licensing for the terms.
Getting started
One element, a working board. Drag a card between columns (Escape cancels mid-drag), double-click a card to edit it, double-click empty column space to add one there, right-click for the built-in menu, and type in the toolbar to search. Columns declare titles, colors and WIP limits — or derive from the data.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeKanban } from '@oge-ui/kanban';
@Component({
selector: 'demo-root',
imports: [OgeKanban],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- One element, a working board. Any item shape binds through the
*Expr inputs — field names, dotted paths or getter functions. Columns can
be declared (with titles, colors and WIP limits) or derived from the data.
Drag a card between columns (Escape cancels mid-drag), double-click to
edit, right-click for the built-in menu, type in the toolbar to search,
and add a column at the board's end (allowColumnAdding). The edit
dialog only offers editors for fields the board maps. -->
<oge-kanban
[dataSource]="tasks"
keyExpr="id"
columnExpr="status"
titleExpr="title"
descriptionExpr="notes"
assigneeExpr="owner"
dueDateExpr="due"
priorityExpr="priority"
tagsExpr="labels"
[columns]="columns"
[allowColumnAdding]="true"
style="height: 520px"
/>
`,
})
export class Demo {
protected readonly columns = [
{ key: 'todo', title: 'To do', color: '#64748b' },
{ key: 'doing', title: 'In progress', color: '#2563eb', wipLimit: 3 },
{ key: 'review', title: 'Review', color: '#d97706' },
{ key: 'done', title: 'Done', color: '#16a34a' },
];
protected readonly tasks = [
{
id: 1,
status: 'doing',
title: 'Checkout revamp',
notes: 'New payment flow behind the feature flag',
owner: 'Ada Lovelace',
due: new Date(2026, 7, 21),
priority: 'high',
labels: ['feature'],
},
{
id: 2,
status: 'todo',
title: 'Wallet UI polish',
owner: ['Grace Hopper', 'Alan Turing'],
priority: 'medium',
labels: ['design'],
},
{ id: 3, status: 'todo', title: 'Upgrade CI runners', priority: 'low' },
{
id: 4,
status: 'review',
title: 'Fix login crash',
notes: 'Repro: expired refresh token',
owner: 'Ada Lovelace',
due: new Date(2026, 7, 5),
priority: 'high',
labels: ['bug'],
},
{ id: 5, status: 'done', title: 'Q3 roadmap draft' },
];
}Swimlanes
swimlaneExpr turns the board into swimlane rows × columns. Lane headers collapse — [(collapsedSwimlanes)] and [(collapsedColumns)] are two-way — and every lane scrolls its cells independently.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeKanban } from '@oge-ui/kanban';
@Component({
selector: 'demo-root',
imports: [OgeKanban],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- swimlaneExpr turns the board into swimlane rows × columns.
Lane headers collapse ([(collapsedSwimlanes)] is two-way, like
[(collapsedColumns)]); every lane scrolls its column cells
independently. -->
<oge-kanban
[dataSource]="tasks"
keyExpr="id"
columnExpr="status"
titleExpr="title"
swimlaneExpr="team"
[columns]="[{ key: 'todo', title: 'To do' }, { key: 'doing', title: 'In progress' }, { key: 'done', title: 'Done' }]"
[(collapsedSwimlanes)]="collapsedLanes"
style="height: 560px"
/>
`,
})
export class Demo {
protected readonly collapsedLanes = signal<readonly string[]>([]);
protected readonly tasks = [
{ id: 1, team: 'Platform', status: 'doing', title: 'Sharding rollout' },
{ id: 2, team: 'Platform', status: 'todo', title: 'Postgres 18 upgrade' },
{ id: 3, team: 'Mobile', status: 'todo', title: 'Push notification opt-in' },
{ id: 4, team: 'Mobile', status: 'done', title: 'Biometric login' },
{ id: 5, team: 'Web', status: 'doing', title: 'Design token migration' },
];
}WIP limits
wipLimit is a soft limit: the column badge shows count/limit, turns to the danger tone on overflow, and previews the target column's +1 while a drag hovers it. Limits count real data — search filtering never changes them.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeKanban } from '@oge-ui/kanban';
@Component({
selector: 'demo-root',
imports: [OgeKanban],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- wipLimit is a soft limit: the column count badge shows
count/limit, turns to the danger tone on overflow, and previews the
target column's +1 while a drag hovers it. Limits count real data —
search filtering never changes them. -->
<oge-kanban
[dataSource]="tasks"
keyExpr="id"
columnExpr="status"
titleExpr="title"
[columns]="[
{ key: 'todo', title: 'To do' },
{ key: 'doing', title: 'In progress', wipLimit: 2 },
{ key: 'done', title: 'Done' },
]"
style="height: 420px"
/>
`,
})
export class Demo {
protected readonly tasks = [
{ id: 1, status: 'doing', title: 'Payments API' },
{ id: 2, status: 'doing', title: 'Search relevance' },
{ id: 3, status: 'doing', title: 'One too many — WIP exceeded' },
{ id: 4, status: 'todo', title: 'Docs sweep' },
{ id: 5, status: 'done', title: 'Login rate limits' },
];
}Drag & drop pipeline
Every move — drag, Ctrl+Arrow or moveCard() — runs the same cancelable pipeline: cardMoving (set cancel to veto) then cardMoved. Here nothing may leave Done — try it. orderExpr persists the in-column order back onto your items; column headers drag too.
drag a card
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeKanban } from '@oge-ui/kanban';
import type { OgeKanbanCardMovingEvent } from '@oge-ui/kanban';
@Component({
selector: 'demo-root',
imports: [OgeKanban],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Every move — drag, Ctrl+Arrow or moveCard() — runs the same
cancelable pipeline: cardMoving (set cancel = true to veto) then
cardMoved with {card, fromColumn, toColumn, fromIndex, toIndex}.
Mid-drag Escape restores everything. orderExpr persists the in-column
order back onto your items; without it the array order is the board
order. Column headers drag too (allowColumnReordering). -->
<oge-kanban
[dataSource]="tasks"
keyExpr="id"
columnExpr="status"
titleExpr="title"
orderExpr="rank"
[allowColumnReordering]="true"
(cardMoving)="onMoving($event)"
(cardMoved)="log.set('moved: ' + $event.toColumn + ' @ ' + $event.toIndex)"
style="height: 420px"
/>
<p>{{ log() }}</p>
`,
})
export class Demo {
protected readonly log = signal('drag a card');
protected onMoving(event: OgeKanbanCardMovingEvent): void {
// veto example: nothing may leave "done"
if (event.fromColumn === 'done') event.cancel = true;
}
protected readonly tasks = [
{ id: 1, status: 'todo', title: 'Refactor auth', rank: 0 },
{ id: 2, status: 'todo', title: 'Ship dark mode', rank: 1 },
{ id: 3, status: 'doing', title: 'Bundle size audit', rank: 0 },
{ id: 4, status: 'done', title: 'This card is locked in', rank: 0 },
];
}Keyboard moving & a11y
Arrows rove between cards and columns, Enter edits, Delete deletes — and Ctrl+Arrow moves the focused card, the exact keyboard twin of the drag, with a polite live-region announcement after every commit. Columns are labeled lists with their count and WIP in the accessible name, one Tab stop each; Tab from a card continues into its quick-action buttons and any controls a card template renders.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeKanban } from '@oge-ui/kanban';
@Component({
selector: 'demo-root',
imports: [OgeKanban],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Full keyboard board: arrows rove between cards and columns,
Enter edits, Delete deletes, and Ctrl+Arrow MOVES the focused card —
the exact keyboard twin of the drag, with a polite live-region
announcement ("… moved to …, position 2 of 3") after every commit.
Columns are labeled lists of cards (one Tab stop each); Tab from a
card continues into its quick-action buttons. -->
<oge-kanban
[dataSource]="tasks"
keyExpr="id"
columnExpr="status"
titleExpr="title"
style="height: 420px"
/>
`,
})
export class Demo {
protected readonly tasks = [
{ id: 1, status: 'todo', title: 'Tab to the board, then arrow around' },
{ id: 2, status: 'todo', title: 'Ctrl+ArrowRight moves me' },
{ id: 3, status: 'doing', title: 'Enter opens my dialog' },
];
}Edit dialog & events
The built-in dialog (an OgeForm) covers the standard card fields; cardEditDialogShowing is both the veto and the customization point — formItems arrives pre-populated and may be mutated or replaced. This demo swaps the color field for a sprint picker.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeKanban } from '@oge-ui/kanban';
import type { OgeKanbanEditDialogShowingEvent } from '@oge-ui/kanban';
@Component({
selector: 'demo-root',
imports: [OgeKanban],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- The built-in dialog (an OgeForm) covers the standard fields;
cardEditDialogShowing is both the veto and the customization point —
formItems arrives pre-populated and may be mutated or replaced.
Saves and deletes run the cancelable cardUpdating / cardDeleting
pipelines; new cards go through cardAdding. -->
<oge-kanban
[dataSource]="tasks"
keyExpr="id"
columnExpr="status"
titleExpr="title"
descriptionExpr="notes"
(cardEditDialogShowing)="onDialogShowing($event)"
style="height: 420px"
/>
`,
})
export class Demo {
protected onDialogShowing(event: OgeKanbanEditDialogShowingEvent): void {
// drop the color field and add a sprint picker
event.formItems = [
...event.formItems.filter((item) => item.field !== 'color'),
{
field: 'sprint',
label: 'Sprint',
editorType: 'selectBox',
editorOptions: { items: ['Sprint 41', 'Sprint 42', 'Sprint 43'] },
},
];
}
protected readonly tasks = [
{ id: 1, status: 'todo', title: 'Double-click me', notes: 'Custom form field below' },
{ id: 2, status: 'doing', title: 'Right-click me for the menu' },
];
}Card template
*ogeKanbanCardTemplate replaces the card body while drag, keyboard and ARIA stay on the component. Controls inside the template are real interactive content — Tab from a card reaches its Roll back button, which neither starts a drag nor steals the board's arrow keys (Escape returns to the card). Rich templates usually pair with a matching cardHeight — or opt out of virtualization entirely when heights must vary (documented exception).
v2.14.0v1.9.2v3.1.1
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeKanban, OgeKanbanCardTemplate } from '@oge-ui/kanban';
import type { OgeKanbanCard } from '@oge-ui/kanban';
@Component({
selector: 'demo-root',
imports: [OgeKanban, OgeKanbanCardTemplate],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- *ogeKanbanCardTemplate replaces the card body while drag,
keyboard and ARIA stay on the component. card.source is your original
item (typed unknown — narrow it in a helper). Controls in the template
are real interactive content: Tab from the card reaches the button, it
never starts a drag, and Escape returns to the card. Rich templates
usually pair with a matching cardHeight, or opt out of virtualization
entirely ([virtualScrolling]="false") when heights must vary. -->
<oge-kanban
[dataSource]="deployments"
keyExpr="id"
columnExpr="stage"
titleExpr="service"
[cardHeight]="112"
style="height: 420px"
>
<ng-template ogeKanbanCardTemplate let-card>
<div style="padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; flex: 1">
<strong>{{ card.title }}</strong>
<code style="font-size: 11px">{{ field(card, 'version') }}</code>
<progress
[value]="+field(card, 'health')"
max="100"
style="width: 100%"
></progress>
<button type="button" (click)="log.set('Rollback requested: ' + card.title)">
Roll back
</button>
</div>
</ng-template>
</oge-kanban>
<p aria-live="polite">{{ log() }}</p>
`,
})
export class Demo {
protected readonly log = signal('');
protected field(card: OgeKanbanCard, name: string): string {
return String((card.source as Record<string, unknown>)[name] ?? '');
}
protected readonly deployments = [
{ id: 1, stage: 'staging', service: 'api-gateway', version: 'v2.14.0', health: 98 },
{ id: 2, stage: 'staging', service: 'search', version: 'v1.9.2', health: 74 },
{ id: 3, stage: 'production', service: 'billing', version: 'v3.1.1', health: 100 },
];
}Configuration & i18n
Every user-facing string — toolbar, menu, dialog, aria labels, live-region announcements — lives in OgeKanbanMessages: provide once, override per instance with [messages]. locale drives every Intl format (the due-date badges here render in German).
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeKanban, provideOgeKanbanConfig } from '@oge-ui/kanban';
@Component({
selector: 'demo-root',
imports: [OgeKanban],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Every user-facing string — toolbar, menu, dialog, aria labels,
live-region announcements — lives in OgeKanbanMessages. Provide once,
override per instance with [messages]; locale drives every Intl
format (due-date badges). -->
<oge-kanban
[dataSource]="tasks"
keyExpr="id"
columnExpr="status"
titleExpr="title"
dueDateExpr="due"
locale="de-DE"
style="height: 380px"
/>
`,
})
export class Demo {
// App-wide (main.ts / route providers):
// provideOgeKanbanConfig({
// locale: 'de-DE',
// cardHeight: 104,
// messages: {
// toolbar: {
// label: 'Kanban-Werkzeugleiste',
// addCard: 'Neue Karte',
// collapseAll: 'Alle einklappen',
// expandAll: 'Alle ausklappen',
// searchLabel: 'Karten durchsuchen',
// searchPlaceholder: 'Suchen…',
// clearSearch: 'Suche löschen',
// },
// },
// })
protected readonly tasks = [
{ id: 1, status: 'Offen', title: 'Angebot schreiben', due: new Date(2026, 7, 14) },
{ id: 2, status: 'In Arbeit', title: 'Rechnung prüfen' },
{ id: 3, status: 'Fertig', title: 'Kickoff-Termin' },
];
}