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.json

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/kanban-board.tsx
tsx
"use client";
import React, { useCallback, useEffect, useState } from "react";import {  LayoutGroup,  Reorder,  motion,  useDragControls,  useReducedMotion,} from "motion/react";import { GripVertical } from "lucide-react";import { cn } from "@/lib/utils";
export type KanbanCard = {  id: string;  title: string;  meta?: string;};
export type KanbanColumn = {  id: string;  title: string;  cards: KanbanCard[];};
export type KanbanBoardProps = {  columns: KanbanColumn[];  onChange?: (columns: KanbanColumn[]) => void;  className?: string;};
/** Layout settle — under 300ms, no bounce (Emil: bounce is rare in UI). */const LAYOUT_SPRING = { type: "spring" as const, bounce: 0, duration: 0.25 };/** Lift / press — snappy ease-out, never ease-in. */const LIFT_TRANSITION = {  type: "spring" as const,  bounce: 0,  duration: 0.2,};
function cloneColumns(cols: KanbanColumn[]): KanbanColumn[] {  return cols.map((c) => ({ ...c, cards: [...c.cards] }));}
function findCard(  cols: KanbanColumn[],  cardId: string,): { colIndex: number; cardIndex: number } | null {  for (let i = 0; i < cols.length; i++) {    const j = cols[i].cards.findIndex((c) => c.id === cardId);    if (j >= 0) return { colIndex: i, cardIndex: j };  }  return null;}
function columnAtPoint(x: number, y: number): string | undefined {  const els = document.elementsFromPoint(x, y);  const colEl = els.find((el) => (el as HTMLElement).dataset?.kanbanCol) as    | HTMLElement    | undefined;  return colEl?.dataset.kanbanCol;}
function CardRow({  card,  reduce,  onDragToColumn,  columnIds,  onDragActive,}: {  card: KanbanCard;  reduce: boolean | null;  onDragToColumn: (cardId: string, targetColId: string) => void;  columnIds: string[];  onDragActive: (cardId: string | null, overColId: string | null) => void;}) {  const controls = useDragControls();  const [dragging, setDragging] = useState(false);
  return (    <Reorder.Item      value={card}      id={card.id}      as="li"      layout={reduce ? undefined : true}      dragListener={false}      dragControls={controls}      dragElastic={0.12}      dragMomentum={false}      onDragStart={() => {        setDragging(true);        onDragActive(card.id, null);      }}      onDrag={(_, info) => {        const target = columnAtPoint(info.point.x, info.point.y);        onDragActive(card.id, target && columnIds.includes(target) ? target : null);      }}      onDragEnd={(_, info) => {        setDragging(false);        const target = columnAtPoint(info.point.x, info.point.y);        onDragActive(null, null);        if (target && columnIds.includes(target)) {          onDragToColumn(card.id, target);        }      }}      transition={        reduce          ? { duration: 0.12 }          : {              layout: LAYOUT_SPRING,              scale: LIFT_TRANSITION,            }      }      initial={false}      animate={        reduce          ? { scale: 1 }          : {              // Lift only while dragging — press lives on the grip (:active).              // Never scale(0); 1.02 is enough to read as "picked up".              scale: dragging ? 1.02 : 1,              zIndex: dragging ? 20 : 0,            }      }      className={cn(        "list-none rounded-[var(--radius-md)] bg-[var(--color-paper)]",        "ring-1 ring-[var(--color-rule)]",        // Shadow via class + CSS transition — not animated boxShadow (paint thrash).        "transition-[box-shadow,ring-color] duration-(--dur-short) ease-[var(--ease-out)]",        dragging          ? "shadow-[0_8px_24px_rgba(0,0,0,0.12)] ring-[var(--color-brand)]/35"          : "shadow-none",      )}      style={{ position: "relative" }}    >      <div className="flex items-start gap-1 px-2 py-2.5">        <button          type="button"          aria-label={`Drag ${card.title}`}          className={cn(            "mt-0.5 flex size-8 shrink-0 touch-none items-center justify-center",            "rounded-[var(--radius-sm)] text-[var(--color-ink-muted)] outline-none",            "transition-[transform,background-color,color] duration-(--dur-micro) ease-[var(--ease-out)]",            "active:scale-[0.97] active:bg-[var(--color-paper-2)] active:text-[var(--color-brand)]",            "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",            "[@media(hover:hover)_and_(pointer:fine)]:hover:bg-[var(--color-paper-2)]",            dragging && "text-[var(--color-brand)]",          )}          onPointerDown={(e) => {            e.currentTarget.setPointerCapture?.(e.pointerId);            controls.start(e);          }}        >          <GripVertical className="size-4" strokeWidth={1.5} aria-hidden />        </button>        <div className="min-w-0 flex-1 pr-1">          <p            className="text-[length:var(--text-sm)] font-semibold leading-[1.29] tracking-[var(--tracking-caption)] text-[var(--color-ink)]"          >            {card.title}          </p>          {card.meta ? (            <p              className="mt-0.5 text-[length:var(--text-xs)] leading-[var(--leading-tight)] tracking-[var(--tracking-fine)] text-[var(--color-ink-muted)]"            >              {card.meta}            </p>          ) : null}        </div>      </div>    </Reorder.Item>  );}
export function KanbanBoard({  columns: columnsProp,  onChange,  className,}: KanbanBoardProps) {  const reduce = useReducedMotion();  const [internal, setInternal] = useState(columnsProp);  const [activeCardId, setActiveCardId] = useState<string | null>(null);  const [overColId, setOverColId] = useState<string | null>(null);  const columns = onChange ? columnsProp : internal;  const columnIds = columns.map((c) => c.id);
  const commit = useCallback(    (next: KanbanColumn[]) => {      if (onChange) onChange(next);      else setInternal(next);    },    [onChange],  );
  useEffect(() => {    if (!onChange) setInternal(columnsProp);  }, [columnsProp, onChange]);
  const onDragActive = useCallback(    (cardId: string | null, overId: string | null) => {      setActiveCardId(cardId);      setOverColId(overId);    },    [],  );
  const reorderInColumn = (colId: string, cards: KanbanCard[]) => {    const next = cloneColumns(columns);    const col = next.find((c) => c.id === colId);    if (!col) return;    col.cards = cards;    commit(next);  };
  const moveCardToColumn = (cardId: string, targetColId: string) => {    const loc = findCard(columns, cardId);    if (!loc) return;    if (columns[loc.colIndex].id === targetColId) return;    const next = cloneColumns(columns);    const [card] = next[loc.colIndex].cards.splice(loc.cardIndex, 1);    const target = next.find((c) => c.id === targetColId);    if (!target || !card) return;    target.cards.push(card);    commit(next);  };
  return (    <LayoutGroup>      <div className={cn("flex gap-3 overflow-x-auto pb-1", className)}>        {columns.map((col) => {          const isDropTarget =            Boolean(activeCardId) &&            overColId === col.id &&            !col.cards.some((c) => c.id === activeCardId);
          return (            <motion.div              key={col.id}              layout={reduce ? undefined : true}              transition={reduce ? { duration: 0.1 } : LAYOUT_SPRING}              data-kanban-col={col.id}              className={cn(                "flex w-64 shrink-0 flex-col rounded-[var(--radius-lg)] p-2",                "bg-[var(--color-paper-2)]",                "ring-1 transition-[box-shadow,ring-color,background-color] duration-(--dur-short) ease-[var(--ease-out)]",                isDropTarget                  ? "bg-[color-mix(in_oklab,var(--color-brand)_6%,var(--color-paper-2))] ring-[var(--color-brand)]/40"                  : "ring-transparent",              )}            >              <div className="mb-2 flex items-center justify-between px-1.5 pt-1">                <h3                  className="text-[length:var(--text-xs)] font-semibold uppercase leading-[var(--leading-tight)] tracking-[var(--tracking-fine)] text-[var(--color-ink)]"                >                  {col.title}                </h3>                <span                  className={cn(                    "inline-flex min-w-5 items-center justify-center rounded-[var(--radius-sm)] px-1.5 py-0.5",                    "text-[length:var(--text-xs)] tabular-nums leading-none tracking-[var(--tracking-fine)]",                    isDropTarget                      ? "bg-[var(--color-brand)] text-[var(--color-brand-ink)]"                      : "bg-[var(--color-paper)] text-[var(--color-ink-muted)] ring-1 ring-[var(--color-rule)]",                  )}                >                  {col.cards.length}                </span>              </div>              <Reorder.Group                axis="y"                values={col.cards}                onReorder={(cards) => reorderInColumn(col.id, cards)}                className="flex min-h-[4.5rem] flex-1 flex-col gap-2"              >                {col.cards.map((card) => (                  <CardRow                    key={card.id}                    card={card}                    reduce={reduce}                    columnIds={columnIds}                    onDragToColumn={moveCardToColumn}                    onDragActive={onDragActive}                  />                ))}              </Reorder.Group>            </motion.div>          );        })}      </div>    </LayoutGroup>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { KanbanBoard } from "@/components/ui/kanban-board";
<KanbanBoard columns={columns} onChange={setColumns} />

Props

PropTypeRequiredDefaultDescription
columnsKanbanColumn[]YesColumns with id, title, and cards.
onChange(columns: KanbanColumn[]) => voidNoFires after reorder or cross-column move.

Best Practices

  1. Persist via onChange — the board is controlled when you pass it.
  2. Keep card titles short; put status in meta.
  3. For single-list reorder only, prefer Reorder List instead.