← ui

kanban

ui

a board of lanes of cards, carried down a lane or across

npx shadcn add @ag/kanban
Already installed? Preview an update
npx shadcn add @ag/kanban --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-04
  • New: a board of lanes of cards, swift-utils' InkKanban on the web, on carry: a card carried down its lane or into another, the board and the lane both scrolling under it, the destination lane lit as a whole; each lane heads with its hue, name and count, out of its limit when it has one, and a full lane refuses a card from another (its count red and leaning, the card flying home); allows answers true or the reason a move is refused (a full lane says it is full), said by the carried card in the bad tone; onAdd puts an add row after a lane's cards. Keys: space, ↑ ↓ in a lane, ← → across lanes, space or return, esc. Controlled: onMove({ card, from, to, index }).
  • Its cards are cards with item-card's parts, no longer a tinted fill and a coloured edge: renderCard writes the parts (its kind, its title, a foot with who has it and its counts), tint is the card's hue by the flare rule. material draws it plain (lanes of the fill ladder, the page's cards on them) or glass (each lane a pane of glass, its cards inset on it, one surface rather than glass on glass). detail opens a card whole: a press that does not travel, or return, grows it into a card of its own over the page; esc, a press outside or its ✕ put it back. A lane taller than its room fades where it runs on instead of cutting a card.
Updated 2026-11-04 · release notes

shelf controls

uses carry, drag-list, item-card, card, material, scrims

the demo above, verbatim

kanban.demo.tsx
"use client"

import { ArrowUp } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Backdrop } from "@/registry/base-nova/ui/backdrop"
import { CardFooter } from "@/registry/base-nova/ui/card"
import {
  CardAuthor,
  CardCount,
  CardKind,
} from "@/registry/base-nova/ui/item-card"
import { Kanban, type KanbanMove } from "@/registry/base-nova/ui/kanban"
import {
  type Material,
  MaterialProvider,
} from "@/registry/base-nova/ui/material"
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"

// #region board
// A release board. "doing" holds three at most: full, it refuses a card from another
// lane. A card in review goes on to done or back to doing, never to the backlog. Each
// card wears its kind's hue by the flare rule and says who has it; a card pressed
// without moving opens whole.
type Ticket = {
  id: string
  title: string
  kind: "bug" | "feat" | "chore"
  number: string
  who: string
  face?: string
  votes: number
}
type Lane = {
  id: string
  name: string
  hue: string
  limit?: number
  cards: Ticket[]
}

const KIND: Record<Ticket["kind"], string> = {
  bug: "oklch(64% 0.19 27)",
  feat: "oklch(68% 0.13 250)",
  chore: "oklch(72% 0.14 155)",
}

const ADRIAN = { who: "Adrian Galilea", face: "/adriangalilea.jpg" }
const WALTER = { who: "Walter Cronkite", face: "/people/cronkite.jpg" }
const ADA = { who: "Ada Lovelace" }

const LANES: Lane[] = [
  {
    id: "backlog",
    name: "backlog",
    hue: "oklch(70% 0.02 250)",
    cards: [
      {
        id: "c1",
        title: "dark mode for the settings page",
        kind: "feat",
        number: "#12",
        votes: 9,
        ...ADA,
      },
      {
        id: "c2",
        title: "crash on an empty playlist",
        kind: "bug",
        number: "#17",
        votes: 4,
        ...WALTER,
      },
      {
        id: "c3",
        title: "update the icon set",
        kind: "chore",
        number: "#19",
        votes: 1,
        ...ADRIAN,
      },
      {
        id: "c4",
        title: "share a clip to messages",
        kind: "feat",
        number: "#23",
        votes: 6,
        ...ADA,
      },
    ],
  },
  {
    id: "doing",
    name: "doing",
    hue: "oklch(72% 0.15 75)",
    limit: 3,
    cards: [
      {
        id: "c6",
        title: "offline downloads",
        kind: "feat",
        number: "#21",
        votes: 12,
        ...ADRIAN,
      },
      {
        id: "c7",
        title: "subtitles drift after a seek",
        kind: "bug",
        number: "#34",
        votes: 7,
        ...WALTER,
      },
      {
        id: "c8",
        title: "move the build to the new runner",
        kind: "chore",
        number: "#40",
        votes: 3,
        ...ADA,
      },
    ],
  },
  {
    id: "review",
    name: "review",
    hue: "oklch(68% 0.13 250)",
    cards: [
      {
        id: "c9",
        title: "picture in picture",
        kind: "feat",
        number: "#28",
        votes: 5,
        ...ADRIAN,
      },
    ],
  },
  { id: "done", name: "done", hue: "oklch(70% 0.15 155)", cards: [] },
]

