Skip to main content
Live Demo

Installation

npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/data-kanban-criteria.json"

Features

  • Drag and drop between columns with @dnd-kit
  • Virtualized lists for large datasets with @tanstack/react-virtual
  • Infinite scroll per column
  • Horizontal scroll for unlimited columns
  • Integrated filtering and search
  • Optimistic updates with automatic rollback on error
  • Fractional indexing for scalable ordering
  • Customizable card, column, and empty state rendering
  • Smart cursor styles (grab/grabbing/pointer)
  • Prevent drag on specific elements with data-no-drag
  • URL sync for filters and search

Basic Usage

import { useCriteriaKanban } from "@/hooks/use-criteria-kanban";
import {
  DataKanbanCriteria,
  KanbanCard,
  KanbanCardHeader,
  KanbanCardTitle,
  KanbanCardDescription,
  KanbanCardContent,
  KanbanCardFooter,
  KanbanCardBadge,
} from "@/components/data-kanban-criteria";
import type { KanbanColumnDefinition } from "@/types/use-criteria-kanban.type";
import type { QueryFilter } from "@/lib/filter-utils";

interface Task {
  id: string;
  title: string;
  description: string;
  status: "todo" | "doing" | "done";
  priority: "low" | "medium" | "high";
  order: string; // Fractional index for ordering
}

// Define columns with criteria filters
const columns: KanbanColumnDefinition<Task>[] = [
  {
    id: "todo",
    title: "To Do",
    criteria: (c) => c.where("status", "equals", "todo"),
    value: "todo",
  },
  {
    id: "doing",
    title: "In Progress",
    criteria: (c) => c.where("status", "equals", "doing"),
    value: "doing",
  },
  {
    id: "done",
    title: "Done",
    criteria: (c) => c.where("status", "equals", "done"),
    value: "done",
  },
];

const filterFields: QueryFilter[] = [
  {
    field: "priority",
    fieldLabel: "Priority",
    type: "string",
    multiSelect: true,
    options: [
      { label: "High", value: "high" },
      { label: "Medium", value: "medium" },
      { label: "Low", value: "low" },
    ],
  },
];

function TaskKanban() {
  const kanban = useCriteriaKanban<Task>(["tasks", "kanban"], getTasks, {
    columns,
    getItemId: (task) => task.id,
    groupField: "status",
    filterFields,
    columnPageSize: 20,
    syncWithUrl: true,
    onCardMove: async ({ cardId, toColumn, insertAfterId }) => {
      // Call your API to move the task
      await moveTask(cardId, toColumn.id, insertAfterId);
    },
    onMoveError: (error, { fromColumn }) => {
      toast.error(`Failed to move task. Reverted to ${fromColumn.title}`);
    },
  });

  return (
    <DataKanbanCriteria
      kanban={kanban}
      renderCard={(task, isDragging, isClickable) => (
        <KanbanCard id={task.id} isDragging={isDragging} isClickable={isClickable}>
          <KanbanCardHeader>
            <KanbanCardTitle>{task.title}</KanbanCardTitle>
          </KanbanCardHeader>
          <KanbanCardContent>
            <KanbanCardDescription>{task.description}</KanbanCardDescription>
          </KanbanCardContent>
          <KanbanCardFooter>
            <KanbanCardBadge>{task.priority}</KanbanCardBadge>
          </KanbanCardFooter>
        </KanbanCard>
      )}
      estimatedCardHeight={120}
    />
  );
}

Hook Options

interface UseCriteriaKanbanOptions<T> {
  // Required
  columns: KanbanColumnDefinition<T>[]; // Column definitions
  getItemId: (item: T) => string; // Get unique ID from item
  groupField: FieldPath<T>; // Field that determines column

  // Card move handling
  onCardMove?: (params: CardMoveParams<T>) => Promise<void>;
  onMoveError?: (error: Error, params: CardMoveParams<T>) => void;

  // Filter configuration
  filterFields?: QueryFilter[];

  // Search
  searchDebounceMs?: number; // Default: 300

  // Pagination
  columnPageSize?: number; // Default: 50

  // Drag and drop
  enableDragDrop?: boolean; // Default: true
  sensors?: SensorDescriptor<SensorOptions>[]; // Custom dnd-kit sensors

  // URL sync
  syncWithUrl?: boolean;
}

Column Definition

interface KanbanColumnDefinition<T> {
  id: string; // Unique identifier
  title: string; // Display title
  criteria: (base: Criteria<T>) => Criteria<T>; // Filter for this column
  value?: unknown; // Value to set on items when moved here
  color?: string; // Optional color theme
  icon?: React.ReactNode; // Optional icon
  limit?: number; // WIP limit
}
The value property is used when the column id differs from the actual value stored in your data. For example, if your column has id: "in-progress" but your database stores status: "DOING", set value: "DOING". When a card is moved to this column, the hook will use value for optimistic updates. If value is omitted, the column id is used instead.

Card Move Parameters

When a card is moved, onCardMove receives:
interface CardMoveParams<T> {
  cardId: string; // ID of the moved card
  item: T; // The complete item data
  fromColumn: KanbanColumnDefinition<T>;
  toColumn: KanbanColumnDefinition<T>;
  toIndex: number; // New position index
  insertAfterId: string | null; // ID of item above, null if first
}
The insertAfterId pattern is designed for scalability:
  • Frontend sends only the reference ID (not the order)
  • Backend queries the real neighbors to calculate the correct order
  • Works correctly even with filters applied

Backend Integration

