组件
看板 Kanban
用于敏捷项目管理、需求流转与线索阶段跟踪的可组合看板组件,支持跨列拖拽、列内排序、键盘移动与布局过渡动画。
基础用法
包含待规划、进行中与已完成三列的基础看板。拖动卡片时目标列高亮,插入线跟随指针滑动;放下后卡片以弹簧动画滑入新位置,列计数上下滚动。点击列头的 + 可新建卡片:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/kanban安装基础依赖与图标库
pnpm add motion lucide-react clsx tailwind-merge复制组件源码到
components/ui/kanban.tsx"use client"
import * as React from "react"
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
type Transition,
} from "motion/react"
import { GripVerticalIcon } from "lucide-react"
import { cn } from "@/lib/utils"
type KanbanMove = {
itemId: string
from: string
to: string
/** Final position of the card inside `to`, counted after it left `from`. */
index: number
}
type DragSource = { itemId: string; from: string; index: number }
type DropTarget = { column: string; index: number; offset: number }
type KanbanContextValue = {
rootRef: React.RefObject<HTMLDivElement | null>
drag: React.RefObject<DragSource | null>
draggingId: string | null
setDraggingId: (id: string | null) => void
target: DropTarget | null
setTarget: React.Dispatch<React.SetStateAction<DropTarget | null>>
grabbed: DragSource | null
setGrabbed: (source: DragSource | null) => void
pendingFocus: React.RefObject<string | null>
ready: React.RefObject<boolean>
known: React.RefObject<Set<string>>
instructionsId: string
move: (move: KanbanMove) => void
announce: (message: string) => void
endDrag: () => void
}
type KanbanColumnContextValue = { value: string }
const KanbanContext = React.createContext<KanbanContextValue | null>(null)
const KanbanColumnContext =
React.createContext<KanbanColumnContextValue | null>(null)
const spring: Transition = {
type: "spring",
stiffness: 520,
damping: 38,
mass: 0.7,
}
const ease = [0.22, 1, 0.36, 1] as const
function useKanban() {
return React.useContext(KanbanContext)!
}
function getColumns(root: HTMLElement) {
return Array.from(
root.querySelectorAll<HTMLElement>('[data-slot="kanban-column"]')
)
}
function getItems(column: HTMLElement) {
return Array.from(
column.querySelectorAll<HTMLElement>('[data-slot="kanban-item"]')
)
}
function getColumnTitle(column: HTMLElement) {
return (
column.querySelector('[data-slot="kanban-column-title"]')?.textContent ??
column.dataset.value
)
}
/**
* Applies a KanbanMove to column-keyed state. Works for both cross-column
* moves and reordering inside one column.
*/
function moveKanbanItem<T>(
columns: Record<string, T[]>,
move: KanbanMove,
getId: (item: T) => string
): Record<string, T[]> {
const item = columns[move.from].find((entry) => getId(entry) === move.itemId)
if (!item) return columns
const source = columns[move.from].filter(
(entry) => getId(entry) !== move.itemId
)
const target = move.from === move.to ? [...source] : [...columns[move.to]]
target.splice(move.index, 0, item)
return { ...columns, [move.from]: source, [move.to]: target }
}
export interface KanbanProps extends React.ComponentProps<"div"> {
/**
* Called when a card is dropped into a new position, by pointer or keyboard.
* `from === to` means the card was reordered inside its column.
*/
onMove?: (move: KanbanMove) => void
}
/** A horizontally scrollable, composable board with pointer and keyboard card moves. */
function Kanban({ className, onMove, children, ...props }: KanbanProps) {
const layoutId = React.useId()
const instructionsId = React.useId()
const rootRef = React.useRef<HTMLDivElement>(null)
const drag = React.useRef<DragSource | null>(null)
const pendingFocus = React.useRef<string | null>(null)
const ready = React.useRef(false)
const known = React.useRef(new Set<string>())
const onMoveRef = React.useRef(onMove)
const [draggingId, setDraggingId] = React.useState<string | null>(null)
const [target, setTarget] = React.useState<DropTarget | null>(null)
const [grabbed, setGrabbed] = React.useState<DragSource | null>(null)
const [announcement, setAnnouncement] = React.useState("")
React.useEffect(() => {
onMoveRef.current = onMove
})
React.useEffect(() => {
ready.current = true
}, [])
const context = React.useMemo<KanbanContextValue>(
() => ({
rootRef,
drag,
draggingId,
setDraggingId,
target,
setTarget,
grabbed,
setGrabbed,
pendingFocus,
ready,
known,
instructionsId,
move: (move) => onMoveRef.current?.(move),
announce: setAnnouncement,
endDrag: () => {
drag.current = null
setDraggingId(null)
setTarget(null)
},
}),
[draggingId, target, grabbed, instructionsId]
)
return (
<KanbanContext.Provider value={context}>
<div
ref={rootRef}
data-slot="kanban"
className={cn(
"grid auto-cols-[minmax(17rem,1fr)] grid-flow-col gap-3 overflow-x-auto pb-2",
className
)}
{...props}
>
<LayoutGroup id={layoutId}>{children}</LayoutGroup>
<p id={instructionsId} className="sr-only">
按空格键拿起卡片,使用方向键移动,再次按空格键放下,按 Esc 取消。
</p>
<p aria-live="assertive" className="sr-only">
{announcement}
</p>
</div>
</KanbanContext.Provider>
)
}
export interface KanbanColumnProps extends React.ComponentProps<"section"> {
/** Stable column identifier used by drag-and-drop. */
value: string
}
function KanbanColumn({
className,
value,
onDragOver,
onDragLeave,
onDrop,
...props
}: KanbanColumnProps) {
const board = useKanban()
const over = board.target?.column === value
return (
<KanbanColumnContext.Provider value={{ value }}>
<section
data-slot="kanban-column"
data-value={value}
data-over={over || undefined}
className={cn(
"bg-muted/25 data-[over=true]:border-primary/35 data-[over=true]:bg-primary/[0.035] flex min-h-72 flex-col rounded-lg border transition-colors duration-200",
className
)}
onDragOver={(event) => {
const source = board.drag.current
if (source) {
event.preventDefault()
event.dataTransfer.dropEffect = "move"
const column = event.currentTarget
const body = column.querySelector<HTMLElement>(
'[data-slot="kanban-column-body"]'
)!
const items = getItems(column).filter(
(item) => item.dataset.value !== source.itemId
)
const rects = items.map((item) => item.getBoundingClientRect())
const bodyTop = body.getBoundingClientRect().top
let index = rects.findIndex(
(rect) => event.clientY < rect.top + rect.height / 2
)
if (index === -1) index = rects.length
const offset =
rects.length === 0
? 6
: index === 0
? rects[0].top - bodyTop - 4
: index === rects.length
? rects[index - 1].bottom - bodyTop + 4
: (rects[index - 1].bottom + rects[index].top) / 2 - bodyTop
board.setTarget((current) =>
current?.column === value &&
current.index === index &&
current.offset === offset
? current
: { column: value, index, offset }
)
}
onDragOver?.(event)
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node))
board.setTarget((current) =>
current?.column === value ? null : current
)
onDragLeave?.(event)
}}
onDrop={(event) => {
event.preventDefault()
const source = board.drag.current
const target = board.target
if (
source &&
target?.column === value &&
!(source.from === value && source.index === target.index)
) {
board.move({
itemId: source.itemId,
from: source.from,
to: value,
index: target.index,
})
}
board.endDrag()
onDrop?.(event)
}}
{...props}
/>
</KanbanColumnContext.Provider>
)
}
function KanbanColumnHeader({
className,
...props
}: React.ComponentProps<"header">) {
return (
<header
data-slot="kanban-column-header"
className={cn(
"flex min-h-12 items-center justify-between gap-3 border-b px-3.5",
className
)}
{...props}
/>
)
}
function KanbanColumnTitle({
className,
...props
}: React.ComponentProps<"h3">) {
return (
<h3
data-slot="kanban-column-title"
className={cn("text-sm font-semibold tracking-tight", className)}
{...props}
/>
)
}
/** Column count that rolls up or down when the number changes. */
function KanbanColumnCount({
className,
children,
...props
}: React.ComponentProps<"span">) {
const reduceMotion = useReducedMotion()
const numeric = Number(children)
const previous = React.useRef(numeric)
const direction = numeric < previous.current ? -1 : 1
React.useEffect(() => {
previous.current = numeric
}, [numeric])
return (
<span
data-slot="kanban-column-count"
className={cn(
"bg-muted text-muted-foreground relative flex h-5 min-w-5 items-center justify-center overflow-hidden rounded-full px-1.5 text-[11px] font-medium tabular-nums",
className
)}
{...props}
>
<AnimatePresence initial={false} mode="popLayout" custom={direction}>
<motion.span
key={String(children)}
custom={direction}
variants={{
enter: (dir: number) => ({ y: dir * 10, opacity: 0 }),
center: { y: 0, opacity: 1 },
exit: (dir: number) => ({ y: dir * -10, opacity: 0 }),
}}
initial={reduceMotion ? false : "enter"}
animate="center"
exit={reduceMotion ? undefined : "exit"}
transition={{ duration: 0.22, ease }}
>
{children}
</motion.span>
</AnimatePresence>
</span>
)
}
function KanbanColumnBody({
className,
children,
...props
}: React.ComponentProps<"div">) {
const board = useKanban()
const column = React.useContext(KanbanColumnContext)!
const reduceMotion = useReducedMotion()
const target = board.target?.column === column.value ? board.target : null
const source = board.drag.current
const visible =
target !== null &&
!(source?.from === column.value && source.index === target.index)
return (
<div
data-slot="kanban-column-body"
className={cn("relative flex flex-1 flex-col gap-2 p-2", className)}
{...props}
>
{children}
<AnimatePresence>
{visible ? (
<motion.span
aria-hidden
data-slot="kanban-drop-indicator"
className="bg-primary pointer-events-none absolute inset-x-2 top-0 h-0.5 rounded-full"
initial={{ opacity: 0, y: target.offset - 1, scaleX: 0.6 }}
animate={{ opacity: 1, y: target.offset - 1, scaleX: 1 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={reduceMotion ? { duration: 0 } : spring}
/>
) : null}
</AnimatePresence>
</div>
)
}
export interface KanbanCardProps extends React.ComponentProps<"article"> {
/** Stable card identifier supplied to onMove. */
value: string
/** Enable pointer drag-and-drop and keyboard moves. @default true */
draggable?: boolean
}
function KanbanCard({
className,
value,
draggable = true,
ref,
onDragStart,
onDragEnd,
onKeyDown,
onBlur,
children,
...props
}: KanbanCardProps) {
const board = useKanban()
const column = React.useContext(KanbanColumnContext)!
const reduceMotion = useReducedMotion()
const cardRef = React.useRef<HTMLElement | null>(null)
const [initial] = React.useState(() =>
board.ready.current && !board.known.current.has(value) && !reduceMotion
? { opacity: 0, y: -6, scale: 0.98 }
: false
)
const dragging = board.draggingId === value
const grabbed = board.grabbed?.itemId === value
React.useEffect(() => {
board.known.current.add(value)
}, [board.known, value])
React.useEffect(() => {
if (board.pendingFocus.current !== value) return
board.pendingFocus.current = null
cardRef.current?.focus()
})
function locate(element: HTMLElement) {
const columnElement = element.closest<HTMLElement>(
'[data-slot="kanban-column"]'
)!
const items = getItems(columnElement)
return {
columnElement,
index: items.findIndex((item) => item.dataset.value === value),
count: items.length,
}
}
function moveByKeyboard(element: HTMLElement, key: string) {
const { columnElement, index, count } = locate(element)
const columns = getColumns(board.rootRef.current!)
const columnIndex = columns.indexOf(columnElement)
let destination = columnElement
let nextIndex = index
if (key === "ArrowUp") nextIndex = index - 1
if (key === "ArrowDown") nextIndex = index + 1
if (key === "ArrowLeft" || key === "ArrowRight") {
const next = columns[columnIndex + (key === "ArrowLeft" ? -1 : 1)]
if (!next) return
destination = next
nextIndex = Math.min(index, getItems(next).length)
}
if (destination === columnElement && (nextIndex < 0 || nextIndex >= count))
return
board.pendingFocus.current = value
board.move({
itemId: value,
from: column.value,
to: destination.dataset.value!,
index: nextIndex,
})
board.announce(
`已移动到「${getColumnTitle(destination)}」第 ${nextIndex + 1} 位`
)
}
return (
<motion.div
data-slot="kanban-item"
data-value={value}
layout="position"
layoutId={value}
initial={initial}
animate={{ opacity: 1, y: 0, scale: grabbed ? 1.02 : 1 }}
transition={reduceMotion ? { duration: 0 } : spring}
className={cn("relative", grabbed && "z-10")}
>
<article
ref={(node) => {
cardRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
}}
data-slot="kanban-card"
data-dragging={dragging || undefined}
data-grabbed={grabbed || undefined}
draggable={draggable}
tabIndex={0}
aria-roledescription={draggable ? "可拖拽卡片" : undefined}
aria-describedby={draggable ? board.instructionsId : undefined}
className={cn(
"bg-background shadow-xs hover:border-foreground/20 focus-visible:ring-ring/30 group/kanban-card relative cursor-default rounded-md border p-3 text-sm outline-none transition-[border-color,box-shadow,opacity] duration-200 hover:shadow-sm focus-visible:ring-[3px]",
"data-[dragging=true]:border-dashed data-[dragging=true]:opacity-40 data-[dragging=true]:shadow-none",
"data-[grabbed=true]:border-primary/40 data-[grabbed=true]:shadow-md",
draggable && "cursor-grab active:cursor-grabbing",
className
)}
onDragStart={(event) => {
const { index } = locate(event.currentTarget)
board.drag.current = { itemId: value, from: column.value, index }
board.setGrabbed(null)
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData("text/plain", value)
// Defer the placeholder style so the browser's drag image keeps full opacity.
requestAnimationFrame(() => {
if (board.drag.current?.itemId === value) board.setDraggingId(value)
})
onDragStart?.(event)
}}
onDragEnd={(event) => {
board.endDrag()
onDragEnd?.(event)
}}
onKeyDown={(event) => {
onKeyDown?.(event)
if (
event.defaultPrevented ||
!draggable ||
event.target !== event.currentTarget
)
return
if (event.key === " " || event.key === "Enter") {
event.preventDefault()
if (grabbed) {
board.setGrabbed(null)
board.announce("已放下卡片")
} else {
const { index } = locate(event.currentTarget)
board.setGrabbed({ itemId: value, from: column.value, index })
board.announce("已拿起卡片,使用方向键移动")
}
return
}
if (!grabbed) return
if (event.key === "Escape") {
event.preventDefault()
const origin = board.grabbed!
const { index } = locate(event.currentTarget)
if (origin.from !== column.value || origin.index !== index) {
board.pendingFocus.current = value
board.move({
itemId: value,
from: column.value,
to: origin.from,
index: origin.index,
})
}
board.setGrabbed(null)
board.announce("已取消移动")
return
}
if (event.key.startsWith("Arrow")) {
event.preventDefault()
moveByKeyboard(event.currentTarget, event.key)
}
}}
onBlur={(event) => {
if (grabbed && board.pendingFocus.current !== value)
board.setGrabbed(null)
onBlur?.(event)
}}
{...props}
>
{draggable ? (
<GripVerticalIcon
aria-hidden
className="text-muted-foreground absolute right-2 top-3 size-4 opacity-0 transition-opacity duration-200 group-hover/kanban-card:opacity-100 group-focus-visible/kanban-card:opacity-100"
/>
) : null}
{children}
</article>
</motion.div>
)
}
export type { KanbanMove }
export {
Kanban,
KanbanCard,
KanbanColumn,
KanbanColumnBody,
KanbanColumnCount,
KanbanColumnHeader,
KanbanColumnTitle,
moveKanbanItem,
}
属性 Props
Kanban (根容器)
继承原生 <div> 元素的全部 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onMove | (move: { itemId: string; from: string; to: string; index: number }) => void | — | 卡片通过拖拽或键盘移动到新位置时触发。index 为卡片在目标列中的最终位置(已排除卡片自身);from 与 to 相同表示列内排序。 |
| className | string | — | 应用于看板横向滚动网格容器的额外 CSS 类名。 |
KanbanColumn
代表看板中的单个状态列:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | string | — | 当前列的唯一标识字符串(例如 'todo'、'in_progress'、'done'),必须唯一且稳定。 |
| className | string | — | 应用于列容器的额外 CSS 类名。 |
KanbanCard
代表可拖动的任务卡片:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | string | — | 当前任务卡片的唯一标识 ID,在 onMove 回调中作为 itemId 返回。 |
| draggable | boolean | true | 是否允许拖拽与键盘移动该卡片。 |
| className | string | — | 应用于卡片盒子的额外 CSS 类名。 |
KanbanColumnHeader / KanbanColumnTitle / KanbanColumnCount / KanbanColumnBody
用于自由组合列头、标题、计数角标与卡片堆叠区的子组件:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 子组件内渲染的内容。 |
| className | string | — | 应用于对应子容器的额外 CSS 类名。 |
moveKanbanItem
把 onMove 的结果应用到按列分组的状态上,同时处理跨列移动与列内排序:
const [board, setBoard] = React.useState<Record<string, Task[]>>(initial)
<Kanban
onMove={(move) =>
setBoard((current) => moveKanbanItem(current, move, (task) => task.id))
}
>
{/* ... */}
</Kanban>| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| columns | Record<string, T[]> | — | 以列 value 为键的卡片数组。 |
| move | KanbanMove | — | onMove 回调收到的移动信息。 |
| getId | (item: T) => string | — | 返回卡片唯一标识,需与 KanbanCard 的 value 一致。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onMove | ({ itemId: string, from: string, to: string, index: number }) => void | — | 卡片跨列放下或列内排序时触发,键盘移动时每一步都会触发。业务层应在回调中更新本地状态或调用后端流转 API,可直接使用 moveKanbanItem。 |
| onDragStart | (event: React.DragEvent<HTMLElement>) => void | — | 开始拖动卡片时触发。 |
| onDragEnd | (event: React.DragEvent<HTMLElement>) => void | — | 卡片拖拽结束(无论是否成功放置)时触发。 |
使用场景与设计规范
Kanban 适用于表达具备明确流转阶段(Pipeline)的工作项:
- 状态持久化与乐观更新:组件本身不持有数据状态。在
onMove回调中,建议先进行本地乐观更新(Optimistic UI),随后发起网络请求,失败时回滚。 - 横向自适应与移动端适配:组件默认内置
grid auto-cols-[minmax(17rem,1fr)] grid-flow-col overflow-x-auto,在窄屏幕上会自动支持横向手势平滑滑动。 - 卡片信息层级清晰:卡片上建议突出任务标题与编号,标签(Tag)、优先级角标(Priority)、负责人头像和故事点(Story Points)应保持紧凑统一的对齐。
场景示例
敏捷开发 Sprint 任务流转板
包含 4 个流转阶段、优先级、任务标签、故事点与负责人头像。「开发中」与「代码评审」设置了 WIP 上限,超出时计数变为警示色;顶部汇总故事点会随拖拽实时更新:
Loading…
无障碍与交互 Accessibility
- 语义分块:每个
KanbanColumn渲染为<section>,卡片渲染为<article>,列头渲染为<header>。 - 键盘移动:卡片可通过 Tab 聚焦,按 Space 或 Enter 拿起;拿起后 ↑ ↓ 在列内排序,← → 移动到相邻列,再次按 Space 放下,Esc 回到拿起前的位置。卡片跨列后焦点会自动跟随。
- 读屏播报:卡片带有
aria-roledescription="可拖拽卡片"与操作说明,拿起、移动、放下时通过aria-live区域播报目标列名称与位置。 - 抓手手势指示:可拖拽卡片的指针为
cursor-grab,拖动中为cursor-grabbing,悬停或键盘聚焦时右上角显示抓手图标。 - 拖拽反馈:拖动中原卡片变为虚线占位,目标列添加
data-over="true"并高亮边框与背景,插入线以弹簧动画指示放置位置。 - 动效降级:开启“减少动态效果”时,布局过渡、计数滚动与插入线动画均直接跳到终点。