/** The consumer's own move: the board hands it over, the consumer writes it. */
function moved(lanes: Lane[], m: KanbanMove<Lane, Ticket>): Lane[] {
  const taken = lanes.map((l) =>
    l.id === m.from.id
      ? { ...l, cards: l.cards.filter((c) => c.id !== m.card.id) }
      : l,
  )
  return taken.map((l) =>
    l.id === m.to.id
      ? {
          ...l,
          cards: [
            ...l.cards.slice(0, m.index),
            m.card,
            ...l.cards.slice(m.index),
          ],
        }
      : l,
  )
}

let made = 0

function Board({ material }: { material: Material }) {
  const [lanes, setLanes] = React.useState(LANES)
  const [voted, setVoted] = React.useState<ReadonlySet<string>>(new Set())
  const vote = (id: string) =>
    setVoted((v) => {
      const next = new Set(v)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })
  // Opened whole the card's title is the view's heading, so the parts there say the
  // rest: its kind, its foot.
  const parts = (c: Ticket, opened = false) => (
    <>
      <CardKind number={c.number}>{c.kind}</CardKind>
      {!opened && <p className="font-semibold text-sm">{c.title}</p>}
      <CardFooter>
        <CardAuthor name={c.who} src={c.face} />
        <CardCount
          icon={ArrowUp}
          count={c.votes + (voted.has(c.id) ? 1 : 0)}
          on={voted.has(c.id)}
          said="votes"
          onClick={() => vote(c.id)}
          className="ml-auto"
        />
      </CardFooter>
    </>
  )
  return (
    <Kanban
      label="release board"
      material={material}
      className="h-[30rem]"
      lanes={lanes}
      laneId={(l) => l.id}
      cards={(l) => l.cards}
      title={(l) => l.name}
      hue={(l) => l.hue}
      limit={(l) => l.limit}
      cardId={(c) => c.id}
      name={(c) => c.title}
      tint={(c) => KIND[c.kind]}
      allows={({ from, to }) =>
        from.id === "review" && to.id === "backlog"
          ? "review never goes back to the backlog"
          : true
      }
      onMove={(m) => setLanes((all) => moved(all, m))}
      onAdd={(l) =>
        setLanes((all) =>
          all.map((x) =>
            x.id === l.id
              ? {
                  ...x,
                  cards: [
                    ...x.cards,
                    {
                      id: `n${++made}`,
                      title: "a new card",
                      kind: "chore",
                      number: `#${50 + made}`,
                      votes: 0,
                      ...ADA,
                    },
                  ],
                }
              : x,
          ),
        )
      }
      onDelete={(c) =>
        setLanes((all) =>
          all.map((x) => ({
            ...x,
            cards: x.cards.filter((y) => y.id !== c.id),
          })),
        )
      }
      renderCard={(c) => parts(c)}
      detail={(c) => parts(c, true)}
    />
  )
}

/** Plain on the page, or glass over one of two pictures, each with light and dark
 *  in it: a valley of dark trees under a pale cliff, a snowfield under a blue sky. */
const LOOKS = {
  plain: null,
  valley: "/lightbox/card.jpg",
  snow: "/lightbox/tall.jpg",
} as const
type Look = keyof typeof LOOKS

function Looks() {
  const [look, setLook] = React.useState<Look>("plain")
  const picture = LOOKS[look]
  return (
    <div className="w-full space-y-4">
      <ToggleGroup
        size="sm"
        aria-label="material"
        value={look}
        onValueChange={(v) => setLook(v as Look)}
      >
        <ToggleGroupItem value="plain">plain</ToggleGroupItem>
        <ToggleGroupItem value="valley">glass, valley</ToggleGroupItem>
        <ToggleGroupItem value="snow">glass, snow</ToggleGroupItem>
      </ToggleGroup>
      {picture === null ? (
        <Board material="plain" />
      ) : (
        <MaterialProvider material="glass">
          <Backdrop src={picture} className="w-full rounded-3xl">
            <Board material="glass" />
          </Backdrop>
        </MaterialProvider>
      )}
    </div>
  )
}
// #endregion

