Overview
The fullest gesture surface in the set. Drag from the grip to reorder within a column; drop on another column to move across. Lift uses a subtle scale 1.02 with a CSS shadow (not animated boxShadow). Columns highlight as drop targets with Action Blue. Press feedback lives on the grip (scale 0.97). Reduced motion keeps drag without lift exaggeration.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/kanban-board.jsonInstall dependencies:
$ pnpm add motion lucide-reactAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useCallback, useEffect, useState } from "react";4import {5 LayoutGroup,6 Reorder,7 motion,8 useDragControls,9 useReducedMotion,10} from "motion/react";11import { GripVertical } from "lucide-react";12import { cn } from "@/lib/utils";13 14export type KanbanCard = {15 id: string;16 title: string;17 meta?: string;18};19 20export type KanbanColumn = {21 id: string;22 title: string;23 cards: KanbanCard[];24};25 26export type KanbanBoardProps = {27 columns: KanbanColumn[];28 onChange?: (columns: KanbanColumn[]) => void;29 className?: string;30};31 32/** Layout settle — under 300ms, no bounce (Emil: bounce is rare in UI). */33const LAYOUT_SPRING = { type: "spring" as const, bounce: 0, duration: 0.25 };34/** Lift / press — snappy ease-out, never ease-in. */35const LIFT_TRANSITION = {36 type: "spring" as const,37 bounce: 0,38 duration: 0.2,39};40 41function cloneColumns(cols: KanbanColumn[]): KanbanColumn[] {42 return cols.map((c) => ({ ...c, cards: [...c.cards] }));43}44 45function findCard(46 cols: KanbanColumn[],47 cardId: string,48): { colIndex: number; cardIndex: number } | null {49 for (let i = 0; i < cols.length; i++) {50 const j = cols[i].cards.findIndex((c) => c.id === cardId);51 if (j >= 0) return { colIndex: i, cardIndex: j };52 }53 return null;54}55 56function columnAtPoint(x: number, y: number): string | undefined {57 const els = document.elementsFromPoint(x, y);58 const colEl = els.find((el) => (el as HTMLElement).dataset?.kanbanCol) as59 | HTMLElement60 | undefined;61 return colEl?.dataset.kanbanCol;62}63 64function CardRow({65 card,66 reduce,67 onDragToColumn,68 columnIds,69 onDragActive,70}: {71 card: KanbanCard;72 reduce: boolean | null;73 onDragToColumn: (cardId: string, targetColId: string) => void;74 columnIds: string[];75 onDragActive: (cardId: string | null, overColId: string | null) => void;76}) {77 const controls = useDragControls();78 const [dragging, setDragging] = useState(false);79 80 return (81 <Reorder.Item82 value={card}83 id={card.id}84 as="li"85 layout={reduce ? undefined : true}86 dragListener={false}87 dragControls={controls}88 dragElastic={0.12}89 dragMomentum={false}90 onDragStart={() => {91 setDragging(true);92 onDragActive(card.id, null);93 }}94 onDrag={(_, info) => {95 const target = columnAtPoint(info.point.x, info.point.y);96 onDragActive(card.id, target && columnIds.includes(target) ? target : null);97 }}98 onDragEnd={(_, info) => {99 setDragging(false);100 const target = columnAtPoint(info.point.x, info.point.y);101 onDragActive(null, null);102 if (target && columnIds.includes(target)) {103 onDragToColumn(card.id, target);104 }105 }}106 transition={107 reduce108 ? { duration: 0.12 }109 : {110 layout: LAYOUT_SPRING,111 scale: LIFT_TRANSITION,112 }113 }114 initial={false}115 animate={116 reduce117 ? { scale: 1 }118 : {119 // Lift only while dragging — press lives on the grip (:active).120 // Never scale(0); 1.02 is enough to read as "picked up".121 scale: dragging ? 1.02 : 1,122 zIndex: dragging ? 20 : 0,123 }124 }125 className={cn(126 "list-none rounded-[var(--radius-md)] bg-[var(--color-paper)]",127 "ring-1 ring-[var(--color-rule)]",128 // Shadow via class + CSS transition — not animated boxShadow (paint thrash).129 "transition-[box-shadow,ring-color] duration-(--dur-short) ease-[var(--ease-out)]",130 dragging131 ? "shadow-[0_8px_24px_rgba(0,0,0,0.12)] ring-[var(--color-brand)]/35"132 : "shadow-none",133 )}134 style={{ position: "relative" }}135 >136 <div className="flex items-start gap-1 px-2 py-2.5">137 <button138 type="button"139 aria-label={`Drag ${card.title}`}140 className={cn(141 "mt-0.5 flex size-8 shrink-0 touch-none items-center justify-center",142 "rounded-[var(--radius-sm)] text-[var(--color-ink-muted)] outline-none",143 "transition-[transform,background-color,color] duration-(--dur-micro) ease-[var(--ease-out)]",144 "active:scale-[0.97] active:bg-[var(--color-paper-2)] active:text-[var(--color-brand)]",145 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",146 "[@media(hover:hover)_and_(pointer:fine)]:hover:bg-[var(--color-paper-2)]",147 dragging && "text-[var(--color-brand)]",148 )}149 onPointerDown={(e) => {150 e.currentTarget.setPointerCapture?.(e.pointerId);151 controls.start(e);152 }}153 >154 <GripVertical className="size-4" strokeWidth={1.5} aria-hidden />155 </button>156 <div className="min-w-0 flex-1 pr-1">157 <p158 className="text-[length:var(--text-sm)] font-semibold leading-[1.29] tracking-[var(--tracking-caption)] text-[var(--color-ink)]"159 >160 {card.title}161 </p>162 {card.meta ? (163 <p164 className="mt-0.5 text-[length:var(--text-xs)] leading-[var(--leading-tight)] tracking-[var(--tracking-fine)] text-[var(--color-ink-muted)]"165 >166 {card.meta}167 </p>168 ) : null}169 </div>170 </div>171 </Reorder.Item>172 );173}174 175export function KanbanBoard({176 columns: columnsProp,177 onChange,178 className,179}: KanbanBoardProps) {180 const reduce = useReducedMotion();181 const [internal, setInternal] = useState(columnsProp);182 const [activeCardId, setActiveCardId] = useState<string | null>(null);183 const [overColId, setOverColId] = useState<string | null>(null);184 const columns = onChange ? columnsProp : internal;185 const columnIds = columns.map((c) => c.id);186 187 const commit = useCallback(188 (next: KanbanColumn[]) => {189 if (onChange) onChange(next);190 else setInternal(next);191 },192 [onChange],193 );194 195 useEffect(() => {196 if (!onChange) setInternal(columnsProp);197 }, [columnsProp, onChange]);198 199 const onDragActive = useCallback(200 (cardId: string | null, overId: string | null) => {201 setActiveCardId(cardId);202 setOverColId(overId);203 },204 [],205 );206 207 const reorderInColumn = (colId: string, cards: KanbanCard[]) => {208 const next = cloneColumns(columns);209 const col = next.find((c) => c.id === colId);210 if (!col) return;211 col.cards = cards;212 commit(next);213 };214 215 const moveCardToColumn = (cardId: string, targetColId: string) => {216 const loc = findCard(columns, cardId);217 if (!loc) return;218 if (columns[loc.colIndex].id === targetColId) return;219 const next = cloneColumns(columns);220 const [card] = next[loc.colIndex].cards.splice(loc.cardIndex, 1);221 const target = next.find((c) => c.id === targetColId);222 if (!target || !card) return;223 target.cards.push(card);224 commit(next);225 };226 227 return (228 <LayoutGroup>229 <div className={cn("flex gap-3 overflow-x-auto pb-1", className)}>230 {columns.map((col) => {231 const isDropTarget =232 Boolean(activeCardId) &&233 overColId === col.id &&234 !col.cards.some((c) => c.id === activeCardId);235 236 return (237 <motion.div238 key={col.id}239 layout={reduce ? undefined : true}240 transition={reduce ? { duration: 0.1 } : LAYOUT_SPRING}241 data-kanban-col={col.id}242 className={cn(243 "flex w-64 shrink-0 flex-col rounded-[var(--radius-lg)] p-2",244 "bg-[var(--color-paper-2)]",245 "ring-1 transition-[box-shadow,ring-color,background-color] duration-(--dur-short) ease-[var(--ease-out)]",246 isDropTarget247 ? "bg-[color-mix(in_oklab,var(--color-brand)_6%,var(--color-paper-2))] ring-[var(--color-brand)]/40"248 : "ring-transparent",249 )}250 >251 <div className="mb-2 flex items-center justify-between px-1.5 pt-1">252 <h3253 className="text-[length:var(--text-xs)] font-semibold uppercase leading-[var(--leading-tight)] tracking-[var(--tracking-fine)] text-[var(--color-ink)]"254 >255 {col.title}256 </h3>257 <span258 className={cn(259 "inline-flex min-w-5 items-center justify-center rounded-[var(--radius-sm)] px-1.5 py-0.5",260 "text-[length:var(--text-xs)] tabular-nums leading-none tracking-[var(--tracking-fine)]",261 isDropTarget262 ? "bg-[var(--color-brand)] text-[var(--color-brand-ink)]"263 : "bg-[var(--color-paper)] text-[var(--color-ink-muted)] ring-1 ring-[var(--color-rule)]",264 )}265 >266 {col.cards.length}267 </span>268 </div>269 <Reorder.Group270 axis="y"271 values={col.cards}272 onReorder={(cards) => reorderInColumn(col.id, cards)}273 className="flex min-h-[4.5rem] flex-1 flex-col gap-2"274 >275 {col.cards.map((card) => (276 <CardRow277 key={card.id}278 card={card}279 reduce={reduce}280 columnIds={columnIds}281 onDragToColumn={moveCardToColumn}282 onDragActive={onDragActive}283 />284 ))}285 </Reorder.Group>286 </motion.div>287 );288 })}289 </div>290 </LayoutGroup>291 );292}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { KanbanBoard } from "@/components/ui/kanban-board";2 3<KanbanBoard columns={columns} onChange={setColumns} />
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| columns | KanbanColumn[] | Yes | — | Columns with id, title, and cards. |
| onChange | (columns: KanbanColumn[]) => void | No | — | Fires after reorder or cross-column move. |
Best Practices
- Persist via
onChange— the board is controlled when you pass it. - Keep card titles short; put status in
meta. - For single-list reorder only, prefer Reorder List instead.