hiip/ui
14 items

Components / Data

Kanban

Lanes side by side, each scrolling its own cards. The layout only — no drag, no state.

New2
anhthu0349

Mosclub · 128K

linhchi.beauty

Mencare · 64K

Contacted1
phucnguyen

Mosclub · 212K

Accepted1
trangdao

Mencare · 89K

Deal signed — stage is closed
demos.tsx
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/kanban

Or 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.

PropTypeDefault
childrenrequiredReactNode—
onMoverequired

The drag ended here.

(move: KanbanMove) => void—
renderOverlayrequired(cardId: string) => ReactNode—
onLaneChange

The card crossed into another lane MID-DRAG.

((move: KanbanMove) => void) | undefined—

Kanban

The horizontal scroller.

No props of its own.

KanbanLane

One lane.

PropTypeDefault
laneIdstring | undefined—

KanbanLaneHeader

The lane's fixed head: title, count, and one trailing control.

PropTypeDefault
actionReactNode—

KanbanLaneTitle

The name of the lane, as a pill that lifts off the tint.

PropTypeDefault
iconReactNode—

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.

PropTypeDefault
cardIdsstring[] | undefined—

KanbanCard

One card.

PropTypeDefault
cardIdstring | undefined—
handle

The card is dragged by a handle, not by its whole surface.

boolean | undefinedfalse
locked

This card cannot be dragged at all.

boolean | undefined—
lockedLanes

The lanes this card is locked OUT of, each mapped to the reason: a deny list keyed by `laneId`.

Record<string, string> | undefined—
lockReason

The sentence to state, and the card's `title` on hover.

string | undefined—
render

Allows 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—
selected

Ticked.

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.

PropTypeDefault
iconReactNode—

KanbanLoadMore

The lane's next page, as the last thing in the list.

PropTypeDefault
className

CSS class applied to the element, or a function that returns a class based on the component's state.

string | ((state: ButtonState) => string | undefined) | undefined—
focusableWhenDisabled

Whether the button should be focusable when disabled.

boolean | undefined—
nativeButton

Whether the component renders a native `<button>` element when replacing it via the `render` prop.

boolean | undefined—
pending

The next page is in flight.

boolean | undefinedfalse
render

Allows 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—
style

Style 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