export default function KanbanDemo() {
  return (
    <Sample
      name="board"
      glance="large"
      title="a release board"
      description="Carry a card down its lane or into another: the lane it would land in lights up. Doing holds three: full, it refuses a card from another lane, its count turns red and leans. Review never goes back to the backlog. A card pressed without moving, or Enter, opens whole. Plain or glass. By keys: Tab in, space takes a card up, the arrows move it, ← → across lanes, space puts it down, esc puts it back."
      with="board"
    >
      <Looks />
    </Sample>
  )
}

the source, every file an install writes

kanban.tsx
"use client"

// A BOARD OF LANES OF CARDS, swift-utils' InkKanban on the web: a card is carried
// down its lane or into another, the board scrolling sideways under it near its left
// and right edges and the lane under it scrolling near its top and bottom (both at
// once in a corner). The lane it would land in is lit as a whole (a step more wash,
// its edge a step brighter, nothing moved), so the destination reads from across the
// board. Each lane heads with its hue, its name and its count, out of its limit when
// it has one (orange once full); a full lane refuses a card from another ("not now":
// under the card it takes a faint wash of red, its count turns red and leans, no gap
// opens anywhere, and a card let go there flies home), and `allows` refuses any move
// the consumer's rules forbid the same way. An empty lane still takes a card. `onAdd`
// puts an add row after each lane's last card, which rises and falls with the cards.
// Keys: space takes the focused card up, ↑ ↓ move it in its lane, ← → to the next
// lane, space or return puts it down, esc puts it back; ← → ↑ ↓ walk the cards
// otherwise. A lane is about thirty characters wide (22 em of the type), never more
// than 86% of the board, so a phone shows one lane and the edge of the next.
//
// CONTROLLED: `onMove` hands the move over and the consumer writes its lanes.

import { Plus } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { CARRY } from "@/registry/base-nova/lib/carry-core"
import { Card } from "@/registry/base-nova/ui/card"
import {
  type Carry,
  CarryLayer,
  refuse,
  useCarry,
} from "@/registry/base-nova/ui/carry"
import { HANDLE } from "@/registry/base-nova/ui/drag-list"
import { CardView, useCardView } from "@/registry/base-nova/ui/item-card"
import { type Material, useMaterial } from "@/registry/base-nova/ui/material"
import "./edge-fade"

/** A move asked of `allows` or made: the card, the lanes it leaves and enters, and
 *  its index there once it lands. */
export type KanbanMove<L, C> = { card: C; from: L; to: L; index: number }

/** A lane's light while a card is carried: the lane it would land in a step more
 *  wash and a brighter edge; a lane that refuses it a faint wash of the bad tone. */
const LIGHT = {
  none: "opacity-0",
  receiving: "bg-fill-row-hover ring-foreground/14",
  refusing: "bg-tone-bad/5 ring-tone-bad/28",
} as const

