a board of lanes of cards, carried down a lane or across
npx shadcn add @ag/kanbanAlready installed? Preview an update
npx shadcn add @ag/kanban --dry-runYour installed source stays unchanged until you update it. How updates work
- 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);allowsanswerstrueor the reason a move is refused (a full lane says it is full), said by the carried card in the bad tone;onAddputs 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:renderCardwrites the parts (its kind, its title, a foot with who has it and its counts),tintis the card's hue by the flare rule.materialdraws 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).detailopens 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.
the demo above, verbatim
"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
"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>
)
}