Your backend should implement a move endpoint like:
// Backend receives insertAfterId and calculates order
async function moveTask(
  taskId: string,
  newStatus: string,
  insertAfterId: string | null
) {
  let prevOrder: string | null = null;
  let nextOrder: string | null = null;

  if (insertAfterId === null) {
    // Insert at top - find first item's order
    const firstItem = await db.task.findFirst({
      where: { status: newStatus },
      orderBy: { order: "asc" },
      select: { order: true },
    });
    nextOrder = firstItem?.order ?? null;
  } else {
    // Find the reference task's order
    const refTask = await db.task.findUnique({
      where: { id: insertAfterId },
      select: { order: true },
    });
    prevOrder = refTask!.order;

    // Find next order after reference
    const nextItem = await db.task.findFirst({
      where: { status: newStatus, order: { gt: prevOrder } },
      orderBy: { order: "asc" },
      select: { order: true },
    });
    nextOrder = nextItem?.order ?? null;
  }

  // Calculate new order between prev and next
  const newOrder = generateFractionalIndex(prevOrder, nextOrder);

  await db.task.update({
    where: { id: taskId },
    data: { status: newStatus, order: newOrder },
  });
}

Component Props

interface DataKanbanCriteriaProps<T> {
  kanban: UseCriteriaKanbanReturn<T>; // From useCriteriaKanban
  renderCard: (item: T, isDragging: boolean, isClickable: boolean) => React.ReactNode;
  renderColumnHeader?: (column, itemCount) => React.ReactNode;
  renderColumnFooter?: (column) => React.ReactNode;
  renderEmptyState?: (column) => React.ReactNode; // Custom empty state
  onCardClick?: (item: T) => void;

  // Layout
  toolbarLayout?: "default" | "compact" | "none";
  actionBar?: React.ReactNode;
  className?: string;
  columnsClassName?: string;
  columnClassName?: string;
  columnsContentScrollClassName?: string;

  // Virtualization
  estimatedCardHeight?: number; // Default: 120
  showItemCount?: boolean; // Default: true
  showSkeleton?: boolean; // Default: true
}

Toolbar Layouts

// Default layout - toolbar with filters and search
<DataKanbanCriteria
  kanban={kanban}
  renderCard={renderCard}
  toolbarLayout="default"
/>

// Compact - smaller toolbar
<DataKanbanCriteria
  kanban={kanban}
  renderCard={renderCard}
  toolbarLayout="compact"
/>

// No toolbar - just the board
<DataKanbanCriteria
  kanban={kanban}
  renderCard={renderCard}
  toolbarLayout="none"
/>

Programmatic Card Move

const { moveCard } = useCriteriaKanban(...);

// Move card programmatically
await moveCard(
  cardId,      // Card to move
  fromColumnId,
  toColumnId,
  toIndex      // Position in target column
);

Custom Card Components

The package includes pre-built card components:
import {
  KanbanCard,
  KanbanCardHeader,
  KanbanCardTitle,
  KanbanCardDescription,
  KanbanCardContent,
  KanbanCardFooter,
  KanbanCardBadge,
} from "@/components/data-kanban-criteria";

<KanbanCard id={task.id} isDragging={isDragging} isClickable={isClickable}>
  <KanbanCardHeader>
    <KanbanCardTitle>{task.title}</KanbanCardTitle>
  </KanbanCardHeader>
  <KanbanCardContent>
    <KanbanCardDescription>{task.description}</KanbanCardDescription>
  </KanbanCardContent>
  <KanbanCardFooter>
    <KanbanCardBadge className="bg-red-100 text-red-700">
      {task.priority}
    </KanbanCardBadge>
  </KanbanCardFooter>
</KanbanCard>

Custom Empty State

Customize what’s shown when a column has no items:
<DataKanbanCriteria
  kanban={kanban}
  renderCard={renderCard}
  renderEmptyState={(column) => (
    <div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
      <p className="text-sm">No tasks in {column.title}</p>
      <button className="mt-2 text-primary text-sm">
        + Add task
      </button>
    </div>
  )}
/>
If not provided, defaults to a simple “No items” message.

Preventing Drag on Specific Elements

Use the data-no-drag attribute on elements that should not trigger dragging:
<KanbanCard id={task.id} isDragging={isDragging} isClickable={isClickable}>
  <KanbanCardHeader>
    <KanbanCardTitle>{task.title}</KanbanCardTitle>
    {/* Button won't trigger drag when clicked */}
    <button data-no-drag onClick={() => deleteTask(task.id)}>
      Delete
    </button>
  </KanbanCardHeader>
</KanbanCard>
This is useful for interactive elements inside cards like buttons, inputs, or dropdown menus.

Cursor Styles

The KanbanCard component automatically handles cursor styles based on state:
StateCursorWhen
cursor-grabCard is draggable, no onCardClick
cursor-pointer👆Card has onCardClick handler
cursor-grabbingCard is being dragged
cursor-not-allowed🚫Card is disabled
The isClickable parameter in renderCard determines if the card should show cursor-pointer:
<DataKanbanCriteria
  kanban={kanban}
  renderCard={(task, isDragging, isClickable) => (
    <KanbanCard
      id={task.id}
      isDragging={isDragging}
      isClickable={isClickable}  // Pass this to get correct cursor
    >
      {/* ... */}
    </KanbanCard>
  )}
  onCardClick={(task) => openTaskModal(task)}  // When set, isClickable=true
/>

Horizontal Scroll

The board automatically supports horizontal scrolling when you have many columns. Each column has a fixed width (320px) and the container scrolls horizontally when columns exceed the viewport. This works out of the box - no configuration needed.

Full Height Layout

To make the kanban fill the viewport height:
<DataKanbanCriteria
  kanban={kanban}
  renderCard={renderCard}
  columnsContentScrollClassName="h-[calc(100vh-200px)]"
/>