shadimage
Shuip
blockkanbansource: ok · hit

Kanban

npx shadcn add "https://shuip.plvo.dev/r/kanban.json"
free

Where it goes

kanban

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

buttoncardinput

Files1 · source cached 10/9/2026, 1:05:13 AM

./items/blocks/kanban/component.tsx
'use client';

import {
  closestCorners,
  DndContext,
  type DragEndEvent,
  type DragOverEvent,
  DragOverlay,
  type DragStartEvent,
  KeyboardSensor,
  PointerSensor,
  useDroppable,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Plus, Search } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';

export type KanbanColumn = {
  id: string;
  label: string;
  color?: string;
};

export type KanbanField<T> = {
  key: keyof T;
  label?: string;
  render?: (value: T[keyof T], item: T) => React.ReactNode;
};

export type KanbanProps<T extends Record<string, unknown>> = {
  columns: KanbanColumn[];
  data?: T[];
  defaultData?: T[];
  onDataChange?: (next: T[]) => void;
  idField?: keyof T;
  columnField: keyof T;
  title?: (item: T) => React.ReactNode;
  fields?: KanbanField<T>[];
  cardContent?: (item: T) => React.ReactNode;
  searchableFields?: (keyof T)[];
  searchPlaceholder?: string;
  renderColumnSummary?: (items: T[], column: KanbanColumn) => React.ReactNode;
  onCardClick?: (item: T) => void;
  onCardAdd?: (columnId: string) => void;
  onCardMove?: (e: { item: T; fromColumn: string; toColumn: string; toIndex: number }) => void;
  className?: string;
};

