Kanban
kanbanuiComposable compound Kanban primitive for building board, pipeline, and agile workflows with tactile card drag-and-drop mechanics.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/kanbanInstalls to: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.
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
isOver | 'true''false' | — | optional |
class | HTMLAttributes['class'] | — | 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
}KanbanContextinterface KanbanContext {
draggingCardId: Ref<string | null>
draggingColumnId: Ref<string | null>
overColumnId: Ref<string | null>
/** Set while a card is held by keyboard (Space), not by pointer drag. */
grabbedCardId: Ref<string | null>
setDraggingCard: (cardId: string | null, columnId: string | null) => void
setOverColumn: (columnId: string | null) => void
setGrabbedCard: (cardId: string | null) => void
emitMove: (event: KanbanMoveEvent) => void
/** Speak a message through the board's polite live region. */
announce: (message: string) => void
}KanbanColumnContextinterface KanbanColumnContext {
columnId: string
/** Column label, used to announce keyboard moves. Falls back to the id. */
label: Ref<string | undefined>
}npm dependencies
Files installed (18)
app/components/ui/kanban/Kanban.vue1.7 kB
<script setup lang="ts"> import { provide, ref, type HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { KanbanContextKey, type KanbanMoveEvent } from './context' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() const emit = defineEmits<{ 'card-move': [event: KanbanMoveEvent] }>() const draggingCardId = ref<string | null>(null) const draggingColumnId = ref<string | null>(null) const overColumnId = ref<string | null>(null) const grabbedCardId = ref<string | null>(null) // Keyboard moves are silent to a screen reader — the card just appears // somewhere else. This region narrates pick up / move / drop / cancel. const announcement = ref('') function setDraggingCard(cardId: string | null, columnId: string | null) { draggingCardId.value = cardId draggingColumnId.value = columnId } function setOverColumn(columnId: string | null) { overColumnId.value = columnId } function setGrabbedCard(cardId: string | null) { grabbedCardId.value = cardId } function emitMove(event: KanbanMoveEvent) { emit('card-move', event) } function announce(message: string) { // Re-assigning the same string would not re-trigger the live region. announcement.value = announcement.value === message ? `${message} ` : message } provide(KanbanContextKey, { draggingCardId, draggingColumnId, overColumnId, grabbedCardId, setDraggingCard, setOverColumn, setGrabbedCard, emitMove, announce, }) </script> <template> <div data-uipkge data-slot="kanban" :class="cn('w-full', props.class)"> <slot /> <div data-slot="kanban-live-region" class="sr-only" role="status" aria-live="polite" aria-atomic="true"> {{ announcement }} </div> </div> </template>app/components/ui/kanban/KanbanBoard.vue0.4 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <div data-slot="kanban-board" :class="cn('flex w-full items-start gap-4 overflow-x-auto pb-4', props.class)"> <slot /> </div> </template>app/components/ui/kanban/KanbanColumn.vue2 kB
<script setup lang="ts"> import { computed, inject, provide, toRef, type HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { KanbanColumnContextKey, KanbanContextKey } from './context' import { kanbanColumnVariants } from './kanban.variants' interface Props { id: string /** Accessible name for the column, also used in keyboard move * announcements ("moved to In progress"). Falls back to the id. */ label?: string class?: HTMLAttributes['class'] } const props = defineProps<Props>() const kanban = inject(KanbanContextKey) provide(KanbanColumnContextKey, { columnId: props.id, label: toRef(props, 'label'), }) const isOver = computed(() => kanban?.overColumnId.value === props.id) function handleDragOver(e: DragEvent) { e.preventDefault() if (e.dataTransfer) { e.dataTransfer.dropEffect = 'move' } if (kanban && kanban.overColumnId.value !== props.id) { kanban.setOverColumn(props.id) } } function handleDragLeave(e: DragEvent) { const currentTarget = e.currentTarget as HTMLElement | null const relatedTarget = e.relatedTarget as HTMLElement | null if (currentTarget?.contains(relatedTarget)) return if (kanban && kanban.overColumnId.value === props.id) { kanban.setOverColumn(null) } } function handleDrop(e: DragEvent) { e.preventDefault() if (kanban && kanban.draggingCardId.value && kanban.draggingColumnId.value) { kanban.emitMove({ cardId: kanban.draggingCardId.value, fromColumnId: kanban.draggingColumnId.value, toColumnId: props.id, }) } kanban?.setDraggingCard(null, null) kanban?.setOverColumn(null) } </script> <template> <div data-uipkge data-slot="kanban-column" role="group" :aria-label="label ?? id" :data-column-id="id" :data-over="isOver ? '' : undefined" :class="cn(kanbanColumnVariants({ isOver }), props.class)" @dragover="handleDragOver" @dragleave="handleDragLeave" @drop="handleDrop" > <slot :is-over="isOver" /> </div> </template>app/components/ui/kanban/KanbanColumnHeader.vue0.4 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <div data-slot="kanban-column-header" :class="cn('flex items-center justify-between gap-2 px-1 py-0.5', props.class)"> <slot /> </div> </template>app/components/ui/kanban/KanbanColumnDot.vue0.4 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { color?: string class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { color: 'bg-primary', }) </script> <template> <span data-slot="kanban-column-dot" :class="cn('size-2 shrink-0 rounded-full', props.color, props.class)" /> </template>app/components/ui/kanban/KanbanColumnTitle.vue0.4 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <h3 data-slot="kanban-column-title" :class="cn('text-foreground text-sm font-semibold tracking-tight', props.class)"> <slot /> </h3> </template>app/components/ui/kanban/KanbanColumnCount.vue0.4 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { count?: number | string class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <span data-slot="kanban-column-count" :class="cn('bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-xs font-medium tabular-nums', props.class)" > <slot>{{ count }}</slot> </span> </template>app/components/ui/kanban/KanbanColumnAdd.vue0.7 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { Plus } from 'lucide-vue-next' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <button type="button" data-slot="kanban-column-add" :class=" 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', props.class, ) " > <slot> <Plus class="size-3.5" /> </slot> </button> </template>app/components/ui/kanban/KanbanColumnBody.vue0.4 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <div data-slot="kanban-column-body" :class="cn('flex flex-1 flex-col gap-2 overflow-y-auto py-1', props.class)"> <slot /> </div> </template>app/components/ui/kanban/KanbanColumnEmpty.vue0.5 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <div data-slot="kanban-column-empty" :class=" cn( 'border-border/60 text-muted-foreground/60 flex flex-1 items-center justify-center rounded-lg border border-dashed py-8 text-xs', props.class, ) " > <slot>No cards</slot> </div> </template>app/components/ui/kanban/KanbanCard.vue5.8 kB
<script setup lang="ts"> import { computed, inject, ref, type HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { KanbanColumnContextKey, KanbanContextKey } from './context' import { kanbanCardVariants } from './kanban.variants' interface Props { id: string disabled?: boolean /** Disable the keyboard grab (Space / arrows) while leaving pointer * dragging intact. Default: enabled. */ keyboardDraggable?: boolean class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { disabled: false, keyboardDraggable: true, }) const kanban = inject(KanbanContextKey) const column = inject(KanbanColumnContextKey) const cardEl = ref<HTMLElement | null>(null) const isGrabbed = computed(() => kanban?.grabbedCardId.value === props.id) const isDragging = computed(() => kanban?.draggingCardId.value === props.id || isGrabbed.value) const canKeyboardDrag = computed(() => props.keyboardDraggable && !props.disabled && !!kanban && !!column) function columnName(el: HTMLElement | null) { return el?.getAttribute('aria-label') || el?.dataset.columnId || 'column' } /** Ordered, enabled columns of the board this card sits in. */ function boardColumns(): HTMLElement[] { const board = cardEl.value?.closest('[data-slot="kanban"]') ?? document return Array.from(board.querySelectorAll<HTMLElement>('[data-slot="kanban-column"]')) } function focusSelfAfterMove() { // The consumer owns the data, so the card is re-rendered (often as a new // node) in its new column. Re-find it by id and restore focus. requestAnimationFrame(() => { const moved = document.querySelector<HTMLElement>(`[data-slot="kanban-card"][data-card-id="${props.id}"]`) moved?.focus() }) } function grab() { if (!canKeyboardDrag.value) return kanban!.setGrabbedCard(props.id) kanban!.setDraggingCard(props.id, column!.columnId) kanban!.setOverColumn(column!.columnId) kanban!.announce(`Picked up card. Use the arrow keys to move it, space to drop, escape to cancel.`) } function release(cancelled: boolean) { if (!kanban) return kanban.setGrabbedCard(null) kanban.setDraggingCard(null, null) kanban.setOverColumn(null) kanban.announce(cancelled ? 'Move cancelled.' : 'Card dropped.') } function moveToColumn(delta: -1 | 1) { const columns = boardColumns() const currentIdx = columns.findIndex((el) => el.dataset.columnId === column!.columnId) if (currentIdx === -1) return const target = columns[currentIdx + delta] // Deliberately not wrapping: running off the end of a board should stop, // not teleport the card back to the first column. if (!target?.dataset.columnId) return kanban!.emitMove({ cardId: props.id, fromColumnId: column!.columnId, toColumnId: target.dataset.columnId, }) kanban!.setDraggingCard(props.id, target.dataset.columnId) kanban!.setOverColumn(target.dataset.columnId) kanban!.announce(`Moved to ${columnName(target)}.`) focusSelfAfterMove() } function moveWithinColumn(delta: -1 | 1) { const columnEl = cardEl.value?.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 === props.id) if (currentIdx === -1) return const targetIdx = currentIdx + delta if (targetIdx < 0 || targetIdx > cards.length - 1) return kanban!.emitMove({ cardId: props.id, fromColumnId: column!.columnId, toColumnId: column!.columnId, toIndex: targetIdx, }) kanban!.announce(`Position ${targetIdx + 1} of ${cards.length}.`) focusSelfAfterMove() } function handleKeydown(e: KeyboardEvent) { if (!canKeyboardDrag.value) return if (e.key === ' ' || e.key === 'Spacebar') { e.preventDefault() if (isGrabbed.value) release(false) else grab() return } if (!isGrabbed.value) return if (e.key === 'Escape') { e.preventDefault() release(true) return } if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault() moveToColumn(e.key === 'ArrowLeft' ? -1 : 1) return } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault() moveWithinColumn(e.key === 'ArrowUp' ? -1 : 1) } } function handleBlur() { // A grabbed card that loses focus (click elsewhere, Tab) would otherwise // stay stuck in the held state with no way back to it. if (isGrabbed.value) release(true) } function handleDragStart(e: DragEvent) { if (props.disabled) { e.preventDefault() return } if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', props.id) } kanban?.setDraggingCard(props.id, column?.columnId ?? null) } function handleDragEnd() { kanban?.setDraggingCard(null, null) kanban?.setOverColumn(null) } </script> <template> <!-- `role="button"` rather than a real <button> so consumers can nest interactive content (menus, links) inside a card — the HTML spec forbids that inside <button>. Matches <BoardCard>. --> <div ref="cardEl" data-uipkge data-slot="kanban-card" :data-card-id="id" :data-state="isGrabbed ? 'grabbed' : isDragging ? 'dragging' : 'idle'" :data-disabled="disabled || undefined" :role="canKeyboardDrag ? 'button' : undefined" :tabindex="canKeyboardDrag ? 0 : undefined" :aria-disabled="disabled || undefined" :aria-roledescription="canKeyboardDrag ? 'draggable card' : undefined" :aria-pressed="canKeyboardDrag ? isGrabbed : undefined" :draggable="!disabled" :class="cn(kanbanCardVariants({ isDragging }), disabled && 'pointer-events-none opacity-50', props.class)" @dragstart="handleDragStart" @dragend="handleDragEnd" @keydown="handleKeydown" @blur="handleBlur" > <slot :is-dragging="isDragging" :is-grabbed="isGrabbed" /> </div> </template>app/components/ui/kanban/KanbanCardHeader.vue0.3 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <div data-slot="kanban-card-header" :class="cn('flex flex-col gap-1', props.class)"> <slot /> </div> </template>app/components/ui/kanban/KanbanCardTitle.vue0.3 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <p data-slot="kanban-card-title" :class="cn('text-foreground text-sm leading-snug font-medium', props.class)"> <slot /> </p> </template>app/components/ui/kanban/KanbanCardDescription.vue0.3 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <p data-slot="kanban-card-description" :class="cn('text-muted-foreground line-clamp-2 text-xs', props.class)"> <slot /> </p> </template>app/components/ui/kanban/KanbanCardFooter.vue0.4 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <div data-slot="kanban-card-footer" :class="cn('text-muted-foreground mt-1 flex items-center justify-between gap-2 pt-1 text-xs', props.class)" > <slot /> </div> </template>app/components/ui/kanban/context.ts1.1 kB
import type { InjectionKey, Ref } from 'vue' export interface KanbanMoveEvent { cardId: string fromColumnId: string toColumnId: string toIndex?: number } export interface KanbanContext { draggingCardId: Ref<string | null> draggingColumnId: Ref<string | null> overColumnId: Ref<string | null> /** Set while a card is held by keyboard (Space), not by pointer drag. */ grabbedCardId: Ref<string | null> setDraggingCard: (cardId: string | null, columnId: string | null) => void setOverColumn: (columnId: string | null) => void setGrabbedCard: (cardId: string | null) => void emitMove: (event: KanbanMoveEvent) => void /** Speak a message through the board's polite live region. */ announce: (message: string) => void } export const KanbanContextKey: InjectionKey<KanbanContext> = Symbol('KanbanContext') export interface KanbanColumnContext { columnId: string /** Column label, used to announce keyboard moves. Falls back to the id. */ label: Ref<string | undefined> } export const KanbanColumnContextKey: InjectionKey<KanbanColumnContext> = Symbol('KanbanColumnContext')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>app/components/ui/kanban/index.ts1 kB
export { default as Kanban } from './Kanban.vue' export { default as KanbanBoard } from './KanbanBoard.vue' export { default as KanbanColumn } from './KanbanColumn.vue' export { default as KanbanColumnHeader } from './KanbanColumnHeader.vue' export { default as KanbanColumnDot } from './KanbanColumnDot.vue' export { default as KanbanColumnTitle } from './KanbanColumnTitle.vue' export { default as KanbanColumnCount } from './KanbanColumnCount.vue' export { default as KanbanColumnAdd } from './KanbanColumnAdd.vue' export { default as KanbanColumnBody } from './KanbanColumnBody.vue' export { default as KanbanColumnEmpty } from './KanbanColumnEmpty.vue' export { default as KanbanCard } from './KanbanCard.vue' export { default as KanbanCardHeader } from './KanbanCardHeader.vue' export { default as KanbanCardTitle } from './KanbanCardTitle.vue' export { default as KanbanCardDescription } from './KanbanCardDescription.vue' export { default as KanbanCardFooter } from './KanbanCardFooter.vue' export * from './kanban.variants' export * from './context'
Raw manifest:https://uipkge.dev/r/vue/kanban.json