Components / Data
Kanban
Lanes side by side, each scrolling its own cards. The layout only — no drag, no state.
Mosclub · 128K
Mencare · 64K
Mosclub · 212K
Mencare · 89K
import { useState } from "react";
import {
Kanban,
KanbanCard,
KanbanDnd,
KanbanLane,
KanbanLaneCards,
KanbanLaneCount,
KanbanLaneHeader,
KanbanLaneTitle,
type KanbanMove,
} from "@/components/ui/hiip-ui/kanban";
type KanbanDemoCard = {
id: string;
name: string;
meta: string;
/** Set and the card cannot be dragged at all — the reason is shown on it. */
lockReason?: string;
/** Lanes this card may not be dropped into, each with the reason. */
lockedLanes?: Record<string, string>;
};
const KANBAN_LANES = [
{
id: "new",
label: "New",
// A STATIC class per lane, never one built from the id — see KanbanLane.
tint: "bg-muted/60 dark:bg-muted/30",
},
{
id: "contacted",
label: "Contacted",
tint: "bg-sky-50 dark:bg-sky-950/25",
},
{
id: "accepted",
label: "Accepted",
tint: "bg-emerald-50 dark:bg-emerald-950/25",
},
] as const;
function KanbanDemo() {
const [lanes, setLanes] = useState<Record<string, KanbanDemoCard[]>>({
new: [
{ id: "k1", name: "anhthu0349", meta: "Mosclub · 128K" },
{
id: "k2",
name: "linhchi.beauty",
meta: "Mencare · 64K",
// Nobody has replied yet, so a deal cannot be accepted for this lead.
lockedLanes: { accepted: "No reply yet — cannot accept" },
},
],
contacted: [{ id: "k3", name: "phucnguyen", meta: "Mosclub · 212K" }],
accepted: [
{
id: "k4",
name: "trangdao",
meta: "Mencare · 89K",
lockReason: "Deal signed — stage is closed",
},
],
});
// The board reports where the card went; moving it is the caller's job.
//
// ⚠️ REMOVE BY ID, THEN INSERT — not a splice at `fromIndex`. That is what
// makes ONE reducer safe for both callbacks: `onLaneChange` fires over and
// over while the pointer is down, and re-applying a move that already
// happened has to be a no-op.
function applyMove({ cardId, toLaneId, toIndex }: KanbanMove) {
setLanes((prev) => {
const card = Object.values(prev)
.flat()
.find((item) => item.id === cardId);
if (!card) return prev;
const next: Record<string, KanbanDemoCard[]> = {};
for (const [laneId, items] of Object.entries(prev)) {
next[laneId] = items.filter((item) => item.id !== cardId);
}
const to = [...(next[toLaneId] ?? [])];
to.splice(Math.min(toIndex, to.length), 0, card);
next[toLaneId] = to;
return next;
});
}
const byId = new Map(
Object.values(lanes)
.flat()
.map((card) => [card.id, card] as const),
);
function card(item: KanbanDemoCard) {
return (
<>
<span className="block truncate text-sm font-medium">{item.name}</span>
<p className="mt-0.5 text-xs text-muted-foreground">{item.meta}</p>
</>
);
}
return (
// The height is the board's to receive, not to invent — see `Kanban`.
<div className="flex h-96 w-full flex-col">
<KanbanDnd
onMove={applyMove}
// Wired up, so the destination lane opens a gap while you are still
// dragging instead of only accepting the card on release.
onLaneChange={applyMove}
renderOverlay={(cardId) => {
const item = byId.get(cardId);
return item ? <KanbanCard>{card(item)}</KanbanCard> : null;
}}
>
<Kanban>
{KANBAN_LANES.map((lane) => {
const items = lanes[lane.id] ?? [];
return (
<KanbanLane key={lane.id} laneId={lane.id} className={lane.tint}>
<KanbanLaneHeader>
<KanbanLaneTitle>{lane.label}</KanbanLaneTitle>
<KanbanLaneCount>{items.length}</KanbanLaneCount>
</KanbanLaneHeader>
<KanbanLaneCards cardIds={items.map((item) => item.id)}>
{items.map((item) => (
<KanbanCard
key={item.id}
cardId={item.id}
locked={Boolean(item.lockReason)}
lockReason={item.lockReason}
lockedLanes={item.lockedLanes}
>
{card(item)}
</KanbanCard>
))}
</KanbanLaneCards>
</KanbanLane>
);
})}
</Kanban>
</KanbanDnd>
</div>
);
}Install — needs @hiip registered in components.json
bunx shadcn@latest add @hiip/kanbanOr by URL, with the token in the query
bunx shadcn@latest add "https://ui.hiip.ai/r/kanban.json?token=$HIIP_REGISTRY_TOKEN"Both need a token — set one up.
Anatomy
Import from components/ui/hiip-ui/kanban.
{/* omit KanbanDnd and the same markup is a static board */}
<KanbanDnd
onMove={applyMove} {/* on release — persist */}
onLaneChange={applyMove} {/* mid-drag — local only; this is what animates */}
renderOverlay={(cardId) => …}
>
<Kanban> {/* give it a height — see `min-h-0` */}
<KanbanLane laneId="contacted" className="bg-sky-50 dark:bg-sky-950/25">
<KanbanLaneHeader action={…}> {/* label, count, one control */}
<KanbanLaneTitle>Contacted</KanbanLaneTitle>
<KanbanLaneCount>59</KanbanLaneCount> {/* pinned to the far end */}
</KanbanLaneHeader>
<KanbanLaneCards cardIds={ids}> {/* the drop zone + the sort order */}
{/* the whole card is the grab area — add `handle` + a
`<KanbanCardHandle />` inside only if it holds its own controls */}
<KanbanCard
cardId="c1"
locked {/* cannot be picked up */}
lockReason="Deal signed — stage is closed"
{/* …or only barred from certain lanes. Both states draw the same
veil + reason, one on the card, one over the lane. */}
lockedLanes={{ accepted: "No reply yet — cannot accept" }}
render={<button type="button" />}
>
…
</KanbanCard>
<KanbanEmpty icon={…}>No creators</KanbanEmpty> {/* instead of cards */}
<KanbanLoadMore pending={…} onClick={…} /> {/* after the last one */}
</KanbanLaneCards>
</KanbanLane>
</Kanban>
</KanbanDnd>API Reference
Generated from the source. Props forwarded to the underlying element are not listed.
KanbanDnd
Wraps the board to make its cards draggable.
| Prop | Type | Default |
|---|---|---|
childrenrequired | ReactNode | — |
onMoverequiredThe drag ended here. | (move: KanbanMove) => void | — |
renderOverlayrequired | (cardId: string) => ReactNode | — |
onLaneChangeThe card crossed into another lane MID-DRAG. | ((move: KanbanMove) => void) | undefined | — |
Kanban
The horizontal scroller.
No props of its own.
KanbanLane
One lane.
| Prop | Type | Default |
|---|---|---|
laneId | string | undefined | — |
KanbanLaneHeader
The lane's fixed head: title, count, and one trailing control.
| Prop | Type | Default |
|---|---|---|
action | ReactNode | — |
KanbanLaneTitle
The name of the lane, as a pill that lifts off the tint.
| Prop | Type | Default |
|---|---|---|
icon | ReactNode | — |
KanbanLaneCount
How many cards the lane holds — take it from the count the server reports, not from the cards rendered, which is one page of them.
No props of its own.
KanbanLaneCards
The lane's scrolling card list, and its drop zone.
| Prop | Type | Default |
|---|---|---|
cardIds | string[] | undefined | — |
KanbanCard
One card.
| Prop | Type | Default |
|---|---|---|
cardId | string | undefined | — |
handleThe card is dragged by a handle, not by its whole surface. | boolean | undefined | false |
lockedThis card cannot be dragged at all. | boolean | undefined | — |
lockedLanesThe lanes this card is locked OUT of, each mapped to the reason: a deny list keyed by `laneId`. | Record<string, string> | undefined | — |
lockReasonThe sentence to state, and the card's `title` on hover. | string | undefined | — |
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}> | undefined | — |
selectedTicked. | boolean | undefined | — |
KanbanCardHandle
The grab area for a card marked `handle`.
No props of its own.
KanbanEmpty
The lane is empty — not loading, and not filtered down to nothing by accident.
| Prop | Type | Default |
|---|---|---|
icon | ReactNode | — |
KanbanLoadMore
The lane's next page, as the last thing in the list.
| Prop | Type | Default |
|---|---|---|
classNameCSS class applied to the element, or a function that returns a class based on the component's state. | string | ((state: ButtonState) => string | undefined) | undefined | — |
focusableWhenDisabledWhether the button should be focusable when disabled. | boolean | undefined | — |
nativeButtonWhether the component renders a native `<button>` element when replacing it via the `render` prop. | boolean | undefined | — |
pendingThe next page is in flight. | boolean | undefined | false |
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState> | undefined | — |
size | "default" | "sm" | "xs" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined | — |
styleStyle applied to the element, or a function that returns a style object based on the component's state. | CSSProperties | ((state: ButtonState) => CSSProperties | undefined) | undefined | — |
variant | "link" | "default" | "outline" | "secondary" | "ghost" | "destructive" | null | undefined | — |
- Type
registry:ui- Pulls in
- buttonshadcn installs these for you if they are missing.
- npm packages
@base-ui/react @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities- Files written
- components/ui/hiip-ui/kanban.tsx