export function Kanban<T extends Record<string, unknown>>({
  columns,
  data,
  defaultData,
  onDataChange,
  idField = 'id' as keyof T,
  columnField,
  title,
  fields,
  cardContent,
  searchableFields,
  searchPlaceholder = 'Search...',
  renderColumnSummary,
  onCardClick,
  onCardAdd,
  onCardMove,
  className,
}: KanbanProps<T>) {
  const getId = React.useCallback((item: T) => String(item[idField]), [idField]);
  const getColumn = React.useCallback((item: T) => String(item[columnField]), [columnField]);

  const [items, setItems] = React.useState<T[]>(() => data ?? defaultData ?? []);
  const draggingRef = React.useRef(false);
  const fromColumnRef = React.useRef<string | null>(null);
  const fromIndexRef = React.useRef<number | null>(null);
  const startItemsRef = React.useRef<T[] | null>(null);
  React.useEffect(() => {
    if (data && !draggingRef.current) setItems(data);
  }, [data]);

  const [query, setQuery] = React.useState('');
  const deferredQuery = React.useDeferredValue(query);
  const [activeId, setActiveId] = React.useState<string | null>(null);

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  );

  const visible = React.useMemo(() => {
    const q = deferredQuery.trim().toLowerCase();
    if (!q || !searchableFields?.length) return items;
    return items.filter((it) =>
      searchableFields.some((f) =>
        String(it[f] ?? '')
          .toLowerCase()
          .includes(q),
      ),
    );
  }, [items, deferredQuery, searchableFields]);

  const itemsByColumn = React.useMemo(() => {
    const grouped = new Map<string, T[]>(columns.map((column) => [column.id, []]));
    for (const item of visible) {
      grouped.get(getColumn(item))?.push(item);
    }
    return grouped;
  }, [columns, visible, getColumn]);

  const columnIndexOf = React.useCallback(
    (list: T[], columnId: string, cardId: string) =>
      list.filter((it) => getColumn(it) === columnId).findIndex((it) => getId(it) === cardId),
    [getColumn, getId],
  );

  function handleDragStart(event: DragStartEvent) {
    draggingRef.current = true;
    const id = String(event.active.id);
    const startItem = items.find((it) => getId(it) === id);
    const startColumn = startItem ? getColumn(startItem) : null;
    fromColumnRef.current = startColumn;
    fromIndexRef.current = startColumn == null ? null : columnIndexOf(items, startColumn, id);
    startItemsRef.current = items;
    setActiveId(id);
  }

  // Reorder live so the rendered list always matches the drag preview; the drop
  // handler only reads the settled order and notifies. This keeps intra- and
  // cross-column moves consistent (no off-by-one between preview and commit).
  function handleDragOver(event: DragOverEvent) {
    const { active, over } = event;
    if (!over) return;
    const activeCardId = String(active.id);
    const overId = String(over.id);
    if (activeCardId === overId) return;

    setItems((prev) => {
      const activeIndex = prev.findIndex((it) => getId(it) === activeCardId);
      if (activeIndex < 0) return prev;

      const overIsColumn = columns.some((c) => c.id === overId);
      const overItem = overIsColumn ? undefined : prev.find((it) => getId(it) === overId);
      const overColumn = overIsColumn ? overId : overItem ? getColumn(overItem) : null;
      if (overColumn == null) return prev;

      const columnChanged = getColumn(prev[activeIndex]) !== overColumn;
      const updated = columnChanged
        ? prev.map((it, i) => (i === activeIndex ? ({ ...it, [columnField]: overColumn } as T) : it))
        : prev;
      const activeIndexNow = columnChanged ? updated.findIndex((it) => getId(it) === activeCardId) : activeIndex;

      let overIndex = activeIndexNow;
      if (overIsColumn) {
        for (let i = 0; i < updated.length; i++) {
          if (getId(updated[i]) !== activeCardId && getColumn(updated[i]) === overColumn) overIndex = i;
        }
      } else {
        overIndex = updated.findIndex((it) => getId(it) === overId);
      }

      if (overIndex < 0 || (!columnChanged && overIndex === activeIndexNow)) return updated;
      return arrayMove(updated, activeIndexNow, overIndex);
    });
  }

  function handleDragEnd(event: DragEndEvent) {
    draggingRef.current = false;
    setActiveId(null);
    const fromColumn = fromColumnRef.current;
    const fromIndex = fromIndexRef.current;
    fromColumnRef.current = null;
    fromIndexRef.current = null;
    if (fromColumn == null) return;

    const activeCardId = String(event.active.id);
    const movedItem = items.find((it) => getId(it) === activeCardId);
    if (!movedItem) return;

    const toColumn = getColumn(movedItem);
    const toIndex = columnIndexOf(items, toColumn, activeCardId);
    startItemsRef.current = null;
    if (toColumn === fromColumn && toIndex === fromIndex) return;

    onDataChange?.(items);
    onCardMove?.({ item: movedItem, fromColumn, toColumn, toIndex });
  }

  function handleDragCancel() {
    draggingRef.current = false;
    const snapshot = startItemsRef.current;
    startItemsRef.current = null;
    fromColumnRef.current = null;
    fromIndexRef.current = null;
    setActiveId(null);
    if (snapshot) setItems(snapshot);
  }

  const activeItem = activeId ? (items.find((it) => getId(it) === activeId) ?? null) : null;

  const renderTitle = React.useCallback((item: T): React.ReactNode => (title ? title(item) : null), [title]);

  const renderBody = React.useCallback(
    (item: T): React.ReactNode => {
      if (cardContent) return cardContent(item);
      if (!fields?.length) return null;
      return (
        <div className='flex flex-wrap gap-x-2 gap-y-1 text-xs text-muted-foreground'>
          {fields.map((field) => {
            const value = item[field.key];
            return (
              <span key={String(field.key)} className='whitespace-nowrap'>
                {field.label ? <span className='font-medium'>{field.label}: </span> : null}
                {field.render ? field.render(value, item) : String(value ?? '')}
              </span>
            );
          })}
        </div>
      );
    },
    [cardContent, fields],
  );

  return (
    <div className={cn('flex flex-col gap-4', className)}>
      {searchableFields?.length ? (
        <div className='relative w-full max-w-xs'>
          <Search className='absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
          <Input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder={searchPlaceholder}
            className='pl-8'
          />
        </div>
      ) : null}

      <DndContext
        sensors={sensors}
        collisionDetection={closestCorners}
        onDragStart={handleDragStart}
        onDragOver={handleDragOver}
        onDragEnd={handleDragEnd}
        onDragCancel={handleDragCancel}
      >
        <div className='flex flex-row gap-4 overflow-x-auto pb-2'>
          {columns.map((column) => {
            const columnItems = itemsByColumn.get(column.id) ?? [];
            return (
              <KanbanColumnView
                key={column.id}
                column={column}
                count={columnItems.length}
                summary={renderColumnSummary?.(columnItems, column)}
                onAdd={onCardAdd ? () => onCardAdd(column.id) : undefined}
              >
                <SortableContext items={columnItems.map((it) => getId(it))} strategy={verticalListSortingStrategy}>
                  {columnItems.length === 0 ? (
                    <p className='rounded-md border border-dashed p-3 text-center text-xs text-muted-foreground'>
                      No items
                    </p>
                  ) : (
                    columnItems.map((item) => (
                      <SortableCard
                        key={getId(item)}
                        id={getId(item)}
                        columnId={column.id}
                        title={renderTitle(item)}
                        body={renderBody(item)}
                        onClick={onCardClick ? () => onCardClick(item) : undefined}
                      />
                    ))
                  )}
                </SortableContext>
              </KanbanColumnView>
            );
          })}
        </div>

        <DragOverlay>
          {activeItem ? (
            <KanbanCardFace
              title={renderTitle(activeItem)}
              body={renderBody(activeItem)}
              className='rotate-3 shadow-lg'
              handle={<GripVertical className='mt-0.5 size-4 shrink-0 text-muted-foreground' />}
            />
          ) : null}
        </DragOverlay>
      </DndContext>
    </div>
  );
}