export function Kanban<L, C>({
  lanes,
  laneId,
  cards,
  title,
  hue,
  limit,
  cardId,
  name,
  renderCard,
  onMove,
  allows,
  onAdd,
  tint,
  detail,
  onOpen,
  onDelete,
  material,
  label,
  className,
}: {
  lanes: readonly L[]
  laneId: (lane: L) => string
  cards: (lane: L) => readonly C[]
  title: (lane: L) => string
  /** A lane's hue, a CSS colour: the dot before its name. */
  hue?: (lane: L) => string | undefined
  /** How many cards a lane holds at most: full, it refuses a card from another. */
  limit?: (lane: L) => number | undefined
  cardId: (card: C) => string
  /** What a card is called: said as it moves, and its title opened whole. */
  name: (card: C) => string
  /** What a card holds: its parts (`CardKind`, `CardFooter`, …). */
  renderCard: (card: C) => React.ReactNode
  /** What a card says opened whole: a press that never moved, or return, opens it. */
  detail?: (card: C) => React.ReactNode
  onMove: (move: KanbanMove<L, C>) => void
  /** Whether a move is allowed: `true`, or the reason it is not ("review never goes
   *  back to the backlog"), which the carried card says while over that place, in
   *  the bad tone, and leans home with when let go there. A refusal always says why. */
  allows?: (move: KanbanMove<L, C>) => true | string
  /** An add row after each lane's last card. */
  onAdd?: (lane: L) => void
  /** A card's hue, a CSS colour (its kind's), worn by the flare rule. */
  tint?: (card: C) => string | undefined
  /** Without `detail`: where a press that never moved, or return, goes. */
  onOpen?: (card: C) => void
  onDelete?: (card: C) => void
  /** Plain, lanes on the fill ladder and the page's cards; glass, lanes of glass
   *  with their cards inset on them (no glass on glass). */
  material?: Material
  /** The board's accessible name. */
  label: string
  className?: string
}) {
  const ids = React.useMemo(
    () => lanes.map((lane) => cards(lane).map(cardId)),
    [lanes, cards, cardId],
  )
  const byId = React.useMemo(() => {
    const m = new Map<string, C>()
    for (const lane of lanes) for (const c of cards(lane)) m.set(cardId(c), c)
    return m
  }, [lanes, cards, cardId])
  const of = (id: string) => byId.get(id) as C
  const lane = (i: number) => lanes[i] as L
  const m = useMaterial(material)
  const view = useCardView<string>()
  const moveOf = (id: string, from: number, to: number, index: number) => ({
    card: of(id),
    from: lane(from),
    to: lane(to),
    index,
  })
  const carry = useCarry<string>({
    lanes: ids,
    refuses: (id, from, to) => {
      const into = lane(to.lane)
      const most = limit?.(into)
      if (
        to.lane !== from.lane &&
        most !== undefined &&
        cards(into).length >= most
      )
        return `${title(into)} is full: ${most} at most`
      if (!allows) return false
      const answer = allows(moveOf(id, from.lane, to.lane, to.index))
      if (answer !== true && (typeof answer !== "string" || answer === ""))
        throw new Error(
          `Kanban: allows answers true or the reason a move is refused, got ${JSON.stringify(answer)}`,
        )
      return answer === true ? false : answer
    },
    onMove: (id, from, to) => onMove(moveOf(id, from.lane, to.lane, to.index)),
    name: (id) => name(of(id)),
    laneName: (i) => title(lane(i)),
    onOpen: detail ? (id) => view.show(id) : onOpen && ((id) => onOpen(of(id))),
    onDelete: onDelete && ((id) => onDelete(of(id))),
  })
  const opened = view.opened === null ? undefined : byId.get(view.opened)
  return (
    <section
      ref={carry.across}
      aria-label={label}
      data-slot="kanban"
      // edge: a board's sideways scroll ends at its lanes' own padding; a fade would
      // fade the first and last lane's rims, which are the board's edge.
      className={cn("@container relative overflow-x-auto", className)}
    >
      <div className="flex h-full w-max items-start gap-5 p-5">
        {lanes.map((l, i) => (
          <KanbanLane
            key={laneId(l)}
            carry={carry}
            index={i}
            title={title(l)}
            hue={hue?.(l)}
            limit={limit?.(l)}
            cards={cards(l)}
            cardId={cardId}
            renderCard={renderCard}
            tint={tint}
            material={m}
            source={view.source}
            onAdd={onAdd && (() => onAdd(l))}
          />
        ))}
      </div>
      <CarryLayer
        carry={carry}
        className="rounded-[0.75rem]"
        render={(id) => {
          const c = of(id)
          return (
            <Card
              hue={tint?.(c)}
              padding="compact"
              interactive
              material={m}
              lifted
            >
              {renderCard(c)}
            </Card>
          )
        }}
      />
      {detail && opened !== undefined && (
        <CardView
          view={view}
          title={name(opened)}
          hue={tint?.(opened)}
          material={m}
        >
          {detail(opened)}
        </CardView>
      )}
    </section>
  )
}

