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.
npx shadcn@latest add @paragon/kanban-column"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>
);
}