function KanbanColumnView({
  column,
  count,
  summary,
  onAdd,
  children,
}: {
  column: KanbanColumn;
  count: number;
  summary?: React.ReactNode;
  onAdd?: () => void;
  children: React.ReactNode;
}) {
  const { setNodeRef, isOver } = useDroppable({ id: column.id });
  return (
    <div className='flex w-72 shrink-0 flex-col gap-3'>
      <div className='flex items-center gap-2'>
        {column.color ? <span className='size-2 rounded-full' style={{ backgroundColor: column.color }} /> : null}
        <span className='text-sm font-medium'>{column.label}</span>
        <span className='rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground'>{count}</span>
        {summary != null ? <span className='ml-auto text-xs text-muted-foreground'>{summary}</span> : null}
        {onAdd ? (
          <Button variant='ghost' size='icon' className={cn('size-6', summary == null && 'ml-auto')} onClick={onAdd}>
            <Plus className='size-4' />
          </Button>
        ) : null}
      </div>
      <div
        ref={setNodeRef}
        className={cn(
          'flex min-h-24 flex-col gap-2 rounded-lg border border-transparent p-2 transition-colors',
          isOver && 'border-border bg-muted/50',
        )}
      >
        {children}
      </div>
    </div>
  );
}

function SortableCard({
  id,
  columnId,
  title,
  body,
  onClick,
}: {
  id: string;
  columnId: string;
  title?: React.ReactNode;
  body?: React.ReactNode;
  onClick?: () => void;
}) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id,
    data: { columnId },
  });
  const style: React.CSSProperties = {
    transform: CSS.Transform.toString(transform),
    transition,
  };
  return (
    <div ref={setNodeRef} style={style} className={cn(isDragging && 'opacity-50')}>
      <KanbanCardFace
        title={title}
        body={body}
        onClick={onClick}
        handle={
          <button
            type='button'
            className='mt-0.5 cursor-grab touch-none text-muted-foreground active:cursor-grabbing'
            onClick={(e) => e.stopPropagation()}
            {...attributes}
            {...listeners}
          >
            <GripVertical className='size-4' />
          </button>
        }
      />
    </div>
  );
}

function KanbanCardFace({
  title,
  body,
  onClick,
  handle,
  className,
}: {
  title?: React.ReactNode;
  body?: React.ReactNode;
  onClick?: () => void;
  handle?: React.ReactNode;
  className?: string;
}) {
  const hasBody = body != null;
  return (
    <Card className={cn('gap-0 py-0', onClick && 'cursor-pointer', className)} onClick={onClick}>
      <CardHeader className={cn('flex flex-row items-start gap-2 space-y-0 px-3 pt-3', hasBody ? 'pb-0' : 'pb-3')}>
        {handle}
        {title != null ? (
          <CardTitle className='flex-1 text-sm leading-snug'>{title}</CardTitle>
        ) : (
          <span className='flex-1' />
        )}
      </CardHeader>
      {hasBody ? <CardContent className='px-3 pb-3 pl-9 pt-1'>{body}</CardContent> : null}
    </Card>
  );
}

More from Shuip

Browse all components in this library →