function KanbanLane<C>({
  carry,
  index,
  title,
  hue,
  limit,
  cards,
  cardId,
  renderCard,
  tint,
  material,
  source,
  onAdd,
}: {
  carry: Carry<string>
  index: number
  title: string
  hue: string | undefined
  limit: number | undefined
  cards: readonly C[]
  cardId: (card: C) => string
  renderCard: (card: C) => React.ReactNode
  tint?: (card: C) => string | undefined
  material: Material
  /** What a card wears in its place to be opened whole from it. */
  source: (id: string) => React.CSSProperties | undefined
  onAdd?: () => void
}) {
  const light = carry.light(index)
  const count = React.useRef<HTMLSpanElement>(null)
  const { refusals, refusedLane } = carry.state
  // The count leans as a card it refuses arrives over the lane, and again if the
  // card is let go there.
  React.useEffect(() => {
    if (light === "refusing") refuse(count.current, 1, 0, CARRY.leanCount)
  }, [light])
  React.useEffect(() => {
    if (refusals > 0 && refusedLane === index)
      refuse(count.current, 1, 0, CARRY.leanCount)
  }, [refusals, refusedLane, index])
  const full = limit !== undefined && cards.length >= limit
  const said =
    limit !== undefined ? `${cards.length}/${limit}` : `${cards.length}`
  const body = (
    <>
      <header className="flex h-10 shrink-0 items-center gap-2 px-4 pt-1">
        {hue && (
          <span
            aria-hidden="true"
            className="size-[0.6em] shrink-0 rounded-full"
            style={{ background: hue }}
          />
        )}
        <h3 className="min-w-0 flex-1 truncate font-semibold">{title}</h3>
        <span className="sr-only">
          {limit !== undefined
            ? `${cards.length} of ${limit}`
            : `${cards.length} cards`}
        </span>
        <span
          ref={count}
          aria-hidden="true"
          className={cn(
            // Two characters or fewer in a circle, never an oval; past that a capsule.
            "grid h-[1.6em] min-w-[1.6em] place-items-center rounded-full bg-fill-rest text-[12px] font-medium tabular-nums transition-colors",
            said.length > 2 && "px-[0.5em]",
            // A tone's words at the lightness that reads on the face, a step past
            // the flare rule's (material-glass FLARE_INK) since the count's own fill
            // lifts its ground: an amber 3/3 on light glass read 1.2:1, the tone
            // itself on dark glass 3.7:1.
            light === "refusing"
              ? "bg-tone-bad/18 text-[oklch(from_var(--tone-bad)_min(l,0.4)_c_h)] scheme-dark:text-[oklch(from_var(--tone-bad)_max(l,0.9)_c_h)]"
              : full
                ? "text-[oklch(from_var(--tone-warn)_min(l,0.4)_c_h)] scheme-dark:text-[oklch(from_var(--tone-warn)_max(l,0.9)_c_h)]"
                : "text-current/65",
          )}
        >
          {said}
        </span>
      </header>
      <div
        ref={carry.laneScroller(index)}
        // A lane taller than its room fades where it runs on, never cutting a card;
        // at rest at either end that end is whole.
        className="ag-edge-fade-y min-h-0 flex-1"
      >
        <ul
          ref={carry.lane(index)}
          aria-label={title}
          className="flex flex-col gap-2 px-2 pt-0.5 pb-2"
        >
          {cards.map((c) => {
            const id = cardId(c)
            return (
              <li key={id} {...carry.item(id)} className={HANDLE}>
                <Card
                  hue={tint?.(c)}
                  padding="compact"
                  interactive
                  material={material}
                  style={source(id)}
                >
                  {renderCard(c)}
                </Card>
              </li>
            )
          })}
        </ul>
        {/* An empty lane says so, and falls quiet while a card is coming into it:
            where it would land is the open room, never a drawn place. */}
        {cards.length === 0 && !onAdd && (
          <p
            className={cn(
              "-mt-2 pb-3 text-center text-current/65 transition-opacity duration-300",
              light === "receiving" && "opacity-0",
            )}
          >
            no cards
          </p>
        )}
        {/* The room the lane gains as a card from another would land in it. */}
        <div ref={carry.room(index)} aria-hidden="true" />
        {onAdd && (
          <div ref={carry.foot(index)} className="px-2 pb-2">
            <button
              type="button"
              onClick={onAdd}
              className="flex h-8 w-full cursor-pointer items-center gap-1.5 rounded-lg px-2.5 text-current/65 hover:bg-fill-rest hover:text-current focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
            >
              <Plus className="size-3.5" />
              add a card
            </button>
          </div>
        )}
      </div>
      {/* The lane's light: drawn over it, never moving it. */}
      <div
        aria-hidden="true"
        className={cn(
          "pointer-events-none absolute inset-0 z-20 rounded-[inherit] ring-1 ring-transparent ring-inset transition-[opacity,background-color,box-shadow] duration-300",
          LIGHT[light],
        )}
      />
    </>
  )
  const lane =
    "relative flex max-h-full w-[min(22em,max(86cqw,200px))] shrink-0 flex-col text-[13px]"
  // Glass: the lane is a pane of glass and its cards stand on it inset; plain, a
  // ground of the fill ladder under the page's cards.
  return material === "glass" ? (
    <Card
      aria-label={title}
      data-slot="kanban-lane"
      material="glass"
      radius="1rem"
      className={lane}
      bodyClassName="gap-0 p-0"
    >
      {body}
    </Card>
  ) : (
    <section
      aria-label={title}
      data-slot="kanban-lane"
      className={cn(lane, "rounded-xl bg-fill-group")}
    >
      {body}
    </section>
  )
}