Kanban
kanbanuiComposable compound Kanban primitive for building board, pipeline, and agile workflows with tactile card drag-and-drop mechanics.
Installation
$pnpm dlx uipkge-ng@latest add kanban$npx uipkge-ng@latest add kanban$yarn dlx uipkge-ng@latest add kanban$bunx uipkge-ng@latest add kanbanManifest:
https://uipkge.dev/r/angular/kanban.jsonInstalls to:src/app/components/ui/kanban/Variants
Loading interactive previews…
Loading interactive previews…
Examples in Use
Full BlockMulti-column agile board composing Kanban columns, drag-and-drop task cards, priority indicators, and member avatar stacks.
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
isOver | 'true''false' | — | optional |
class | string | — | optional |
cardMoveReact `onCardMove`. | EventEmitter<KanbanMoveEvent> | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
KanbanMoveEventinterface KanbanMoveEvent {
cardId: string
fromColumnId: string
toColumnId: string
toIndex?: number
}npm dependencies
Files installed (3)
src/app/components/ui/kanban/kanban.component.ts19.1 kB
import { AfterViewInit, ApplicationRef, Component, ElementRef, EventEmitter, Input, Output, booleanAttribute, effect, inject, signal, ChangeDetectionStrategy, } from '@angular/core' import { cn } from '@/lib/utils' import { kanbanCardVariants, kanbanColumnVariants } from './kanban.variants' export interface KanbanMoveEvent { cardId: string fromColumnId: string toColumnId: string toIndex?: number } /* ------------------------------------------------------------------ */ /* Kanban (root provider) */ /* ------------------------------------------------------------------ */ /** * Angular port of UIPKGE Kanban (React parity). The root holds the drag state shared by * columns and cards (pointer drag via native HTML5 drag-and-drop, keyboard drag via Space / * arrows / Escape on a card) and narrates keyboard moves through a polite live region. * The consumer owns the data: every move is emitted through `cardMove` * `{ cardId, fromColumnId, toColumnId, toIndex? }` and the consumer re-renders. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban, [ui-kanban]', standalone: true, host: { '[attr.data-uipkge]': '""', '[attr.data-slot]': '"kanban"', '[class]': 'hostClass', }, template: `<ng-content /> <div data-slot="kanban-live-region" class="sr-only" role="status" aria-live="polite" aria-atomic="true"> {{ announcement() }} </div>`, }) export class UiKanbanComponent { @Input('class') className?: string /** React `onCardMove`. */ @Output() cardMove = new EventEmitter<KanbanMoveEvent>() readonly draggingCardId = signal<string | null>(null) readonly draggingColumnId = signal<string | null>(null) readonly overColumnId = signal<string | null>(null) /** Set while a card is held by keyboard (Space), not by pointer drag. */ readonly grabbedCardId = signal<string | null>(null) readonly announcement = signal('') /** * True for one task after a keyboard move is emitted. Angular re-renders the moved card * by destroying / re-inserting its DOM node, which fires a blur that is not the user * leaving the card (React detaches its handlers first, so it never sees that blur). */ moving = false private readonly appRef = inject(ApplicationRef) /** Emit a keyboard move and re-render at once (React flushes discrete events synchronously), * so an immediate second arrow press reads the new card order. */ emitMove(event: KanbanMoveEvent): void { this.moving = true setTimeout(() => (this.moving = false)) this.cardMove.emit(event) this.appRef.tick() } get hostClass(): string { return cn('block w-full', this.className) } setDraggingCard(cardId: string | null, columnId: string | null): void { this.draggingCardId.set(cardId) this.draggingColumnId.set(columnId) } setOverColumn(columnId: string | null): void { this.overColumnId.set(columnId) } setGrabbedCard(cardId: string | null): void { this.grabbedCardId.set(cardId) } /** Speak a message through the board's polite live region. */ announce(message: string): void { // Re-assigning the same string would not re-trigger the live region. this.announcement.update((current) => (current === message ? `${message} ` : message)) } } /* ------------------------------------------------------------------ */ /* KanbanBoard */ /* ------------------------------------------------------------------ */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-board, [ui-kanban-board]', standalone: true, host: { '[attr.data-slot]': '"kanban-board"', '[class]': 'hostClass' }, template: `<ng-content />`, }) export class UiKanbanBoardComponent { @Input('class') className?: string get hostClass(): string { return cn('flex w-full items-start gap-4 overflow-x-auto pb-4', this.className) } } /* ------------------------------------------------------------------ */ /* KanbanColumn */ /* ------------------------------------------------------------------ */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-column, [ui-kanban-column]', standalone: true, host: { '[attr.data-uipkge]': '""', '[attr.data-slot]': '"kanban-column"', role: 'group', '[attr.aria-label]': 'label ?? id', '[attr.data-column-id]': 'id', '[attr.data-over]': 'isOver ? "" : null', '[class]': 'hostClass', '(dragover)': 'onDragOver($event)', '(dragleave)': 'onDragLeave($event)', '(drop)': 'onDrop($event)', }, template: `<ng-content />`, }) export class UiKanbanColumnComponent { readonly kanban = inject(UiKanbanComponent) private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement @Input({ required: true }) id!: string /** Accessible name for the column, also used in keyboard move announcements. Falls back to the id. */ @Input() label?: string @Input('class') className?: string get isOver(): boolean { return this.kanban.overColumnId() === this.id } get hostClass(): string { return cn(kanbanColumnVariants({ isOver: this.isOver }), this.className) } onDragOver(e: DragEvent): void { e.preventDefault() if (e.dataTransfer) e.dataTransfer.dropEffect = 'move' if (this.kanban.overColumnId() !== this.id) this.kanban.setOverColumn(this.id) } onDragLeave(e: DragEvent): void { if (this.el.contains(e.relatedTarget as Node | null)) return if (this.kanban.overColumnId() === this.id) this.kanban.setOverColumn(null) } onDrop(e: DragEvent): void { e.preventDefault() const cardId = this.kanban.draggingCardId() const fromColumnId = this.kanban.draggingColumnId() if (cardId && fromColumnId) this.kanban.cardMove.emit({ cardId, fromColumnId, toColumnId: this.id }) this.kanban.setDraggingCard(null, null) this.kanban.setOverColumn(null) } } /* ------------------------------------------------------------------ */ /* KanbanColumnHeader & sub-components */ /* ------------------------------------------------------------------ */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-column-header, [ui-kanban-column-header]', standalone: true, host: { '[attr.data-slot]': '"kanban-column-header"', '[class]': 'hostClass' }, template: `<ng-content />`, }) export class UiKanbanColumnHeaderComponent { @Input('class') className?: string get hostClass(): string { return cn('flex items-center justify-between gap-2 px-1 py-0.5', this.className) } } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-column-dot, [ui-kanban-column-dot]', standalone: true, host: { '[attr.data-slot]': '"kanban-column-dot"', '[class]': 'hostClass' }, template: ``, }) export class UiKanbanColumnDotComponent { /** Any background utility. */ @Input() color = 'bg-primary' @Input('class') className?: string get hostClass(): string { return cn('size-2 shrink-0 rounded-full', this.color, this.className) } } /** Put it on an <h3> for React's DOM. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-column-title, [ui-kanban-column-title]', standalone: true, host: { '[attr.data-slot]': '"kanban-column-title"', '[class]': 'hostClass' }, template: `<ng-content />`, }) export class UiKanbanColumnTitleComponent { @Input('class') className?: string get hostClass(): string { return cn('block text-foreground text-sm font-semibold tracking-tight', this.className) } } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-column-count, [ui-kanban-column-count]', standalone: true, host: { '[attr.data-slot]': '"kanban-column-count"', '[class]': 'hostClass' }, template: `@if (count != null) { <ng-container>{{ count }}</ng-container> } @else { <ng-content /> }`, }) export class UiKanbanColumnCountComponent { /** Wins over projected content (React `count ?? children`). */ @Input() count?: number | string | null @Input('class') className?: string get hostClass(): string { return cn( 'bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-xs font-medium tabular-nums', this.className, ) } } /** Put it on a <button> for React's DOM. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-column-add, [ui-kanban-column-add]', standalone: true, host: { '[attr.type]': '"button"', '[attr.data-slot]': '"kanban-column-add"', '[class]': 'hostClass' }, template: `<ng-content ><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus size-3.5" aria-hidden="true" > <path d="M5 12h14" /> <path d="M12 5v14" /></svg ></ng-content>`, }) export class UiKanbanColumnAddComponent { @Input('class') className?: string get hostClass(): string { return cn( 'text-muted-foreground hover:bg-background hover:text-foreground focus-visible:ring-ring inline-flex size-6 items-center justify-center rounded-md transition-colors focus-visible:ring-1 focus-visible:outline-none', this.className, ) } } /* ------------------------------------------------------------------ */ /* KanbanColumnBody & Empty */ /* ------------------------------------------------------------------ */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-column-body, [ui-kanban-column-body]', standalone: true, host: { '[attr.data-slot]': '"kanban-column-body"', '[class]': 'hostClass' }, template: `<ng-content />`, }) export class UiKanbanColumnBodyComponent { @Input('class') className?: string get hostClass(): string { return cn('flex flex-1 flex-col gap-2 overflow-y-auto py-1', this.className) } } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-column-empty, [ui-kanban-column-empty]', standalone: true, host: { '[attr.data-slot]': '"kanban-column-empty"', '[class]': 'hostClass' }, template: `<ng-content>No cards</ng-content>`, }) export class UiKanbanColumnEmptyComponent { @Input('class') className?: string get hostClass(): string { return cn( 'border-border/60 text-muted-foreground/60 flex flex-1 items-center justify-center rounded-lg border border-dashed py-8 text-xs', this.className, ) } } /* ------------------------------------------------------------------ */ /* KanbanCard & sub-components */ /* ------------------------------------------------------------------ */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-card, [ui-kanban-card]', standalone: true, host: { '[attr.data-uipkge]': '""', '[attr.data-slot]': '"kanban-card"', '[attr.data-card-id]': 'id', '[attr.data-state]': 'isGrabbed ? "grabbed" : isDragging ? "dragging" : "idle"', '[attr.data-disabled]': 'disabled || null', '[attr.role]': 'canKeyboardDrag ? "button" : null', '[attr.tabindex]': 'canKeyboardDrag ? 0 : null', '[attr.aria-disabled]': 'disabled || null', '[attr.aria-roledescription]': 'canKeyboardDrag ? "draggable card" : null', '[attr.aria-pressed]': 'canKeyboardDrag ? isGrabbed : null', '[attr.draggable]': '!disabled', '[class]': 'hostClass', '(dragstart)': 'onDragStart($event)', '(dragend)': 'onDragEnd()', '(keydown)': 'onKeyDown($event)', '(blur)': 'onBlur()', }, template: `<ng-content />`, }) export class UiKanbanCardComponent implements AfterViewInit { readonly kanban = inject(UiKanbanComponent) readonly column = inject(UiKanbanColumnComponent) private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement @Input({ required: true }) id!: string @Input({ transform: booleanAttribute }) disabled = false /** Disable the keyboard grab (Space / arrows) while leaving pointer dragging intact. */ @Input({ transform: booleanAttribute }) keyboardDraggable = true @Input('class') className?: string constructor() { // The consumer owns the data, so a moved card is destroyed and re-created under its new // column; whichever instance is mounted while the card is held takes focus back. effect(() => { if (this.kanban.grabbedCardId() === this.id) queueMicrotask(() => this.refocus()) }) } get isGrabbed(): boolean { return this.kanban.grabbedCardId() === this.id } get isDragging(): boolean { return this.kanban.draggingCardId() === this.id || this.isGrabbed } get canKeyboardDrag(): boolean { return this.keyboardDraggable && !this.disabled } get hostClass(): string { return cn( kanbanCardVariants({ isDragging: this.isDragging }), this.disabled && 'pointer-events-none opacity-50', this.className, ) } ngAfterViewInit(): void { if (this.isGrabbed) this.refocus() } private refocus(): void { if (this.isGrabbed && this.el.isConnected && document.activeElement !== this.el) this.el.focus() } onDragStart(e: DragEvent): void { if (this.disabled) { e.preventDefault() return } if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', this.id) } this.kanban.setDraggingCard(this.id, this.column.id) } onDragEnd(): void { this.kanban.setDraggingCard(null, null) this.kanban.setOverColumn(null) } private columnName(el: HTMLElement | null): string { return el?.getAttribute('aria-label') || el?.dataset['columnId'] || 'column' } /** Ordered columns of the board this card sits in. */ private boardColumns(): HTMLElement[] { const board: ParentNode = this.el.closest('[data-slot="kanban"]') ?? document return Array.from(board.querySelectorAll<HTMLElement>('[data-slot="kanban-column"]')) } private grab(): void { this.kanban.setGrabbedCard(this.id) this.kanban.setDraggingCard(this.id, this.column.id) this.kanban.setOverColumn(this.column.id) this.kanban.announce('Picked up card. Use the arrow keys to move it, space to drop, escape to cancel.') } private release(cancelled: boolean): void { this.kanban.setGrabbedCard(null) this.kanban.setDraggingCard(null, null) this.kanban.setOverColumn(null) this.kanban.announce(cancelled ? 'Move cancelled.' : 'Card dropped.') } private moveToColumn(delta: -1 | 1): void { const columns = this.boardColumns() // The held card's column lives in the root: a second arrow press can land on this // instance before change detection has re-rendered it under its new column. const fromColumnId = this.kanban.draggingColumnId() ?? this.column.id const currentIdx = columns.findIndex((el) => el.dataset['columnId'] === fromColumnId) if (currentIdx === -1) return const target = columns[currentIdx + delta] // Deliberately not wrapping: running off the end of a board stops. const toColumnId = target?.dataset['columnId'] if (!toColumnId) return this.kanban.emitMove({ cardId: this.id, fromColumnId, toColumnId }) this.kanban.setDraggingCard(this.id, toColumnId) this.kanban.setOverColumn(toColumnId) this.kanban.announce(`Moved to ${this.columnName(target)}.`) } private moveWithinColumn(delta: -1 | 1): void { const columnEl = this.el.closest<HTMLElement>('[data-slot="kanban-column"]') if (!columnEl) return const cards = Array.from(columnEl.querySelectorAll<HTMLElement>('[data-slot="kanban-card"]')) const currentIdx = cards.findIndex((el) => el.dataset['cardId'] === this.id) if (currentIdx === -1) return const targetIdx = currentIdx + delta if (targetIdx < 0 || targetIdx > cards.length - 1) return this.kanban.emitMove({ cardId: this.id, fromColumnId: this.column.id, toColumnId: this.column.id, toIndex: targetIdx, }) this.kanban.announce(`Position ${targetIdx + 1} of ${cards.length}.`) } onKeyDown(e: KeyboardEvent): void { if (!this.canKeyboardDrag || e.defaultPrevented) return if (e.key === ' ' || e.key === 'Spacebar') { e.preventDefault() if (this.isGrabbed) this.release(false) else this.grab() return } if (!this.isGrabbed) return if (e.key === 'Escape') { e.preventDefault() this.release(true) return } if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault() this.moveToColumn(e.key === 'ArrowLeft' ? -1 : 1) return } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault() this.moveWithinColumn(e.key === 'ArrowUp' ? -1 : 1) } } onBlur(): void { // A grabbed card that loses focus (click elsewhere, Tab) would otherwise stay held. if (!this.isGrabbed) return if (this.kanban.moving) { // Blur from the move re-render: the mounted instance takes focus back. setTimeout(() => this.refocus()) return } this.release(true) } } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-card-header, [ui-kanban-card-header]', standalone: true, host: { '[attr.data-slot]': '"kanban-card-header"', '[class]': 'hostClass' }, template: `<ng-content />`, }) export class UiKanbanCardHeaderComponent { @Input('class') className?: string get hostClass(): string { return cn('flex flex-col gap-1', this.className) } } /** Put it on a <p> for React's DOM. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-card-title, [ui-kanban-card-title]', standalone: true, host: { '[attr.data-slot]': '"kanban-card-title"', '[class]': 'hostClass' }, template: `<ng-content />`, }) export class UiKanbanCardTitleComponent { @Input('class') className?: string get hostClass(): string { return cn('block text-foreground text-sm leading-snug font-medium', this.className) } } /** Put it on a <p> for React's DOM. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-card-description, [ui-kanban-card-description]', standalone: true, host: { '[attr.data-slot]': '"kanban-card-description"', '[class]': 'hostClass' }, template: `<ng-content />`, }) export class UiKanbanCardDescriptionComponent { @Input('class') className?: string get hostClass(): string { return cn('text-muted-foreground line-clamp-2 text-xs', this.className) } } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-kanban-card-footer, [ui-kanban-card-footer]', standalone: true, host: { '[attr.data-slot]': '"kanban-card-footer"', '[class]': 'hostClass' }, template: `<ng-content />`, }) export class UiKanbanCardFooterComponent { @Input('class') className?: string get hostClass(): string { return cn('text-muted-foreground mt-1 flex items-center justify-between gap-2 pt-1 text-xs', this.className) } } export { kanbanColumnVariants, kanbanCardVariants } export type { KanbanColumnVariantsProps, KanbanCardVariantsProps } from './kanban.variants'src/app/components/ui/kanban/kanban.variants.ts1.4 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' export const kanbanColumnVariants = cva( 'flex min-h-[300px] w-72 shrink-0 flex-col gap-2 rounded-xl border bg-muted/40 p-3 transition-colors duration-200 ease-out', { variants: { isOver: { true: 'border-primary/50 bg-primary/5 ring-2 ring-primary/20', false: 'border-border/70', }, }, defaultVariants: { isOver: false, }, }, ) export const kanbanCardVariants = cva( 'group relative flex cursor-grab flex-col gap-2 rounded-lg border bg-card p-3 text-card-foreground shadow-xs transition-[border-color,box-shadow,opacity] duration-150 ease-out active:cursor-grabbing hover:border-border hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', { variants: { isDragging: { // Keyboard grab reuses this state, so the held card stays fully // opaque — unlike a pointer drag there is no drag image to look at. true: 'shadow-none ring-2 ring-primary/40 opacity-100 data-[state=dragging]:opacity-40', false: 'opacity-100', }, }, defaultVariants: { isDragging: false, }, }, ) export type KanbanColumnVariantsProps = VariantProps<typeof kanbanColumnVariants> export type KanbanCardVariantsProps = VariantProps<typeof kanbanCardVariants>src/app/components/ui/kanban/index.ts0.6 kB
export { UiKanbanComponent, UiKanbanBoardComponent, UiKanbanColumnComponent, UiKanbanColumnHeaderComponent, UiKanbanColumnDotComponent, UiKanbanColumnTitleComponent, UiKanbanColumnCountComponent, UiKanbanColumnAddComponent, UiKanbanColumnBodyComponent, UiKanbanColumnEmptyComponent, UiKanbanCardComponent, UiKanbanCardHeaderComponent, UiKanbanCardTitleComponent, UiKanbanCardDescriptionComponent, UiKanbanCardFooterComponent, kanbanColumnVariants, kanbanCardVariants, type KanbanMoveEvent, type KanbanColumnVariantsProps, type KanbanCardVariantsProps, } from './kanban.component'
Raw manifest:https://uipkge.dev/r/angular/kanban.json