Skip to content

Kanban

Shared Components

A controlled task board with drag handles, native move controls, empty columns, and custom card content.

Controlled project workflowPointer and keyboard draggingNative move controlsCustom cards / read-only / empty
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Provide uniquely identified columns with cards and own their state through onColumnsChange(nextColumns, move). The component performs immutable local movement and reports the card ID, source/destination IDs, and requested index; persistence, permissions, pending state, and failed-write rollback belong to the parent. The exported moveKanbanCard helper rejects missing cards, disabled columns, and invalid indices, clamps bounds, and preserves the original input. renderCard can show application-specific metadata and actions. Without a callback or with disabled, the board is read-only. The Kanban app uses the same component with its task store and creation/deletion dialog.

Import
import { Kanban, type KanbanColumn } from "@/components/ui/kanban";

Shipped as editable source in src/components/ui/kanban.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/kanban.tsx
"use client";

import { useId, useState, type ReactNode } from "react";
import { DragDropContext, Draggable, Droppable } from "@hello-pangea/dnd";
import { ArrowDown, ArrowUp, GripVertical } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
export interface KanbanCard {
  id: string;
  title: string;
  description?: string;
}
export interface KanbanColumn<T extends KanbanCard = KanbanCard> {
  id: string;
  title: string;
  cards: T[];
  disabled?: boolean;
}
export interface KanbanMove {
  cardId: string;
  fromColumnId: string;
  toColumnId: string;
  toIndex: number;
}
export function moveKanbanCard<T extends KanbanCard>(
  columns: KanbanColumn<T>[],
  move: KanbanMove,
): KanbanColumn<T>[] {
  const source = columns.find((column) => column.id === move.fromColumnId);
  const destination = columns.find((column) => column.id === move.toColumnId);
  const index =
    source?.cards.findIndex((card) => card.id === move.cardId) ?? -1;
  if (
    !source ||
    !destination ||
    source.disabled ||
    destination.disabled ||
    index < 0 ||
    !Number.isInteger(move.toIndex)
  )
    return columns;
  const target = Math.max(
    0,
    Math.min(
      destination.cards.length - (source === destination ? 1 : 0),
      Math.trunc(move.toIndex),
    ),
  );
  if (source === destination && index === target) return columns;
  const next = columns.map((column) => ({
    ...column,
    cards: [...column.cards],
  }));
  const from = next.find((column) => column.id === source.id)!;
  const to = next.find((column) => column.id === destination.id)!;
  const [card] = from.cards.splice(index, 1);
  to.cards.splice(target, 0, card);
  return next;
}
export interface KanbanProps<T extends KanbanCard = KanbanCard> {
  label: string;
  columns: KanbanColumn<T>[];
  onColumnsChange?: (columns: KanbanColumn<T>[], move: KanbanMove) => void;
  renderCard?: (card: T, column: KanbanColumn<T>) => ReactNode;
  disabled?: boolean;
  className?: string;
}
export function Kanban<T extends KanbanCard>({
  label,
  columns,
  onColumnsChange,
  renderCard,
  disabled,
  className,
}: KanbanProps<T>) {
  const id = useId();
  const [announcement, setAnnouncement] = useState("");
  const cardIds = columns.flatMap((column) =>
    column.cards.map((card) => card.id),
  );
  const invalid =
    columns.some(
      (column) => !column.id || column.cards.some((card) => !card.id),
    ) ||
    new Set(columns.map((column) => column.id)).size !== columns.length ||
    new Set(cardIds).size !== cardIds.length;
  if (invalid)
    return (
      <p
        role="alert"
        className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive"
      >
        Kanban needs unique non-empty column and card IDs.
      </p>
    );
  const readOnly = disabled || !onColumnsChange;
  const move = (change: KanbanMove) => {
    if (readOnly) return;
    const next = moveKanbanCard(columns, change);
    if (next === columns) return;
    const focused = document.activeElement;
    const cardElementId = `${id}-card-${encodeURIComponent(change.cardId)}`;
    const restore =
      focused instanceof HTMLElement &&
      document.getElementById(cardElementId)?.contains(focused);
    const focusLabel = focused?.getAttribute("aria-label");
    onColumnsChange?.(next, change);
    if (restore)
      requestAnimationFrame(() => {
        const element = document.getElementById(cardElementId);
        const target = [
          ...(element?.querySelectorAll<HTMLElement>("[aria-label]") ?? []),
        ].find(
          (control) =>
            control.getAttribute("aria-label") === focusLabel &&
            !(control as HTMLButtonElement).disabled,
        );
        (
          target ??
          element?.querySelector<HTMLElement>("select:not(:disabled)") ??
          element
        )?.focus();
      });
    const card = columns
      .flatMap((column) => column.cards)
      .find((item) => item.id === change.cardId);
    const destination = next.find((column) => column.id === change.toColumnId)!;
    setAnnouncement(
      `${card?.title} moved to ${destination.title}, position ${destination.cards.findIndex((item) => item.id === change.cardId) + 1}.`,
    );
  };
  return (
    <section aria-label={label} className={cn("min-w-0 space-y-3", className)}>
      <div className="flex flex-wrap items-center justify-between gap-2">
        <h3 className="text-sm font-semibold">{label}</h3>
        <p className="text-xs text-muted-foreground">
          {readOnly
            ? "Read-only board"
            : "Drag a handle or use each card’s move controls"}
        </p>
      </div>
      {!columns.length ? (
        <p className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
          No columns yet. Add a column to start your board.
        </p>
      ) : (
        <DragDropContext
          onDragEnd={(result) => {
            if (result.destination)
              move({
                cardId: result.draggableId,
                fromColumnId: result.source.droppableId,
                toColumnId: result.destination.droppableId,
                toIndex: result.destination.index,
              });
          }}
        >
          <div
            role="region"
            aria-label={`${label} columns`}
            tabIndex={0}
            className="flex gap-4 overflow-x-auto rounded-xl pb-3 outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            {columns.map((column, columnIndex) => (
              <div
                key={column.id}
                className="w-72 min-w-64 shrink-0 rounded-xl border bg-muted/35 p-3 sm:w-80"
                role="group"
                aria-labelledby={`${id}-column-${columnIndex}`}
              >
                <div className="mb-3 flex items-center justify-between gap-2 px-1">
                  <h4
                    id={`${id}-column-${columnIndex}`}
                    className="text-sm font-semibold"
                  >
                    {column.title}
                  </h4>
                  <span className="rounded-full bg-background px-2 py-0.5 text-xs tabular-nums">
                    {column.cards.length}
                  </span>
                </div>
                <Droppable
                  droppableId={column.id}
                  isDropDisabled={readOnly || column.disabled}
                >
                  {(provided, snapshot) => (
                    <div
                      ref={provided.innerRef}
                      {...provided.droppableProps}
                      className={cn(
                        "min-h-32 space-y-3 rounded-lg",
                        snapshot.isDraggingOver &&
                          "bg-primary/5 ring-2 ring-primary/30",
                      )}
                    >
                      {column.cards.map((card, cardIndex) => (
                        <Draggable
                          key={card.id}
                          draggableId={card.id}
                          index={cardIndex}
                          disableInteractiveElementBlocking
                          isDragDisabled={readOnly || column.disabled}
                        >
                          {(drag, dragging) => (
                            <article
                              id={`${id}-card-${encodeURIComponent(card.id)}`}
                              tabIndex={-1}
                              ref={drag.innerRef}
                              {...drag.draggableProps}
                              aria-label={card.title}
                              className={cn(
                                "min-w-0 rounded-xl border bg-card p-3 text-card-foreground",
                                dragging.isDragging &&
                                  "shadow-xl ring-2 ring-primary",
                              )}
                            >
                              <div className="mb-2 flex items-start gap-2">
                                <button
                                  type="button"
                                  {...drag.dragHandleProps}
                                  disabled={readOnly || column.disabled}
                                  aria-label={`Drag ${card.title}`}
                                  className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
                                >
                                  <GripVertical className="size-4" />
                                </button>
                                <div className="min-w-0 flex-1">
                                  {renderCard ? (
                                    renderCard(card, column)
                                  ) : (
                                    <>
                                      <p className="text-sm font-medium">
                                        {card.title}
                                      </p>
                                      {card.description && (
                                        <p className="mt-1 text-xs leading-5 text-muted-foreground">
                                          {card.description}
                                        </p>
                                      )}
                                    </>
                                  )}
                                </div>
                              </div>
                              <div className="flex items-center gap-1 border-t pt-2">
                                <Button
                                  type="button"
                                  size="icon-sm"
                                  variant="ghost"
                                  aria-label={`Move ${card.title} up`}
                                  disabled={
                                    readOnly ||
                                    column.disabled ||
                                    cardIndex === 0
                                  }
                                  onClick={() =>
                                    move({
                                      cardId: card.id,
                                      fromColumnId: column.id,
                                      toColumnId: column.id,
                                      toIndex: cardIndex - 1,
                                    })
                                  }
                                >
                                  <ArrowUp className="size-3.5" />
                                </Button>
                                <Button
                                  type="button"
                                  size="icon-sm"
                                  variant="ghost"
                                  aria-label={`Move ${card.title} down`}
                                  disabled={
                                    readOnly ||
                                    column.disabled ||
                                    cardIndex === column.cards.length - 1
                                  }
                                  onClick={() =>
                                    move({
                                      cardId: card.id,
                                      fromColumnId: column.id,
                                      toColumnId: column.id,
                                      toIndex: cardIndex + 1,
                                    })
                                  }
                                >
                                  <ArrowDown className="size-3.5" />
                                </Button>
                                <select
                                  aria-label={`Move ${card.title} to column`}
                                  disabled={readOnly || column.disabled}
                                  value={column.id}
                                  className="ms-auto min-w-0 max-w-40 rounded-md border bg-background px-1 py-1 text-xs disabled:opacity-50"
                                  onChange={(event) =>
                                    move({
                                      cardId: card.id,
                                      fromColumnId: column.id,
                                      toColumnId: event.target.value,
                                      toIndex:
                                        columns.find(
                                          (item) =>
                                            item.id === event.target.value,
                                        )?.cards.length ?? 0,
                                    })
                                  }
                                >
                                  {columns.map((target) => (
                                    <option
                                      key={target.id}
                                      value={target.id}
                                      disabled={target.disabled}
                                    >
                                      {target.title}
                                    </option>
                                  ))}
                                </select>
                              </div>
                            </article>
                          )}
                        </Draggable>
                      ))}
                      {provided.placeholder}
                      {!column.cards.length && (
                        <p className="rounded-lg border border-dashed p-5 text-center text-xs text-muted-foreground">
                          {readOnly || column.disabled
                            ? "No cards in this column"
                            : "Drop a card here"}
                        </p>
                      )}
                    </div>
                  )}
                </Droppable>
              </div>
            ))}
          </div>
        </DragDropContext>
      )}
      <p role="status" className="text-xs text-muted-foreground">
        {announcement ||
          `${cardIds.length} cards across ${columns.length} columns.`}
      </p>
    </section>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

Kanban props
PropType / DefaultDescription
labelstring

Default: required

Visible board title and accessible name.
columnsKanbanColumn<T>[]

Default: required

Unique column id/title/cards with optional disabled state. Each card needs a globally unique id and title.
onColumnsChange(nextColumns, move) => void

Default: read-only

Parent commits the proposed immutable arrangement and may persist it.
renderCard(card, column) => ReactNode

Default: title / description

Extend card content without reimplementing movement.
disabled / classNameboolean / string

Default: false / —

Make the whole board read-only or extend its container.
moveKanbanCardpure helper

Default: —

Calculate an immutable arrangement for valid moves; no network writes.

Build with these too