Kanban
kanbanuiComposable compound Kanban primitive for building board, pipeline, and agile workflows with tactile card drag-and-drop mechanics.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/kanban.json$npx shadcn@latest add https://uipkge.dev/r/react/kanban.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/kanban.json$bunx shadcn@latest add https://uipkge.dev/r/react/kanban.jsonNamed registry:
npx shadcn@latest add @uipkge-react/kanbanInstalls to: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 |
onCardMove | (event: KanbanMoveEvent) => void | — | 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
}KanbanContextValueinterface KanbanContextValue {
draggingCardId: string | null
draggingColumnId: string | null
overColumnId: string | null
/** Set while a card is held by keyboard (Space), not by pointer drag. */
grabbedCardId: string | null
setDraggingCard: (cardId: string | null, columnId: string | null) => void
setOverColumn: (columnId: string | null) => void
setGrabbedCard: (cardId: string | null) => void
/** Speak a message through the board's polite live region. */
announce: (message: string) => void
onCardMove?: (event: KanbanMoveEvent) => void
}KanbanColumnContextValueinterface KanbanColumnContextValue {
columnId: string
isOver: boolean
/** Column label, used to announce keyboard moves. Falls back to the id. */
label?: string
}npm dependencies
Files installed (3)
components/ui/kanban/Kanban.tsx19.3 kB
'use client' import * as React from 'react' import { cn } from '@/lib/utils' import { kanbanCardVariants, kanbanColumnVariants } from './kanban.variants' import { Plus } from 'lucide-react' /* ------------------------------------------------------------------ */ /* Contexts */ /* ------------------------------------------------------------------ */ export interface KanbanMoveEvent { cardId: string fromColumnId: string toColumnId: string toIndex?: number } interface KanbanContextValue { draggingCardId: string | null draggingColumnId: string | null overColumnId: string | null /** Set while a card is held by keyboard (Space), not by pointer drag. */ grabbedCardId: string | null setDraggingCard: (cardId: string | null, columnId: string | null) => void setOverColumn: (columnId: string | null) => void setGrabbedCard: (cardId: string | null) => void /** Speak a message through the board's polite live region. */ announce: (message: string) => void onCardMove?: (event: KanbanMoveEvent) => void } const KanbanContext = React.createContext<KanbanContextValue | null>(null) export function useKanbanContext() { const ctx = React.useContext(KanbanContext) if (!ctx) { throw new Error('Kanban components must be used within a <Kanban /> provider') } return ctx } interface KanbanColumnContextValue { columnId: string isOver: boolean /** Column label, used to announce keyboard moves. Falls back to the id. */ label?: string } const KanbanColumnContext = React.createContext<KanbanColumnContextValue | null>(null) export function useKanbanColumnContext() { const ctx = React.useContext(KanbanColumnContext) if (!ctx) { throw new Error('Kanban column sub-components must be used within a <KanbanColumn />') } return ctx } /* ------------------------------------------------------------------ */ /* Kanban (Root Provider) */ /* ------------------------------------------------------------------ */ export interface KanbanProps extends React.HTMLAttributes<HTMLDivElement> { onCardMove?: (event: KanbanMoveEvent) => void } export const Kanban = React.forwardRef<HTMLDivElement, KanbanProps>( ({ className, onCardMove, children, ...props }, ref) => { const [draggingCardId, setDraggingCardId] = React.useState<string | null>(null) const [draggingColumnId, setDraggingColumnId] = React.useState<string | null>(null) const [overColumnId, setOverColumnId] = React.useState<string | null>(null) const [grabbedCardId, setGrabbedCardId] = React.useState<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, setAnnouncement] = React.useState('') const setDraggingCard = React.useCallback((cardId: string | null, columnId: string | null) => { setDraggingCardId(cardId) setDraggingColumnId(columnId) }, []) const setOverColumn = React.useCallback((columnId: string | null) => { setOverColumnId(columnId) }, []) const setGrabbedCard = React.useCallback((cardId: string | null) => { setGrabbedCardId(cardId) }, []) const announce = React.useCallback((message: string) => { // Re-assigning the same string would not re-trigger the live region. setAnnouncement((current) => (current === message ? `${message} ` : message)) }, []) const ctx = React.useMemo<KanbanContextValue>( () => ({ draggingCardId, draggingColumnId, overColumnId, grabbedCardId, setDraggingCard, setOverColumn, setGrabbedCard, announce, onCardMove, }), [ draggingCardId, draggingColumnId, overColumnId, grabbedCardId, setDraggingCard, setOverColumn, setGrabbedCard, announce, onCardMove, ], ) return ( <KanbanContext.Provider value={ctx}> <div ref={ref} data-uipkge="" data-slot="kanban" className={cn('w-full', className)} {...props}> {children} <div data-slot="kanban-live-region" className="sr-only" role="status" aria-live="polite" aria-atomic="true"> {announcement} </div> </div> </KanbanContext.Provider> ) }, ) Kanban.displayName = 'Kanban' /* ------------------------------------------------------------------ */ /* KanbanBoard */ /* ------------------------------------------------------------------ */ export const KanbanBoard = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, children, ...props }, ref) => ( <div ref={ref} data-slot="kanban-board" className={cn('flex w-full items-start gap-4 overflow-x-auto pb-4', className)} {...props} > {children} </div> ), ) KanbanBoard.displayName = 'KanbanBoard' /* ------------------------------------------------------------------ */ /* KanbanColumn */ /* ------------------------------------------------------------------ */ export interface KanbanColumnProps extends React.HTMLAttributes<HTMLDivElement> { id: string /** Accessible name for the column, also used in keyboard move * announcements ("moved to In progress"). Falls back to the id. */ label?: string } export const KanbanColumn = React.forwardRef<HTMLDivElement, KanbanColumnProps>( ({ id, label, className, children, onDragOver, onDragLeave, onDrop, ...props }, ref) => { const kanban = useKanbanContext() const isOver = kanban.overColumnId === id const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { e.preventDefault() e.dataTransfer.dropEffect = 'move' if (kanban.overColumnId !== id) { kanban.setOverColumn(id) } onDragOver?.(e) } const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => { if (e.currentTarget.contains(e.relatedTarget as Node)) return if (kanban.overColumnId === id) { kanban.setOverColumn(null) } onDragLeave?.(e) } const handleDrop = (e: React.DragEvent<HTMLDivElement>) => { e.preventDefault() if (kanban.draggingCardId && kanban.draggingColumnId) { kanban.onCardMove?.({ cardId: kanban.draggingCardId, fromColumnId: kanban.draggingColumnId, toColumnId: id, }) } kanban.setDraggingCard(null, null) kanban.setOverColumn(null) onDrop?.(e) } const columnCtx = React.useMemo<KanbanColumnContextValue>( () => ({ columnId: id, isOver, label }), [id, isOver, label], ) return ( <KanbanColumnContext.Provider value={columnCtx}> <div ref={ref} data-uipkge="" data-slot="kanban-column" role="group" aria-label={label ?? id} data-column-id={id} data-over={isOver ? '' : undefined} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} className={cn(kanbanColumnVariants({ isOver }), className)} {...props} > {children} </div> </KanbanColumnContext.Provider> ) }, ) KanbanColumn.displayName = 'KanbanColumn' /* ------------------------------------------------------------------ */ /* KanbanColumnHeader & Sub-components */ /* ------------------------------------------------------------------ */ export const KanbanColumnHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, children, ...props }, ref) => ( <div ref={ref} data-slot="kanban-column-header" className={cn('flex items-center justify-between gap-2 px-1 py-0.5', className)} {...props} > {children} </div> ), ) KanbanColumnHeader.displayName = 'KanbanColumnHeader' export interface KanbanColumnDotProps extends React.HTMLAttributes<HTMLSpanElement> { color?: string } export const KanbanColumnDot = React.forwardRef<HTMLSpanElement, KanbanColumnDotProps>( ({ className, color = 'bg-primary', ...props }, ref) => ( <span ref={ref} data-slot="kanban-column-dot" className={cn('size-2 shrink-0 rounded-full', color, className)} {...props} /> ), ) KanbanColumnDot.displayName = 'KanbanColumnDot' export const KanbanColumnTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>( ({ className, children, ...props }, ref) => ( <h3 ref={ref} data-slot="kanban-column-title" className={cn('text-foreground text-sm font-semibold tracking-tight', className)} {...props} > {children} </h3> ), ) KanbanColumnTitle.displayName = 'KanbanColumnTitle' export interface KanbanColumnCountProps extends React.HTMLAttributes<HTMLSpanElement> { count?: number | string } export const KanbanColumnCount = React.forwardRef<HTMLSpanElement, KanbanColumnCountProps>( ({ className, count, children, ...props }, ref) => ( <span ref={ref} data-slot="kanban-column-count" className={cn( 'bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-xs font-medium tabular-nums', className, )} {...props} > {count ?? children} </span> ), ) KanbanColumnCount.displayName = 'KanbanColumnCount' export const KanbanColumnAdd = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>( ({ className, children, ...props }, ref) => ( <button ref={ref} type="button" data-slot="kanban-column-add" className={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', className, )} {...props} > {children ?? <Plus className="size-3.5" />} </button> ), ) KanbanColumnAdd.displayName = 'KanbanColumnAdd' /* ------------------------------------------------------------------ */ /* KanbanColumnBody & Empty */ /* ------------------------------------------------------------------ */ export const KanbanColumnBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, children, ...props }, ref) => ( <div ref={ref} data-slot="kanban-column-body" className={cn('flex flex-1 flex-col gap-2 overflow-y-auto py-1', className)} {...props} > {children} </div> ), ) KanbanColumnBody.displayName = 'KanbanColumnBody' export const KanbanColumnEmpty = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, children, ...props }, ref) => ( <div ref={ref} data-slot="kanban-column-empty" className={cn( 'border-border/60 text-muted-foreground/60 flex flex-1 items-center justify-center rounded-lg border border-dashed py-8 text-xs', className, )} {...props} > {children ?? 'No cards'} </div> ), ) KanbanColumnEmpty.displayName = 'KanbanColumnEmpty' /* ------------------------------------------------------------------ */ /* KanbanCard & Sub-components */ /* ------------------------------------------------------------------ */ export interface KanbanCardProps extends React.HTMLAttributes<HTMLDivElement> { id: string disabled?: boolean /** Disable the keyboard grab (Space / arrows) while leaving pointer * dragging intact. Default: enabled. */ keyboardDraggable?: boolean } export const KanbanCard = React.forwardRef<HTMLDivElement, KanbanCardProps>( ( { id, disabled = false, keyboardDraggable = true, className, children, onDragStart, onDragEnd, onKeyDown, onBlur, ...props }, ref, ) => { const kanban = useKanbanContext() const column = useKanbanColumnContext() const cardRef = React.useRef<HTMLDivElement | null>(null) const isGrabbed = kanban.grabbedCardId === id const isDragging = kanban.draggingCardId === id || isGrabbed const canKeyboardDrag = keyboardDraggable && !disabled const setRefs = React.useCallback( (node: HTMLDivElement | null) => { cardRef.current = node if (typeof ref === 'function') ref(node) else if (ref) ref.current = node }, [ref], ) const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => { if (disabled) { e.preventDefault() return } e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', id) kanban.setDraggingCard(id, column.columnId) onDragStart?.(e) } const handleDragEnd = (e: React.DragEvent<HTMLDivElement>) => { kanban.setDraggingCard(null, null) kanban.setOverColumn(null) onDragEnd?.(e) } const columnName = (el: HTMLElement | null) => el?.getAttribute('aria-label') || el?.dataset.columnId || 'column' /** Ordered columns of the board this card sits in. */ const boardColumns = () => { const board: ParentNode = cardRef.current?.closest('[data-slot="kanban"]') ?? document return Array.from(board.querySelectorAll<HTMLElement>('[data-slot="kanban-column"]')) } // The consumer owns the data, so a moved card unmounts here and mounts // again under the new column. Chasing it with requestAnimationFrame races // React's commit — the query resolves the node that is on its way out and // focus lands on <body>. Instead, whichever instance is mounted while the // card is held takes focus back after the commit that rendered it. React.useEffect(() => { if (!isGrabbed) return const node = cardRef.current if (node && document.activeElement !== node) node.focus() }, [isGrabbed]) const grab = () => { kanban.setGrabbedCard(id) kanban.setDraggingCard(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.') } const release = (cancelled: boolean) => { kanban.setGrabbedCard(null) kanban.setDraggingCard(null, null) kanban.setOverColumn(null) kanban.announce(cancelled ? 'Move cancelled.' : 'Card dropped.') } const 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.onCardMove?.({ cardId: id, fromColumnId: column.columnId, toColumnId: target.dataset.columnId }) kanban.setDraggingCard(id, target.dataset.columnId) kanban.setOverColumn(target.dataset.columnId) kanban.announce(`Moved to ${columnName(target)}.`) } const moveWithinColumn = (delta: -1 | 1) => { const columnEl = cardRef.current?.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 === id) if (currentIdx === -1) return const targetIdx = currentIdx + delta if (targetIdx < 0 || targetIdx > cards.length - 1) return kanban.onCardMove?.({ cardId: id, fromColumnId: column.columnId, toColumnId: column.columnId, toIndex: targetIdx, }) kanban.announce(`Position ${targetIdx + 1} of ${cards.length}.`) } const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { onKeyDown?.(e) if (!canKeyboardDrag || e.defaultPrevented) return if (e.key === ' ' || e.key === 'Spacebar') { e.preventDefault() if (isGrabbed) release(false) else grab() return } if (!isGrabbed) 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) } } const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => { onBlur?.(e) // 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) release(true) } return ( <div ref={setRefs} 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} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onKeyDown={handleKeyDown} onBlur={handleBlur} className={cn(kanbanCardVariants({ isDragging }), disabled && 'pointer-events-none opacity-50', className)} {...props} > {children} </div> ) }, ) KanbanCard.displayName = 'KanbanCard' export const KanbanCardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, children, ...props }, ref) => ( <div ref={ref} data-slot="kanban-card-header" className={cn('flex flex-col gap-1', className)} {...props}> {children} </div> ), ) KanbanCardHeader.displayName = 'KanbanCardHeader' export const KanbanCardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>( ({ className, children, ...props }, ref) => ( <p ref={ref} data-slot="kanban-card-title" className={cn('text-foreground text-sm leading-snug font-medium', className)} {...props} > {children} </p> ), ) KanbanCardTitle.displayName = 'KanbanCardTitle' export const KanbanCardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>( ({ className, children, ...props }, ref) => ( <p ref={ref} data-slot="kanban-card-description" className={cn('text-muted-foreground line-clamp-2 text-xs', className)} {...props} > {children} </p> ), ) KanbanCardDescription.displayName = 'KanbanCardDescription' export const KanbanCardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, children, ...props }, ref) => ( <div ref={ref} data-slot="kanban-card-footer" className={cn('text-muted-foreground mt-1 flex items-center justify-between gap-2 pt-1 text-xs', className)} {...props} > {children} </div> ), ) KanbanCardFooter.displayName = 'KanbanCardFooter'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>components/ui/kanban/index.ts0.1 kB
export * from './Kanban' export * from './kanban.variants'
Raw manifest:https://uipkge.dev/r/react/kanban.json