Data Display

Kanban Column

A kanban column with drag-to-reorder cards that lift under a shadow overlay, a digit-rolling count badge, keyboard reordering, and an add-card composer that expands from a ghost row.

Install

npx shadcn@latest add @paragon/kanban-column

kanban-column.tsx

"use client";

import * as React from "react";
import {
  AnimatePresence,
  motion,
  Reorder,
  useReducedMotion,
} from "motion/react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";

/**
 * Per-digit rolling counter: when the count changes, each changed digit
 * exits upward while the new one rises in — the odometer read without the
 * odometer weight. Digits are keyed by place value so "9 → 10" animates
 * cleanly. Reduced motion crossfades instead of rolling.
 */
function RollingCount({ value }: { value: number }) {
  const reducedMotion = useReducedMotion();
  const digits = String(value).split("");

  return (
    <span
      data-slot="kanban-count"
      aria-label={`${value} cards`}
      className="inline-flex h-4 items-center overflow-hidden text-xs font-medium text-muted-foreground tabular-nums"
    >
      {digits.map((digit, index) => (
        <span
          aria-hidden
          key={digits.length - index}
          className="relative inline-flex justify-center"
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={digit}
              initial={
                reducedMotion ? { opacity: 0 } : { y: "70%", opacity: 0 }
              }
              animate={reducedMotion ? { opacity: 1 } : { y: 0, opacity: 1 }}
              exit={
                reducedMotion ? { opacity: 0 } : { y: "-70%", opacity: 0 }
              }
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
              className="inline-block"
            >
              {digit}
            </motion.span>
          </AnimatePresence>
        </span>
      ))}
    </span>
  );
}

interface KanbanListContextValue {
  values: unknown[];
  onReorder: (values: unknown[]) => void;
}

const KanbanListContext = React.createContext<KanbanListContextValue | null>(
  null,
);

export interface KanbanColumnProps extends React.ComponentProps<"div"> {
  /** Column header label. */
  title: string;
  /** Card count in the header badge — animates with a digit roll. */
  count?: number;
}

/**
 * A kanban column shell: muted well, header with title + rolling count,
 * and a body slot for `KanbanCardList` and `KanbanComposer`.
 */
export function KanbanColumn({
  title,
  count,
  className,
  children,
  ...props
}: KanbanColumnProps) {
  return (
    <div
      data-slot="kanban-column"
      className={cn(
        "flex w-full flex-col rounded-xl bg-secondary/50 p-2 dark:bg-secondary/25",
        className,
      )}
      {...props}
    >
      <div className="flex items-center gap-2 px-2 pt-1 pb-2.5">
        <h3 className="text-sm font-medium">{title}</h3>
        {count !== undefined ? <RollingCount value={count} /> : null}
      </div>
      {children}
    </div>
  );
}

export interface KanbanCardListProps<T>
  extends Omit<
    React.ComponentProps<"ul">,
    | "onChange"
    | "onDrag"
    | "onDragStart"
    | "onDragEnd"
    | "onAnimationStart"
    | "onAnimationEnd"
  > {
  /** Ordered card values — pass the same values to each KanbanCard. */
  values: T[];
  /** Receives the new order after a drag or keyboard move. */
  onReorder: (values: T[]) => void;
}

/**
 * The reorderable card container (single-column, vertical axis). Wraps
 * motion's Reorder.Group and provides move context so cards can also be
 * reordered from the keyboard.
 */
export function KanbanCardList<T>({
  values,
  onReorder,
  className,
  children,
  ...props
}: KanbanCardListProps<T>) {
  const context = React.useMemo(
    () => ({
      values: values as unknown[],
      onReorder: onReorder as (values: unknown[]) => void,
    }),
    [values, onReorder],
  );

  return (
    <KanbanListContext.Provider value={context}>
      <Reorder.Group
        axis="y"
        values={values}
        onReorder={onReorder}
        data-slot="kanban-card-list"
        className={cn("flex flex-col gap-2", className)}
        {...props}
      >
        {children}
      </Reorder.Group>
    </KanbanListContext.Provider>
  );
}

export interface KanbanCardProps
  extends Omit<
    React.ComponentProps<typeof Reorder.Item>,
    "value" | "children"
  > {
  /** The value this card represents inside the list's `values`. */
  value: unknown;
  children?: React.ReactNode;
  /** Disables the drag-lift scale (drag still works). */
  static?: boolean;
}

/**
 * A draggable kanban card. While lifted it scales to 1.02 under
 * `--shadow-overlay`; at rest it sits on `shadow-border`. Focus the card
 * and press Alt+ArrowUp / Alt+ArrowDown to reorder without a pointer.
 */
export function KanbanCard({
  value,
  static: isStatic = false,
  className,
  children,
  ...props
}: KanbanCardProps) {
  const reducedMotion = useReducedMotion();
  const list = React.useContext(KanbanListContext);

  const move = (direction: -1 | 1) => {
    if (!list) return;
    const index = list.values.indexOf(value);
    const target = index + direction;
    if (index === -1 || target < 0 || target >= list.values.length) return;
    const next = [...list.values];
    next.splice(index, 1);
    next.splice(target, 0, value);
    list.onReorder(next);
  };

  return (
    <Reorder.Item
      value={value}
      data-slot="kanban-card"
      layout="position"
      tabIndex={0}
      whileDrag={
        isStatic || reducedMotion
          ? { boxShadow: "var(--shadow-overlay)" }
          : { scale: 1.02, boxShadow: "var(--shadow-overlay)" }
      }
      transition={
        reducedMotion
          ? { layout: { duration: 0 } }
          : { type: "spring", duration: 0.3, bounce: 0 }
      }
      onKeyDown={(event: React.KeyboardEvent) => {
        if (event.altKey && event.key === "ArrowUp") {
          event.preventDefault();
          move(-1);
        }
        if (event.altKey && event.key === "ArrowDown") {
          event.preventDefault();
          move(1);
        }
      }}
      className={cn(
        "relative cursor-grab touch-none rounded-lg bg-card p-3 shadow-border select-none active:cursor-grabbing",
        className,
      )}
      {...props}
    >
      {children}
      <span className="sr-only">
        Press Alt plus arrow up or arrow down to move this card
      </span>
    </Reorder.Item>
  );
}

export interface KanbanComposerProps
  extends Omit<React.ComponentProps<"div">, "onSubmit"> {
  /** Receives the trimmed card title on submit. */
  onSubmit?: (title: string) => void;
  placeholder?: string;
}

/**
 * The "add card" composer. Sits as a ghost row until clicked, then the
 * input area expands via the grid-rows trick, focuses, and submits on
 * Enter (Escape collapses).
 */
export function KanbanComposer({
  onSubmit,
  placeholder = "Card title",
  className,
  ...props
}: KanbanComposerProps) {
  const [open, setOpen] = React.useState(false);
  const [draft, setDraft] = React.useState("");
  const inputRef = React.useRef<HTMLInputElement>(null);

  const submit = () => {
    const title = draft.trim();
    if (title) onSubmit?.(title);
    setDraft("");
    inputRef.current?.focus();
  };

  const close = () => {
    setDraft("");
    setOpen(false);
  };

  return (
    <div data-slot="kanban-composer" className={cn("mt-2", className)} {...props}>
      <div
        className={cn(
          "grid transition-[grid-template-rows] motion-reduce:transition-none",
          open
            ? "grid-rows-[1fr] duration-(--duration-base) ease-out"
            : "grid-rows-[0fr] duration-(--duration-quick) ease-(--ease-exit)",
        )}
      >
        <div
          inert={!open}
          className={cn(
            "min-h-0 overflow-hidden",
            open
              ? "opacity-100 transition-opacity duration-(--duration-base) ease-out"
              : "opacity-0 transition-opacity duration-(--duration-quick) ease-(--ease-exit)",
          )}
        >
          <div className="rounded-lg bg-card p-2 shadow-border">
            <input
              ref={inputRef}
              value={draft}
              onChange={(event) => setDraft(event.target.value)}
              onKeyDown={(event) => {
                if (event.key === "Enter") {
                  event.preventDefault();
                  submit();
                }
                if (event.key === "Escape") {
                  event.preventDefault();
                  close();
                }
              }}
              placeholder={placeholder}
              aria-label="New card title"
              className="w-full rounded-md bg-transparent px-1.5 py-1 text-sm outline-none placeholder:text-muted-foreground"
            />
            <div className="mt-1.5 flex gap-1.5">
              <button
                type="button"
                onClick={submit}
                className="pressable rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90"
              >
                Add card
              </button>
              <button
                type="button"
                onClick={close}
                className="pressable rounded-md px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground"
              >
                Cancel
              </button>
            </div>
          </div>
        </div>
      </div>
      {!open ? (
        <button
          type="button"
          onClick={() => {
            setOpen(true);
            requestAnimationFrame(() => inputRef.current?.focus());
          }}
          className="pressable flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent/60 hover:text-foreground"
        >
          <Plus className="size-4" aria-hidden />
          Add card
        </button>
      ) : null}
    </div>
  );
}