React kanban board
Kanban boards with limits per column, cards you move by mouse, touch, keyboard or menu, swimlanes, a sprint with burndown, card details and filters.
Cards that move with any input
A kanban board shows work as cards that travel from column to column, and the whole team sees where things stand. This one moves cards by mouse, touch, keyboard and menu, so lifting a card with Space and moving it with the arrow keys works as well as dragging.
Board has columns with work in progress limits that warn, fold away and take new cards. Swimlanes adds one row per person or priority, moving a card into another row hands it over or changes its priority. Sprint puts dates, goal, days left and done points on top, with a burndown against the ideal line and points per column that follow every move. Card details opens a card next to the board with status, people, due date, labels, points, a checklist and comments, as a sheet on phones. Compact with filters packs long boards densely and filters by search, label and person with a live count.
Set limits on the columns where work piles up, often review, the warning then shows a jam before it hurts. Keep labels to a handful of colors. The board takes columns, cards, people and tags as props, so it shows your data from the first render.
Variants
Details
What Kanban board brings along, read from the registry and the source.
- Category
- Tasks and planning
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/kanban-board.tsx
- Brings along
- utilskitavatarbuttondropdown-menuiconline-chartmotionsheetsidebar
- npm packages
- None
- Languages
- English and German, 90 texts of its own
- Studio
- Adjust in code
- In collections
- SaaS app screens
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The KanbanBoardProps interface from components/ui/kanban-board.tsx, with the comments from the code.
variantoptionalKanbanBoardVariantboardswimlanessprintcard-detailcompact
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringcolumnsoptionalKanbanColumn[]cardsoptionalKanbanCard[]All cards, in order. The order inside a column is the order here.
tagsoptionalKanbanLabel[]Labels the cards refer to by id
peopleoptionalKanbanPerson[]meoptionalstringPerson id of the signed in user (comments)
todayoptionalstringYYYY-MM-DD that counts as today
lanesoptional'person' | 'priority'swimlanes: one row per person or per priority
sprintoptionalKanbanSprintsprint
selectedoptionalstringcard-detail: the card open at the start
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on move, add, update, comment and complete-sprint. Without it the block simulates the request.
Built-in texts
Kanban board writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 90 texts
| Key | English | German |
|---|---|---|
| newCard | English New card | German Neue Karte |
| addCard | English Add card | German Karte hinzufügen |
| addCardTo | English Add a card to {column} | German Karte zu {column} hinzufügen |
| cardTitle | English What needs doing? | German Was steht an? |
| add | English Add card | German Karte anlegen |
| cancel | English Cancel | German Abbrechen |
| added | English {card} added to {column}. | German {card} in {column} angelegt. |
| countOf | English {n} of {limit} | German {n} von {limit} |
| overLimit | English {n} over the limit | German {n} über dem Limit |
| atLimit | English At the limit | German Limit erreicht |
| collapse | English Collapse {column} | German {column} einklappen |
| expand | English Expand {column} | German {column} ausklappen |
| moveTo | English Move to | German Verschieben nach |
| moveUp | English Move up | German Nach oben |
| moveDown | English Move down | German Nach unten |
| openCard | English Open details | German Details öffnen |
| cardMenu | English Actions for {card} | German Aktionen für {card} |
| dragHint | English Press Space or Enter to pick up a card. The arrow keys move it, Space or Enter drops it, Escape puts it back. | German Leertaste oder Enter nimmt eine Karte auf. Die Pfeiltasten verschieben sie, Leertaste oder Enter legt sie ab, Escape legt sie zurück. |
| dragHintDetail | English Press Enter to open a card and Space to pick it up. The arrow keys move it, Space drops it, Escape puts it back. | German Enter öffnet eine Karte, die Leertaste nimmt sie auf. Die Pfeiltasten verschieben sie, die Leertaste legt sie ab, Escape legt sie zurück. |
| grabbed | English {card} picked up. {column}, position {pos} of {n}. | German {card} aufgenommen. {column}, Position {pos} von {n}. |
| movedKb | English {column}, position {pos} of {n}. | German {column}, Position {pos} von {n}. |
| dropped | English {card} dropped in {column}, position {pos} of {n}. | German {card} in {column} abgelegt, Position {pos} von {n}. |
| moved | English {card} moved to {column}. | German {card} nach {column} verschoben. |
| cancelled | English {card} is back in {column}. | German {card} liegt wieder in {column}. |
| overNote | English {column} is over its limit. | German {column} ist über dem Limit. |
| draggable | English draggable card | German verschiebbare Karte |
| overdue | English Overdue | German Überfällig |
| dueToday | English Today | German Heute |
| dueTomorrow | English Tomorrow | German Morgen |
| dueOn | English Due {date} | German Fällig {date} |
| points | English {n} points | German {n} Punkte |
| pts | English {n} pts | German {n} Pkt. |
| checklist | English {done} of {n} done | German {done} von {n} erledigt |
| comments | English {n} comments | German {n} Kommentare |
| assigned | English Assigned to {names} | German Zugewiesen an {names} |
| unassigned | English Not assigned | German Nicht zugewiesen |
| priority | English Priority {p} | German Priorität {p} |
| urgent | English Urgent | German Dringend |
| high | English High | German Hoch |
| medium | English Medium | German Mittel |
| low | English Low | German Niedrig |
| none | English No priority | German Ohne Priorität |
| columns | English Columns | German Spalten |
| dropHere | English Drop here | German Hier ablegen |
| empty | English Nothing here yet | German Noch leer |
| noMatch | English No card matches | German Keine Karte passt |
| undo | English Undo | German Rückgängig |
| lane | English {n} cards | German {n} Karten |
| collapseLane | English Collapse {lane} | German {lane} einklappen |
| expandLane | English Expand {lane} | German {lane} ausklappen |
| daysLeft | English {n} days left | German Noch {n} Tage |
| lastDay | English Last day | German Letzter Tag |
| burndown | English Burndown | German Burndown |
| ideal | English Ideal | German Ideal |
| remaining | English Open points | German Offene Punkte |
| pointsDone | English {done} of {total} points done | German {done} von {total} Punkten erledigt |
| completeSprint | English Complete sprint | German Sprint abschließen |
| completing | English Closing… | German Wird abgeschlossen… |
| completed | English Sprint closed. {n} open cards move to the next sprint. | German Sprint abgeschlossen. {n} offene Karten gehen in den nächsten Sprint. |
| goal | English Sprint goal | German Sprintziel |
| cardsDone | English Cards done | German Karten erledigt |
| velocity | English Points a day | German Punkte am Tag |
| pace | English Pace | German Tempo |
| onTrack | English On track | German Im Plan |
| behind | English {n} points behind | German {n} Punkte zurück |
| details | English Card details | German Kartendetails |
| title | English Title | German Titel |
| status | English Status | German Status |
| assignees | English People | German Personen |
| dueDate | English Due date | German Fällig am |
| labelsTitle | English Labels | German Labels |
| estimate | English Points | German Punkte |
| description | English Description | German Beschreibung |
| descPlaceholder | English Add the details the team needs | German Ergänze, was das Team wissen muss |
| checklistTitle | English Checklist | German Checkliste |
| addItem | English Add item | German Punkt hinzufügen |
| itemPlaceholder | English Add an item | German Punkt hinzufügen |
| removeItem | English Remove {item} | German {item} entfernen |
| commentsTitle | English Comments | German Kommentare |
| commentPlaceholder | English Write a comment | German Kommentar schreiben |
| post | English Post comment | German Kommentar senden |
| noCard | English Pick a card to see its details here. | German Wähle eine Karte, dann stehen hier ihre Details. |
| search | English Search cards | German Karten suchen |
| filterLabels | English Filter by label | German Nach Label filtern |
| filterPeople | English Filter by person | German Nach Person filtern |
| showing | English {n} of {total} cards | German {n} von {total} Karten |
| clear | English Show all | German Alle zeigen |
| showOf | English Show cards of {name} | German Karten von {name} zeigen |
| showLabel | English Show cards with {name} | German Karten mit {name} zeigen |
| just | English just now | German gerade eben |
Accessibility
Read from the source of the component.
- Kanban board handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Tab and Space itself.
- Sets aria-checked, aria-current, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed and aria-roledescription, so screen readers announce purpose and state.
- Uses the roles button, checkbox, group, radio, radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,791 lines
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn, formatPercent, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { DropdownMenu, type MenuItem } from '@/components/ui/dropdown-menu';
import { Icon } from '@/components/ui/icon';
import { LineChart } from '@/components/ui/line-chart';
import { useScrollFade } from '@/components/ui/motion';
import { Sheet } from '@/components/ui/sheet';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type KanbanBoardVariant = 'board' | 'swimlanes' | 'sprint' | 'card-detail' | 'compact';
export type KanbanTone = 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
export type KanbanPriority = 'urgent' | 'high' | 'medium' | 'low';
export interface KanbanLabel {
id: string;
name: string;
tone?: KanbanTone;
}
export interface KanbanPerson {
id: string;
name: string;
}
export interface KanbanColumn {
id: string;
title: string;
/** Work in progress limit. The column warns once it holds more cards. */
limit?: number;
/** Cards here count as done (sprint points, burndown) */
done?: boolean;
}
export interface KanbanComment {
id: string;
/** Person id */
author: string;
text: string;
/** ISO date and time */
time: string;
}
export interface KanbanCard {
id: string;
title: string;
/** Column id */
column: string;
/** Short reference, e.g. NW-142 */
key?: string;
/** Label ids */
tags?: string[];
/** Person ids, the first one is the owner */
assignees?: string[];
/** YYYY-MM-DD */
due?: string;
points?: number;
priority?: KanbanPriority;
description?: string;
checklist?: { id: string; text: string; done: boolean }[];
comments?: KanbanComment[];
}
export interface KanbanSprint {
name: string;
/** YYYY-MM-DD, first and last working day */
start: string;
end: string;
goal?: string;
/** Points still open at the end of each working day before today */
remaining?: number[];
}
export interface KanbanBoardProps {
variant?: KanbanBoardVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
columns?: KanbanColumn[];
/** All cards, in order. The order inside a column is the order here. */
cards?: KanbanCard[];
/** Labels the cards refer to by id */
tags?: KanbanLabel[];
people?: KanbanPerson[];
/** Person id of the signed in user (comments) */
me?: string;
/** YYYY-MM-DD that counts as today */
today?: string;
/** swimlanes: one row per person or per priority */
lanes?: 'person' | 'priority';
/** sprint */
sprint?: KanbanSprint;
/** card-detail: the card open at the start */
selected?: string;
/** Called on move, add, update, comment and complete-sprint. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
newCard: 'New card',
addCard: 'Add card',
addCardTo: 'Add a card to {column}',
cardTitle: 'What needs doing?',
add: 'Add card',
cancel: 'Cancel',
added: '{card} added to {column}.',
countOf: '{n} of {limit}',
overLimit: '{n} over the limit',
atLimit: 'At the limit',
collapse: 'Collapse {column}',
expand: 'Expand {column}',
moveTo: 'Move to',
moveUp: 'Move up',
moveDown: 'Move down',
openCard: 'Open details',
cardMenu: 'Actions for {card}',
dragHint: 'Press Space or Enter to pick up a card. The arrow keys move it, Space or Enter drops it, Escape puts it back.',
dragHintDetail: 'Press Enter to open a card and Space to pick it up. The arrow keys move it, Space drops it, Escape puts it back.',
grabbed: '{card} picked up. {column}, position {pos} of {n}.',
movedKb: '{column}, position {pos} of {n}.',
dropped: '{card} dropped in {column}, position {pos} of {n}.',
moved: '{card} moved to {column}.',
cancelled: '{card} is back in {column}.',
overNote: ' {column} is over its limit.',
draggable: 'draggable card',
overdue: 'Overdue',
dueToday: 'Today',
dueTomorrow: 'Tomorrow',
dueOn: 'Due {date}',
points: '{n} points',
pts: '{n} pts',
checklist: '{done} of {n} done',
comments: '{n} comments',
assigned: 'Assigned to {names}',
unassigned: 'Not assigned',
priority: 'Priority {p}',
urgent: 'Urgent',
high: 'High',
medium: 'Medium',
low: 'Low',
none: 'No priority',
columns: 'Columns',
dropHere: 'Drop here',
empty: 'Nothing here yet',
noMatch: 'No card matches',
undo: 'Undo',
lane: '{n} cards',
collapseLane: 'Collapse {lane}',
expandLane: 'Expand {lane}',
daysLeft: '{n} days left',
lastDay: 'Last day',
burndown: 'Burndown',
ideal: 'Ideal',
remaining: 'Open points',
pointsDone: '{done} of {total} points done',
completeSprint: 'Complete sprint',
completing: 'Closing…',
completed: 'Sprint closed. {n} open cards move to the next sprint.',
goal: 'Sprint goal',
cardsDone: 'Cards done',
velocity: 'Points a day',
pace: 'Pace',
onTrack: 'On track',
behind: '{n} points behind',
details: 'Card details',
title: 'Title',
status: 'Status',
assignees: 'People',
dueDate: 'Due date',
labelsTitle: 'Labels',
estimate: 'Points',
description: 'Description',
descPlaceholder: 'Add the details the team needs',
checklistTitle: 'Checklist',
addItem: 'Add item',
itemPlaceholder: 'Add an item',
removeItem: 'Remove {item}',
commentsTitle: 'Comments',
commentPlaceholder: 'Write a comment',
post: 'Post comment',
noCard: 'Pick a card to see its details here.',
search: 'Search cards',
filterLabels: 'Filter by label',
filterPeople: 'Filter by person',
showing: '{n} of {total} cards',
clear: 'Show all',
showOf: 'Show cards of {name}',
showLabel: 'Show cards with {name}',
just: 'just now',
},
de: {
newCard: 'Neue Karte',
addCard: 'Karte hinzufügen',
addCardTo: 'Karte zu {column} hinzufügen',
cardTitle: 'Was steht an?',
add: 'Karte anlegen',
cancel: 'Abbrechen',
added: '{card} in {column} angelegt.',
countOf: '{n} von {limit}',
overLimit: '{n} über dem Limit',
atLimit: 'Limit erreicht',
collapse: '{column} einklappen',
expand: '{column} ausklappen',
moveTo: 'Verschieben nach',
moveUp: 'Nach oben',
moveDown: 'Nach unten',
openCard: 'Details öffnen',
cardMenu: 'Aktionen für {card}',
dragHint: 'Leertaste oder Enter nimmt eine Karte auf. Die Pfeiltasten verschieben sie, Leertaste oder Enter legt sie ab, Escape legt sie zurück.',
dragHintDetail: 'Enter öffnet eine Karte, die Leertaste nimmt sie auf. Die Pfeiltasten verschieben sie, die Leertaste legt sie ab, Escape legt sie zurück.',
grabbed: '{card} aufgenommen. {column}, Position {pos} von {n}.',
movedKb: '{column}, Position {pos} von {n}.',
dropped: '{card} in {column} abgelegt, Position {pos} von {n}.',
moved: '{card} nach {column} verschoben.',
cancelled: '{card} liegt wieder in {column}.',
overNote: ' {column} ist über dem Limit.',
draggable: 'verschiebbare Karte',
overdue: 'Überfällig',
dueToday: 'Heute',
dueTomorrow: 'Morgen',
dueOn: 'Fällig {date}',
points: '{n} Punkte',
pts: '{n} Pkt.',
checklist: '{done} von {n} erledigt',
comments: '{n} Kommentare',
assigned: 'Zugewiesen an {names}',
unassigned: 'Nicht zugewiesen',
priority: 'Priorität {p}',
urgent: 'Dringend',
high: 'Hoch',
medium: 'Mittel',
low: 'Niedrig',
none: 'Ohne Priorität',
columns: 'Spalten',
dropHere: 'Hier ablegen',
empty: 'Noch leer',
noMatch: 'Keine Karte passt',
undo: 'Rückgängig',
lane: '{n} Karten',
collapseLane: '{lane} einklappen',
expandLane: '{lane} ausklappen',
daysLeft: 'Noch {n} Tage',
lastDay: 'Letzter Tag',
burndown: 'Burndown',
ideal: 'Ideal',
remaining: 'Offene Punkte',
pointsDone: '{done} von {total} Punkten erledigt',
completeSprint: 'Sprint abschließen',
completing: 'Wird abgeschlossen…',
completed: 'Sprint abgeschlossen. {n} offene Karten gehen in den nächsten Sprint.',
goal: 'Sprintziel',
cardsDone: 'Karten erledigt',
velocity: 'Punkte am Tag',
pace: 'Tempo',
onTrack: 'Im Plan',
behind: '{n} Punkte zurück',
details: 'Kartendetails',
title: 'Titel',
status: 'Status',
assignees: 'Personen',
dueDate: 'Fällig am',
labelsTitle: 'Labels',
estimate: 'Punkte',
description: 'Beschreibung',
descPlaceholder: 'Ergänze, was das Team wissen muss',
checklistTitle: 'Checkliste',
addItem: 'Punkt hinzufügen',
itemPlaceholder: 'Punkt hinzufügen',
removeItem: '{item} entfernen',
commentsTitle: 'Kommentare',
commentPlaceholder: 'Kommentar schreiben',
post: 'Kommentar senden',
noCard: 'Wähle eine Karte, dann stehen hier ihre Details.',
search: 'Karten suchen',
filterLabels: 'Nach Label filtern',
filterPeople: 'Nach Person filtern',
showing: '{n} von {total} Karten',
clear: 'Alle zeigen',
showOf: 'Karten von {name} zeigen',
showLabel: 'Karten mit {name} zeigen',
just: 'gerade eben',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
/*
* kbn-up: entrance once the board scrolls in (data-kbn on the scope).
* kbn-pop: a count that changed. kbn-flash: a card that just landed.
* kbn-in: something that appears after an action. The server renders the
* end state, with reduced motion nothing moves.
*/
const CSS =
'@keyframes kbn-up{from{opacity:0;transform:translateY(10px)}}@keyframes kbn-pop{0%{transform:scale(.7)}60%{transform:scale(1.15)}}@keyframes kbn-flash{from{box-shadow:0 0 0 3px color-mix(in oklab,var(--primary) 40%,transparent)}}@keyframes kbn-tick{from{stroke-dashoffset:14}}@keyframes kbn-grow{from{transform:scaleX(0)}}' +
'.kbn-grow{transform-origin:left}.kbn-ghost{transform-origin:30% 20%}' +
'@media (prefers-reduced-motion:no-preference){[data-kbn=play] .kbn-up{animation:kbn-up .6s var(--ease-kit) var(--kbn-d,0ms) both}[data-kbn=wait] .kbn-up{opacity:0}[data-kbn=play] .kbn-grow{animation:kbn-grow 1s var(--ease-kit) .2s both}.kbn-pop{animation:kbn-pop .4s var(--ease-kit)}.kbn-flash{animation:kbn-flash 1.4s ease-out}.kbn-in{animation:kbn-up .4s var(--ease-kit) both}.kbn-tick{stroke-dasharray:14;animation:kbn-tick .3s var(--ease-kit) both}.kbn-ghost>*{animation:kbn-lift .18s var(--ease-kit) both}@keyframes kbn-lift{from{transform:none;box-shadow:var(--shadow-sm)}}}';
function Style() {
return (
<style href="kitb5-kanban-board" precedence="default">
{CSS}
</style>
);
}
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
});
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-kbn': state }] as const;
}
function useWide(query = '(min-width: 1024px)') {
const [wide, setWide] = useState(true);
useEffect(() => {
const m = window.matchMedia(query);
const on = () => setWide(m.matches);
on();
m.addEventListener('change', on);
return () => m.removeEventListener('change', on);
}, [query]);
return wide;
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
const TONE: Record<KanbanTone, string> = {
accent: 'bg-[color-mix(in_oklab,var(--primary)_13%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]',
success: 'bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground',
warning: 'bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-warning-foreground',
danger: 'bg-[color-mix(in_oklab,var(--destructive)_12%,var(--card))] text-destructive',
info: 'bg-[color-mix(in_oklab,var(--info)_15%,var(--card))] text-[color-mix(in_oklab,var(--info)_70%,var(--foreground))]',
neutral: 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-muted-foreground',
};
const PRIO: Record<KanbanPriority, { bars: number; tone: string }> = {
urgent: { bars: 3, tone: 'text-destructive' },
high: { bars: 3, tone: 'text-warning-foreground' },
medium: { bars: 2, tone: 'text-muted-foreground' },
low: { bars: 1, tone: 'text-subtle-foreground' },
};
const DOT = ['bg-subtle-foreground', 'bg-primary', 'bg-warning', 'bg-[color-mix(in_oklab,var(--primary)_55%,var(--foreground))]', 'bg-success'];
function PrioBars({ p, t }: { p: KanbanPriority; t: Texts }) {
const s = PRIO[p];
return (
<span className={cn('inline-flex shrink-0 items-end gap-[2px]', s.tone)} title={fill(t.priority, { p: t[p] })}>
<span className="sr-only">{fill(t.priority, { p: t[p] })}</span>
{[0, 1, 2].map((i) => (
<span key={i} aria-hidden className={cn('w-[3px] rounded-[1px]', i < s.bars ? 'bg-current' : 'bg-track')} style={{ height: 5 + i * 3 }} />
))}
{p === 'urgent' && <Icon name="alert-circle" size={12} className="ms-1" />}
</span>
);
}
const dayMs = 86400000;
const dayNum = (iso: string) => Math.round(Date.parse(iso.slice(0, 10) + 'T00:00:00Z') / dayMs);
const tag = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const fmtDay = (iso: string, l: Locale, o: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' }) => new Intl.DateTimeFormat(tag(l), { ...o, timeZone: 'UTC' }).format(new Date(dayNum(iso) * dayMs));
function dueOf(due: string, today: string, l: Locale, t: Texts) {
const d = dayNum(due) - dayNum(today);
if (d < 0) return { text: fmtDay(due, l), tone: 'overdue' as const, sr: `${t.overdue}, ${fmtDay(due, l)}` };
if (d === 0) return { text: t.dueToday, tone: 'soon' as const, sr: fill(t.dueOn, { date: t.dueToday }) };
if (d === 1) return { text: t.dueTomorrow, tone: 'soon' as const, sr: fill(t.dueOn, { date: t.dueTomorrow }) };
return { text: fmtDay(due, l), tone: 'later' as const, sr: fill(t.dueOn, { date: fmtDay(due, l) }) };
}
function ago(l: Locale, iso: string, now: string, t: Texts) {
const rtf = new Intl.RelativeTimeFormat(tag(l), { numeric: 'auto' });
const m = Math.round((Date.parse(iso + 'Z') - Date.parse(now + 'Z')) / 60000);
if (Math.abs(m) < 1) return t.just;
if (Math.abs(m) < 60) return rtf.format(m, 'minute');
if (Math.abs(m) < 1440) return rtf.format(Math.round(m / 60), 'hour');
return rtf.format(Math.round(m / 1440), 'day');
}
const field =
'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const iconBtn = cn('grid size-11 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:size-8', ring);
function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full min-w-0 max-w-[112rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Toast({ text, onUndo, t }: { text: string; onUndo?: () => void; t: Texts }) {
if (!text) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
<div key={text} className="kbn-in pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-popover py-2 ps-4 pe-2 text-[13.5px] text-popover-foreground shadow-[var(--shadow-lg)]">
<Icon name="check-circle" size={16} className="shrink-0 text-success-foreground" />
<span className="min-w-0 flex-1 text-pretty">{text}</span>
{onUndo && (
<button type="button" onClick={onUndo} className={cn('h-11 shrink-0 cursor-pointer rounded-md px-3 text-[13px] font-semibold text-foreground hover:bg-accent sm:h-8', ring)}>
{t.undo}
</button>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Card */
/* ------------------------------------------------------------------ */
interface Ctx {
t: Texts;
locale: Locale;
today: string;
tag: (id: string) => KanbanLabel | undefined;
person: (id: string) => KanbanPerson | undefined;
}
function CardBody({ card, ctx, dense, menu }: { card: KanbanCard; ctx: Ctx; dense?: boolean; menu?: React.ReactNode }) {
const { t, locale, today } = ctx;
const tags = (card.tags ?? []).map(ctx.tag).filter((x): x is KanbanLabel => !!x);
const people = (card.assignees ?? []).map(ctx.person).filter((x): x is KanbanPerson => !!x);
const due = card.due ? dueOf(card.due, today, locale, t) : null;
const list = card.checklist ?? [];
const done = list.filter((x) => x.done).length;
return (
<>
{dense ? (
<div className="flex min-h-6 items-start gap-1.5 [&>:first-child]:mt-1">
{card.priority && <PrioBars p={card.priority} t={t} />}
<p className="min-w-0 flex-1 text-pretty text-[13px] leading-snug font-medium text-foreground">{card.title}</p>
<span className="-me-1.5 -my-1.5">{menu}</span>
</div>
) : (
<>
<div className="flex min-h-6 items-center gap-1.5">
{card.priority && <PrioBars p={card.priority} t={t} />}
{card.key && <span className="shrink-0 text-[11.5px] font-medium tabular-nums text-subtle-foreground">{card.key}</span>}
<span className="ms-auto -me-1.5 -my-1">{menu}</span>
</div>
<p className="mt-0.5 line-clamp-2 text-pretty text-[14px] font-medium leading-snug text-foreground">{card.title}</p>
{tags.length > 0 && (
<span className="mt-2 flex flex-wrap gap-1">
{tags.map((x) => (
<span key={x.id} className={cn('rounded px-1.5 py-px text-[11px] font-medium', TONE[x.tone ?? 'neutral'])}>
{x.name}
</span>
))}
</span>
)}
</>
)}
<div className={cn('flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[12px] text-muted-foreground', dense ? 'mt-1' : 'mt-3')}>
{dense && card.key && <span className="font-medium tabular-nums text-subtle-foreground">{card.key}</span>}
{due && (
<span className={cn('inline-flex items-center gap-1 tabular-nums', due.tone === 'overdue' ? 'font-medium text-destructive' : due.tone === 'soon' ? 'font-medium text-warning-foreground' : '')}>
<Icon name={due.tone === 'overdue' ? 'alert-circle' : 'calendar'} size={12} />
<span aria-hidden>{due.text}</span>
<span className="sr-only">{due.sr}</span>
</span>
)}
{list.length > 0 && (
<span className={cn('inline-flex items-center gap-1 tabular-nums', done === list.length && 'text-success-foreground')}>
<Icon name="check-square" size={12} />
<span aria-hidden>
{done}/{list.length}
</span>
<span className="sr-only">{fill(t.checklist, { done, n: list.length })}</span>
</span>
)}
{!dense && (card.comments?.length ?? 0) > 0 && (
<span className="inline-flex items-center gap-1 tabular-nums">
<Icon name="message-circle" size={12} />
<span aria-hidden>{card.comments!.length}</span>
<span className="sr-only">{fill(t.comments, { n: card.comments!.length })}</span>
</span>
)}
<span className="ms-auto flex items-center gap-2">
{card.points != null && (
<span className="inline-grid h-5 min-w-5 place-items-center rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] px-1.5 text-[11px] font-semibold tabular-nums text-foreground" title={fill(t.points, { n: card.points })}>
<span aria-hidden>{card.points}</span>
<span className="sr-only">{fill(t.points, { n: card.points })}</span>
</span>
)}
{people.length > 0 && (
<span className="flex -space-x-1.5" title={fill(t.assigned, { names: people.map((p) => p.name).join(', ') })}>
<span className="sr-only">{fill(t.assigned, { names: people.map((p) => p.name).join(', ') })}</span>
{people.slice(0, 3).map((p) => (
<span key={p.id} aria-hidden className="rounded-full ring-2 ring-card">
<Avatar name={p.name} size="xs" />
</span>
))}
</span>
)}
</span>
</div>
</>
);
}
/* ------------------------------------------------------------------ */
/* Board engine */
/* ------------------------------------------------------------------ */
export interface Lane {
id: string;
label: string;
person?: string;
}
interface BoardProps {
ctx: Ctx;
columns: KanbanColumn[];
cards: KanbanCard[];
setCards: (next: KanbanCard[] | ((c: KanbanCard[]) => KanbanCard[])) => void;
lanes?: Lane[];
laneOf?: (c: KanbanCard) => string;
toLane?: (c: KanbanCard, lane: string) => KanbanCard;
visible?: (c: KanbanCard) => boolean;
dense?: boolean;
openOnEnter?: boolean;
selected?: string | null;
onOpen?: (id: string) => void;
headExtra?: (col: KanbanColumn, cards: KanbanCard[]) => React.ReactNode;
onAction?: KanbanBoardProps['onAction'];
/** Ask the board to open the add form in a column (header button) */
addRequest?: { col: string; n: number } | null;
className?: string;
}
const NONE = '';
const keyOf = (lane: string, col: string) => `${lane}|${col}`;
const splitKey = (k: string) => {
const i = k.lastIndexOf('|');
return [k.slice(0, i), k.slice(i + 1)] as const;
};
/** Takes a card out and puts it back in its new list, before another card or at the end */
function placeCard(cards: KanbanCard[], id: string, update: (c: KanbanCard) => KanbanCard, before: string | null, listOf: (c: KanbanCard) => string) {
const card = cards.find((c) => c.id === id);
if (!card) return cards;
const moved = update(card);
const rest = cards.filter((c) => c.id !== id);
let at = before ? rest.findIndex((c) => c.id === before) : -1;
if (at < 0) {
const k = listOf(moved);
let last = -1;
rest.forEach((c, i) => {
if (listOf(c) === k) last = i;
});
at = last < 0 ? rest.length : last + 1;
}
rest.splice(at, 0, moved);
return rest;
}
interface DragState {
id: string;
list: string;
before: string | null;
w: number;
}
function Board({ ctx, columns, cards, setCards, lanes, laneOf = () => NONE, toLane = (c) => c, visible = () => true, dense, openOnEnter, selected, onOpen, headExtra, onAction, addRequest, className }: BoardProps) {
const { t } = ctx;
const id = useId();
const domId = (cid: string) => `${id}-c-${cid}`;
const [collapsed, setCollapsed] = useState<string[]>([]);
const [laneShut, setLaneShut] = useState<string[]>([]);
const [adding, setAdding] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const [drag, setDrag] = useState<DragState | null>(null);
const [kb, setKb] = useState<{ id: string; origin: KanbanCard[] } | null>(null);
const [say, setSay] = useState('');
const [toast, setToast] = useState<{ text: string; undo?: KanbanCard[] } | null>(null);
const [fresh, setFresh] = useState<string | null>(null);
const [mcol, setMcol] = useState(0);
const [scope, enter] = useEnter<HTMLDivElement>();
const [fadeRef, fade] = useScrollFade<HTMLDivElement>();
const [tabsRef, tabsFade] = useScrollFade<HTMLElement>();
const scroller = useRef<HTMLDivElement | null>(null);
const ghost = useRef<HTMLDivElement>(null);
const suppress = useRef(false);
const toastTimer = useRef<number>(0);
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
}, []);
const laneIds = lanes?.map((l) => l.id) ?? [NONE];
const openCols = columns.filter((c) => lanes || !collapsed.includes(c.id));
const listOf = (c: KanbanCard) => keyOf(laneOf(c), c.column);
const inList = (list: KanbanCard[], lane: string, col: string) => list.filter((c) => c.column === col && laneOf(c) === lane && visible(c));
const colName = (cid: string) => columns.find((c) => c.id === cid)?.title ?? cid;
const laneName = (lid: string) => lanes?.find((l) => l.id === lid)?.label ?? '';
const where = (lane: string, col: string) => (lanes ? `${laneName(lane)}, ${colName(col)}` : colName(col));
const over = (list: KanbanCard[], col: string) => {
const c = columns.find((x) => x.id === col);
return !!c?.limit && list.filter((x) => x.column === col).length > c.limit;
};
const flash = (cid: string) => {
setFresh(cid);
window.setTimeout(() => setFresh((f) => (f === cid ? null : f)), 1500);
};
const notify = (text: string, undo?: KanbanCard[]) => {
setToast({ text, undo });
window.clearTimeout(toastTimer.current);
toastTimer.current = window.setTimeout(() => setToast(null), 5000);
};
/** A finished move by pointer or menu: board, announcement, toast with undo, callback */
const commit = (cid: string, list: string, before: string | null) => {
const card = cards.find((c) => c.id === cid);
if (!card) return;
const [lane, col] = splitKey(list);
const next = placeCard(cards, cid, (c) => toLane({ ...c, column: col }, lane), before, listOf);
if (next.map((c) => c.id + c.column + laneOf(c)).join() === cards.map((c) => c.id + c.column + laneOf(c)).join()) return;
const before0 = cards;
setCards(next);
flash(cid);
const target = inList(next, lane, col);
const pos = target.findIndex((c) => c.id === cid) + 1;
const text = listOf(card) === list ? fill(t.dropped, { card: card.title, column: where(lane, col), pos, n: target.length }) : fill(t.moved, { card: card.title, column: where(lane, col) });
setSay(text + (over(next, col) ? fill(t.overNote, { column: colName(col) }) : ''));
notify(text, before0);
void onAction?.('move', { id: cid, column: col, lane: lanes ? lane : undefined, position: pos });
};
/* ---------- pointer and touch ---------- */
const onPointerDown = (e: React.PointerEvent<HTMLElement>, card: KanbanCard, list: string, nextId: string | null) => {
if (e.button !== 0 || kb) return;
if ((e.target as HTMLElement).closest('button,a,input,textarea,select,[role=menu]')) return;
const el = e.currentTarget;
const r = el.getBoundingClientRect();
const pid = e.pointerId;
const s = { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, ox: e.clientX - r.left, oy: e.clientY - r.top, began: false, touch: e.pointerType === 'touch' };
let timer = 0;
let raf = 0;
let cur: DragState = { id: card.id, list, before: nextId, w: r.width };
const place = () => {
if (ghost.current) ghost.current.style.transform = `translate3d(${round(s.x - s.ox, 1)}px, ${round(s.y - s.oy, 1)}px, 0)`;
};
const hit = () => {
const under = document.elementFromPoint(s.x, s.y) as HTMLElement | null;
const listEl = under?.closest<HTMLElement>('[data-list]');
if (!listEl || !scroller.current?.contains(listEl)) return;
const k = listEl.dataset.list!;
let before: string | null = null;
for (const c of listEl.querySelectorAll<HTMLElement>('[data-card]')) {
if (c.dataset.card === card.id) continue;
const b = c.getBoundingClientRect();
if (s.y < b.top + b.height / 2) {
before = c.dataset.card!;
break;
}
}
if (cur.list !== k || cur.before !== before) {
cur = { ...cur, list: k, before };
setDrag(cur);
}
};
const tick = () => {
const sc = scroller.current;
let moved = false;
if (sc) {
const b = sc.getBoundingClientRect();
const edge = Math.min(72, b.width * 0.14);
if (s.y > b.top - 40 && s.y < b.bottom + 40) {
const dx = s.x < b.left + edge ? -Math.ceil((b.left + edge - s.x) / 4) : s.x > b.right - edge ? Math.ceil((s.x - b.right + edge) / 4) : 0;
if (dx) {
const was = sc.scrollLeft;
sc.scrollLeft += dx;
moved ||= sc.scrollLeft !== was;
}
}
}
const H = window.innerHeight;
const dy = s.y < 90 ? -Math.ceil((90 - s.y) / 5) : s.y > H - 70 ? Math.ceil((s.y - H + 70) / 5) : 0;
if (dy) {
const was = window.scrollY;
window.scrollBy(0, dy);
moved ||= window.scrollY !== was;
}
if (moved) hit();
raf = requestAnimationFrame(tick);
};
const begin = () => {
s.began = true;
setDrag(cur);
document.body.style.userSelect = 'none';
if (s.touch) navigator.vibrate?.(8);
requestAnimationFrame(place);
raf = requestAnimationFrame(tick);
};
const done = (ok: boolean) => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
document.removeEventListener('pointercancel', cancel);
document.removeEventListener('keydown', key, true);
document.removeEventListener('touchmove', touch);
window.clearTimeout(timer);
cancelAnimationFrame(raf);
document.body.style.userSelect = '';
if (!s.began) return;
suppress.current = true;
window.setTimeout(() => (suppress.current = false), 60);
setDrag(null);
if (ok) commit(cur.id, cur.list, cur.before);
};
const move = (ev: PointerEvent) => {
if (ev.pointerId !== pid) return;
s.x = ev.clientX;
s.y = ev.clientY;
if (!s.began) {
const d = Math.hypot(s.x - s.sx, s.y - s.sy);
if (s.touch) {
if (d > 10) done(false);
return;
}
if (d < 5) return;
begin();
}
place();
hit();
};
const up = (ev: PointerEvent) => ev.pointerId === pid && done(true);
const cancel = (ev: PointerEvent) => ev.pointerId === pid && done(false);
const key = (ev: KeyboardEvent) => {
if (ev.key !== 'Escape' || !s.began) return;
ev.preventDefault();
ev.stopPropagation();
done(false);
setSay(fill(t.cancelled, { card: card.title, column: where(laneOf(card), card.column) }));
};
const touch = (ev: TouchEvent) => {
if (s.began && ev.cancelable) ev.preventDefault();
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
document.addEventListener('pointercancel', cancel);
document.addEventListener('keydown', key, true);
document.addEventListener('touchmove', touch, { passive: false });
if (s.touch) timer = window.setTimeout(begin, 280);
};
/* ---------- keyboard ---------- */
const position = (list: KanbanCard[], cid: string) => {
const c = list.find((x) => x.id === cid)!;
const l = inList(list, laneOf(c), c.column);
return { pos: l.findIndex((x) => x.id === cid) + 1, n: l.length, lane: laneOf(c), col: c.column };
};
const kbMove = (cid: string, dx: number, dy: number) => {
const card = cards.find((c) => c.id === cid);
if (!card) return;
const lane = laneOf(card);
let tLane = lane;
let tCol = card.column;
let before: string | null = null;
const cur = inList(cards, lane, card.column);
const idx = cur.findIndex((c) => c.id === cid);
if (dx) {
const ci = openCols.findIndex((c) => c.id === card.column);
const nc = openCols[ci + dx];
if (!nc) return;
tCol = nc.id;
const target = inList(cards, lane, tCol);
before = target[Math.min(idx, target.length)]?.id ?? null;
} else {
const ni = idx + dy;
if (ni >= 0 && ni < cur.length) {
before = cur.filter((c) => c.id !== cid)[ni]?.id ?? null;
} else if (lanes) {
const li = laneIds.indexOf(lane) + dy;
if (li < 0 || li >= laneIds.length) return;
tLane = laneIds[li];
const target = inList(cards, tLane, tCol);
before = dy > 0 ? target[0]?.id ?? null : null;
setLaneShut((s) => s.filter((x) => x !== tLane));
} else return;
}
const next = placeCard(cards, cid, (c) => toLane({ ...c, column: tCol }, tLane), before, listOf);
setCards(next);
const p = position(next, cid);
setMcol(Math.max(0, columns.findIndex((c) => c.id === tCol)));
setSay(fill(t.movedKb, { column: where(p.lane, p.col), pos: p.pos, n: p.n }) + (over(next, tCol) && dx ? fill(t.overNote, { column: colName(tCol) }) : ''));
};
const onKey = (e: React.KeyboardEvent, card: KanbanCard) => {
// Keys inside the card menu (its trigger and the menu, which bubbles here through the portal) belong to the menu
if (e.target !== e.currentTarget) return;
const lifted = kb?.id === card.id;
if (!lifted) {
if (kb) return;
if (e.key === 'Enter' && openOnEnter && onOpen) {
e.preventDefault();
return onOpen(card.id);
}
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setKb({ id: card.id, origin: cards });
const p = position(cards, card.id);
setSay(fill(t.grabbed, { card: card.title, column: where(p.lane, p.col), pos: p.pos, n: p.n }));
}
return;
}
const map: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
if (map[e.key]) {
e.preventDefault();
kbMove(card.id, ...map[e.key]);
} else if (e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') {
if (e.key !== 'Tab') e.preventDefault();
const p = position(cards, card.id);
setKb(null);
flash(card.id);
setSay(fill(t.dropped, { card: card.title, column: where(p.lane, p.col), pos: p.pos, n: p.n }));
void onAction?.('move', { id: card.id, column: p.col, lane: lanes ? p.lane : undefined, position: p.pos });
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setCards(kb.origin);
setKb(null);
const o = kb.origin.find((c) => c.id === card.id)!;
setSay(fill(t.cancelled, { card: card.title, column: where(laneOf(o), o.column) }));
}
};
// The lifted card keeps the focus while it changes lists
useIso(() => {
if (!kb) return;
const el = document.getElementById(domId(kb.id));
if (el && document.activeElement !== el) el.focus({ preventScroll: true });
el?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}, [cards, kb]);
/* ---------- menu moves ---------- */
const menuMove = (card: KanbanCard, col: string) => {
const lane = laneOf(card);
commit(card.id, keyOf(lane, col), null);
requestAnimationFrame(() => document.getElementById(domId(card.id))?.focus({ preventScroll: true }));
setMcol(Math.max(0, columns.findIndex((c) => c.id === col)));
};
const step = (card: KanbanCard, dy: number) => {
const list = inList(cards, laneOf(card), card.column);
const i = list.findIndex((c) => c.id === card.id);
const ni = i + dy;
if (ni < 0 || ni >= list.length) return;
const before = list.filter((c) => c.id !== card.id)[ni]?.id ?? null;
commit(card.id, listOf(card), before);
};
const menuFor = (card: KanbanCard, list: KanbanCard[]): MenuItem[] => {
const i = list.findIndex((c) => c.id === card.id);
return [
...(onOpen ? [{ label: t.openCard, icon: 'panel-right' as const, onSelect: () => onOpen(card.id) }, { type: 'separator' as const }] : []),
{ type: 'label', label: t.moveTo },
...columns.filter((c) => c.id !== card.column).map((c) => ({ label: c.title, icon: 'arrow-right' as const, onSelect: () => menuMove(card, c.id) })),
{ type: 'separator' },
{ label: t.moveUp, icon: 'arrow-up', disabled: i <= 0, onSelect: () => step(card, -1) },
{ label: t.moveDown, icon: 'arrow-down', disabled: i < 0 || i >= list.length - 1, onSelect: () => step(card, 1) },
];
};
/* ---------- add ---------- */
useEffect(() => {
if (!addRequest) return;
const ci = columns.findIndex((c) => c.id === addRequest.col);
setCollapsed((s) => s.filter((x) => x !== addRequest.col));
setAdding(keyOf(laneIds[0], addRequest.col));
setDraft('');
setMcol(Math.max(0, ci));
const sec = scroller.current?.querySelector<HTMLElement>(`[data-col="${addRequest.col}"]`);
sec?.scrollIntoView({ block: 'nearest', inline: 'start', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [addRequest]);
const submitAdd = (list: string) => {
const title = draft.trim();
if (!title) return setAdding(null);
const [lane, col] = splitKey(list);
const nid = `k${Date.now()}`;
const card = toLane({ id: nid, title, column: col }, lane);
setCards((cs) => placeCard([...cs, card], nid, (c) => c, null, listOf));
setDraft('');
flash(nid);
const text = fill(t.added, { card: title, column: where(lane, col) });
setSay(text);
void onAction?.('add', { card });
};
/* ---------- phone tabs follow the scroll position ---------- */
const setScroller = (el: HTMLDivElement | null) => {
scroller.current = el;
fadeRef.current = el;
};
useEffect(() => {
const sc = scroller.current;
if (!sc) return;
const on = () => {
if (window.innerWidth >= 640) return;
const secs = [...sc.querySelectorAll<HTMLElement>('[data-col-head]')];
const mid = sc.scrollLeft + sc.clientWidth / 2;
let best = 0;
secs.forEach((s, i) => {
if (s.offsetLeft <= mid) best = i;
});
setMcol(best);
};
sc.addEventListener('scroll', on, { passive: true });
return () => sc.removeEventListener('scroll', on);
}, []);
const goCol = (i: number) => {
setMcol(i);
const sc = scroller.current;
const s = sc?.querySelectorAll<HTMLElement>('[data-col-head]')[i];
if (sc && s) sc.scrollTo({ left: s.offsetLeft - parseFloat(getComputedStyle(sc).paddingLeft), behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
};
useEffect(() => {
const nav = tabsRef.current;
const b = nav?.children[mcol] as HTMLElement | undefined;
if (nav && b && nav.scrollWidth > nav.clientWidth) nav.scrollTo({ left: b.offsetLeft - 16, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}, [mcol, tabsRef]);
const dragCard = drag ? cards.find((c) => c.id === drag.id) : undefined;
/* ---------- render ---------- */
const renderCard = (card: KanbanCard, list: KanbanCard[], k: string, i: number) => {
const lifted = kb?.id === card.id;
const hidden = drag?.id === card.id;
const nextId = list[i + 1]?.id ?? null;
return (
<li key={card.id} data-card={card.id} className={cn(hidden && 'hidden')} style={{ '--kbn-d': `${Math.min(i, 6) * 45}ms` } as React.CSSProperties}>
<div
id={domId(card.id)}
role="button"
tabIndex={0}
aria-roledescription={t.draggable}
aria-describedby={`${id}-hint`}
aria-pressed={lifted || undefined}
aria-current={selected === card.id ? 'true' : undefined}
aria-label={card.title}
onPointerDown={(e) => onPointerDown(e, card, k, nextId)}
onContextMenu={(e) => {
if (drag) e.preventDefault();
}}
onClick={(e) => {
if (suppress.current || kb || (e.target as HTMLElement).closest('button,a,[role=menu]') !== null && e.target !== e.currentTarget) return;
onOpen?.(card.id);
}}
onKeyDown={(e) => onKey(e, card)}
onBlur={() => {
if (kb?.id !== card.id) return;
// Focus left the lifted card (click elsewhere): it stays where it is now
requestAnimationFrame(() => {
if (document.activeElement?.id !== domId(card.id)) setKb((x) => (x?.id === card.id ? null : x));
});
}}
className={cn(
'kbn-up group relative block cursor-grab touch-manipulation select-none rounded-lg border bg-card text-left shadow-[var(--highlight),var(--shadow-sm)] [-webkit-touch-callout:none] transition-[box-shadow,transform,border-color] duration-200 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] active:cursor-grabbing motion-reduce:transition-none motion-reduce:hover:translate-y-0',
dense ? 'px-2.5 py-2' : 'p-3',
ring,
selected === card.id ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_40%,transparent),var(--shadow-sm)]' : 'border-border',
lifted && 'z-10 -translate-y-1 scale-[1.02] rotate-[0.6deg] border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_35%,transparent),var(--shadow-lg)] hover:-translate-y-1',
fresh === card.id && 'kbn-flash',
)}
>
<CardBody
card={card}
ctx={ctx}
dense={dense}
menu={
<DropdownMenu
align="end"
items={menuFor(card, list)}
trigger={
<button type="button" aria-label={fill(t.cardMenu, { card: card.title })} className={cn(iconBtn, 'size-11 text-subtle-foreground sm:size-7')}>
<Icon name="more-horizontal" size={15} />
</button>
}
/>
}
/>
</div>
</li>
);
};
const placeholder = (h = dense ? 56 : 92) => (
<li key="__drop" aria-hidden className="grid place-items-center rounded-lg border-2 border-dashed border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,transparent)] text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" style={{ height: h }}>
{t.dropHere}
</li>
);
const renderList = (lane: string, col: KanbanColumn) => {
const k = keyOf(lane, col.id);
const list = inList(cards, lane, col.id);
const items: React.ReactNode[] = [];
const showPh = drag && drag.list === k;
list.forEach((c, i) => {
if (showPh && drag!.before === c.id) items.push(placeholder());
items.push(renderCard(c, list, k, i));
});
if (showPh && (!drag!.before || !list.some((c) => c.id === drag!.before))) items.push(placeholder());
const formOpen = adding === k;
return (
<>
<ul className={cn('flex min-h-16 flex-1 flex-col gap-2', dense && 'gap-1.5')}>
{items}
{!list.length && !showPh && !formOpen && (
<li className="grid min-h-12 flex-1 place-items-center rounded-lg border border-dashed border-border text-[12.5px] text-subtle-foreground">{cards.some((c) => c.column === col.id && laneOf(c) === lane) ? t.noMatch : t.empty}</li>
)}
</ul>
{formOpen ? (
<form
className="kbn-in mt-2 rounded-lg border border-border-strong bg-card p-2 shadow-[var(--shadow-md)]"
onSubmit={(e) => {
e.preventDefault();
submitAdd(k);
}}
>
<label htmlFor={`${id}-new-${k}`} className="sr-only">
{fill(t.addCardTo, { column: where(lane, col.id) })}
</label>
<textarea
id={`${id}-new-${k}`}
autoFocus={mounted.current}
rows={2}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') (e.preventDefault(), setAdding(null));
if (e.key === 'Enter' && !e.shiftKey) (e.preventDefault(), submitAdd(k));
}}
placeholder={t.cardTitle}
className="block w-full resize-none bg-transparent px-1.5 py-1 text-base text-foreground outline-none placeholder:text-subtle-foreground sm:text-[13.5px]"
/>
<div className="mt-1 flex justify-end gap-1.5">
<Button type="button" size="sm" variant="ghost" onClick={() => setAdding(null)}>
{t.cancel}
</Button>
<Button type="submit" size="sm" iconLeft="plus">
{t.add}
</Button>
</div>
</form>
) : (
<button
type="button"
onClick={() => {
setAdding(k);
setDraft('');
}}
aria-label={fill(t.addCardTo, { column: where(lane, col.id) })}
className={cn('mt-2 flex h-11 w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] hover:text-foreground sm:h-9', ring)}
>
<Icon name="plus" size={15} />
{t.addCard}
</button>
)}
</>
);
};
const colHead = (col: KanbanColumn, ci: number, lane?: boolean) => {
const all = cards.filter((c) => c.column === col.id);
const n = all.length;
const isOver = !!col.limit && n > col.limit;
const atLimit = !!col.limit && n === col.limit;
const shut = collapsed.includes(col.id);
return (
<div data-col-head className={cn('min-w-0', lane ? 'px-1 pb-2' : 'px-1 pb-2.5')}>
<div className="flex h-9 items-center gap-2">
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', col.done ? 'bg-success' : DOT[ci % DOT.length])} />
<h2 id={`${id}-h-${col.id}`} className="min-w-0 truncate text-[13.5px] font-semibold text-foreground">
{col.title}
</h2>
<span
key={n}
className={cn(
'kbn-pop inline-flex h-5 shrink-0 items-center rounded-md px-1.5 text-[11.5px] font-semibold tabular-nums',
isOver ? 'bg-[color-mix(in_oklab,var(--warning)_24%,var(--card))] text-warning-foreground' : atLimit ? 'bg-[color-mix(in_oklab,var(--foreground)_10%,var(--card))] text-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-muted-foreground',
)}
>
{col.limit ? <><span aria-hidden>{n}/{col.limit}</span><span className="sr-only">{fill(t.countOf, { n, limit: col.limit })}</span></> : n}
</span>
<span className="ms-auto flex items-center">
{headExtra?.(col, all)}
{!lane && (
<button type="button" aria-expanded={!shut} aria-label={fill(shut ? t.expand : t.collapse, { column: col.title })} onClick={() => setCollapsed((s) => (shut ? s.filter((x) => x !== col.id) : [...s, col.id]))} className={cn(iconBtn, 'max-sm:hidden')}>
<Icon name="chevrons-left" size={15} />
</button>
)}
</span>
</div>
{col.limit != null && (
<div className="mt-1.5 flex items-center gap-2" aria-live="polite">
<span aria-hidden className="relative h-1 flex-1 overflow-hidden rounded-full bg-track">
<span className={cn('kbn-grow absolute inset-y-0 start-0 rounded-full transition-[width] duration-500 ease-[var(--ease-kit)]', isOver ? 'bg-warning' : atLimit ? 'bg-foreground' : 'bg-primary')} style={{ width: `${round(Math.min(100, (n / col.limit) * 100), 1)}%` }} />
</span>
{(isOver || atLimit) && (
<span className={cn('kbn-in shrink-0 text-[11.5px] font-medium', isOver ? 'text-warning-foreground' : 'text-muted-foreground')}>{isOver ? fill(t.overLimit, { n: n - col.limit }) : t.atLimit}</span>
)}
</div>
)}
</div>
);
};
const tabs = (
<nav ref={tabsRef} aria-label={t.columns} className={cn("kit-no-scrollbar -mx-4 mt-5 flex gap-1 overflow-x-auto px-4 sm:hidden", tabsFade)}>
{columns.map((c, i) => {
const n = cards.filter((x) => x.column === c.id).length;
const isOver = !!c.limit && n > c.limit;
return (
<button key={c.id} type="button" aria-current={mcol === i ? 'true' : undefined} onClick={() => goCol(i)} className={cn('relative inline-flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium transition-colors', ring, mcol === i ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground')}>
{c.title}
<span className={cn('tabular-nums text-[12px]', mcol === i ? 'opacity-70' : isOver ? 'text-warning-foreground' : 'text-subtle-foreground')}>{n}</span>
</button>
);
})}
</nav>
);
const scrollerCls = cn('kit-no-scrollbar relative -mx-4 mt-3 overflow-x-auto px-4 pb-3 sm:-mx-6 sm:mt-5 sm:px-6 lg:-mx-8 lg:px-8', drag ? 'snap-none' : 'max-sm:snap-x max-sm:snap-mandatory max-sm:scroll-px-4', fade);
return (
<div ref={scope} {...enter} className={cn('min-w-0', className)}>
<p id={`${id}-hint`} className="sr-only">
{openOnEnter ? t.dragHintDetail : t.dragHint}
</p>
<p role="status" aria-live="assertive" className="sr-only">
{say}
</p>
{tabs}
{!lanes ? (
<div ref={setScroller} data-scroller className={scrollerCls}>
<div className="flex gap-3 sm:gap-4">
{columns.map((col, ci) => {
const shut = collapsed.includes(col.id);
const k = keyOf(NONE, col.id);
const n = cards.filter((c) => c.column === col.id).length;
const isOver = !!col.limit && n > col.limit;
const target = !!drag && drag.list === k;
if (shut)
return (
<section key={col.id} data-col={col.id} data-list={k} aria-labelledby={`${id}-h-${col.id}`} className={cn('kbn-up flex w-14 flex-none flex-col items-center rounded-xl border bg-surface py-2 transition-colors', target ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--surface))]' : isOver ? 'border-[color-mix(in_oklab,var(--warning)_55%,var(--border))]' : 'border-border')}>
<span data-col-head />
<button type="button" aria-expanded={false} aria-label={fill(t.expand, { column: col.title })} onClick={() => setCollapsed((s) => s.filter((x) => x !== col.id))} className={cn('flex w-full flex-1 cursor-pointer flex-col items-center gap-3 rounded-lg py-1 text-muted-foreground hover:text-foreground', ring)}>
<Icon name="chevrons-right" size={15} />
<span className={cn('inline-grid h-5 min-w-5 place-items-center rounded-md px-1 text-[11.5px] font-semibold tabular-nums', isOver ? 'bg-[color-mix(in_oklab,var(--warning)_24%,var(--card))] text-warning-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))]')}>{n}</span>
<h2 id={`${id}-h-${col.id}`} className="text-[13px] font-semibold text-foreground [writing-mode:vertical-rl]">
{col.title}
</h2>
</button>
</section>
);
return (
<section
key={col.id}
data-col={col.id}
data-list={k}
aria-labelledby={`${id}-h-${col.id}`}
className={cn(
'kbn-up flex min-w-0 shrink-0 basis-[calc(100%-1.75rem)] snap-center flex-col rounded-xl border p-2 transition-[border-color,background-color,box-shadow] duration-200 sm:basis-auto sm:flex-[1_0_17rem] lg:flex-[1_0_15rem] xl:flex-[1_0_12.75rem]',
dense && 'sm:flex-[1_0_14.5rem] lg:flex-[1_0_13rem] xl:flex-[1_0_12rem]',
target ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_5%,var(--surface))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_12%,transparent)]' : isOver ? 'border-[color-mix(in_oklab,var(--warning)_55%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_5%,var(--surface))]' : 'border-border bg-surface',
)}
style={{ '--kbn-d': `${ci * 60}ms` } as React.CSSProperties}
>
{colHead(col, ci)}
{renderList(NONE, col)}
</section>
);
})}
</div>
</div>
) : (
<div ref={setScroller} data-scroller className={scrollerCls}>
<div className="grid gap-x-3 [--kb-col:calc(100vw-2.5rem)] sm:gap-x-4 sm:[--kb-col:16rem]" style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(var(--kb-col), 1fr))` }}>
{columns.map((col, ci) => (
<div key={col.id} className="max-sm:snap-center">
{colHead(col, ci, true)}
</div>
))}
{lanes.map((lane) => {
const shut = laneShut.includes(lane.id);
const n = cards.filter((c) => laneOf(c) === lane.id && visible(c)).length;
const person = lane.person ? ctx.person(lane.person) : undefined;
return (
<div key={lane.id} className="col-span-full grid grid-cols-subgrid border-t border-border pt-3 pb-4 first-of-type:border-t-0">
<div className="col-span-full">
<button type="button" aria-expanded={!shut} onClick={() => setLaneShut((s) => (shut ? s.filter((x) => x !== lane.id) : [...s, lane.id]))} className={cn('sticky start-4 flex h-11 max-w-[calc(100vw-2rem)] cursor-pointer items-center gap-2.5 rounded-lg pe-3 ps-1 text-left sm:start-6 sm:h-10 lg:start-8', ring)} aria-label={fill(shut ? t.expandLane : t.collapseLane, { lane: lane.label })}>
<Icon name="chevron-down" size={15} className={cn('shrink-0 text-muted-foreground transition-transform duration-300', shut && '-rotate-90')} />
{person ? <Avatar name={person.name} size="xs" /> : <span aria-hidden className="grid size-6 place-items-center rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-muted-foreground"><Icon name={lanes[0]?.person !== undefined || lane.id === NONE ? 'user' : 'flag'} size={12} /></span>}
<span className="truncate text-[14px] font-semibold text-foreground">{lane.label}</span>
<span className="shrink-0 text-[12.5px] tabular-nums text-subtle-foreground">{fill(t.lane, { n })}</span>
</button>
</div>
{!shut &&
columns.map((col) => {
const k = keyOf(lane.id, col.id);
const target = !!drag && drag.list === k;
return (
<div key={col.id} data-list={k} role="group" aria-label={where(lane.id, col.id)} className={cn('kbn-up mt-2 flex flex-col rounded-xl border p-2 transition-[border-color,background-color] duration-200 max-sm:snap-center', target ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_5%,var(--surface))]' : 'border-border bg-surface')}>
{renderList(lane.id, col)}
</div>
);
})}
</div>
);
})}
</div>
</div>
)}
{dragCard &&
drag &&
createPortal(
<div ref={ghost} aria-hidden className="kbn-ghost pointer-events-none fixed left-0 top-0 z-[100]" style={{ width: drag.w }}>
<div className={cn('rotate-[2deg] scale-[1.03] rounded-lg border border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-card shadow-[var(--shadow-lg)]', dense ? 'px-2.5 py-2' : 'p-3')}>
<CardBody card={dragCard} ctx={ctx} dense={dense} />
</div>
</div>,
document.body,
)}
<Toast
t={t}
text={toast?.text ?? ''}
onUndo={
toast?.undo
? () => {
setCards(toast.undo!);
setToast(null);
setSay(t.undo);
}
: undefined
}
/>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Detail */
/* ------------------------------------------------------------------ */
function Check({ on, onChange, label }: { on: boolean; onChange: () => void; label: string }) {
return (
<button type="button" role="checkbox" aria-checked={on} aria-label={label} onClick={onChange} className={cn('relative grid size-11 shrink-0 cursor-pointer place-items-center rounded-md sm:size-7', ring)}>
<span className={cn('grid size-[18px] place-items-center rounded-[5px] border transition-[background-color,border-color] duration-200', on ? 'border-transparent bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
{on && (
<svg viewBox="0 0 16 16" className="size-3" aria-hidden>
<path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className="kbn-tick" />
</svg>
)}
</span>
</button>
);
}
function Detail({ card, ctx, columns, tags, people, me, now, update }: { card: KanbanCard; ctx: Ctx; columns: KanbanColumn[]; tags: KanbanLabel[]; people: KanbanPerson[]; me?: string; now: string; update: (patch: Partial<KanbanCard>, action?: string) => void }) {
const { t, locale } = ctx;
const id = useId();
const [item, setItem] = useState('');
const [comment, setComment] = useState('');
const list = card.checklist ?? [];
const done = list.filter((x) => x.done).length;
const pct = list.length ? (done / list.length) * 100 : 0;
const lbl = 'text-[12px] font-medium text-muted-foreground';
return (
<div className="grid gap-6">
<div>
<p className="text-[12px] font-medium tabular-nums text-subtle-foreground">{card.key}</p>
<label htmlFor={`${id}-title`} className="sr-only">
{t.title}
</label>
<textarea id={`${id}-title`} rows={2} value={card.title} onChange={(e) => update({ title: e.target.value })} className={cn('mt-1 block w-full resize-none rounded-md bg-transparent text-[18px] font-semibold leading-snug tracking-[-0.015em] text-foreground hover:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)]', ring)} />
</div>
<dl className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<dt>
<label htmlFor={`${id}-status`} className={lbl}>
{t.status}
</label>
</dt>
<dd className="relative">
<select id={`${id}-status`} value={card.column} onChange={(e) => update({ column: e.target.value }, 'move')} className={cn(field, 'cursor-pointer appearance-none pe-8')}>
{columns.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</dd>
</div>
<div className="grid gap-1.5">
<dt>
<label htmlFor={`${id}-due`} className={lbl}>
{t.dueDate}
</label>
</dt>
<dd>
<input id={`${id}-due`} type="date" value={card.due ?? ''} onChange={(e) => update({ due: e.target.value || undefined })} className={cn(field, 'tabular-nums')} />
</dd>
</div>
<div className="grid gap-1.5 sm:col-span-2">
<dt className={lbl} id={`${id}-ppl`}>
{t.assignees}
</dt>
<dd role="group" aria-labelledby={`${id}-ppl`} className="flex flex-wrap gap-1.5">
{people.map((p) => {
const on = (card.assignees ?? []).includes(p.id);
return (
<button key={p.id} type="button" aria-pressed={on} onClick={() => update({ assignees: on ? (card.assignees ?? []).filter((x) => x !== p.id) : [...(card.assignees ?? []), p.id] })} className={cn('inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-full border ps-1 pe-3 text-[12.5px] font-medium transition-colors sm:h-8', ring, on ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
<Avatar name={p.name} size="xs" />
{p.name.split(' ')[0]}
</button>
);
})}
</dd>
</div>
<div className="grid gap-1.5 sm:col-span-2">
<dt className={lbl} id={`${id}-tags`}>
{t.labelsTitle}
</dt>
<dd role="group" aria-labelledby={`${id}-tags`} className="flex flex-wrap gap-1.5">
{tags.map((x) => {
const on = (card.tags ?? []).includes(x.id);
return (
<button key={x.id} type="button" aria-pressed={on} onClick={() => update({ tags: on ? (card.tags ?? []).filter((y) => y !== x.id) : [...(card.tags ?? []), x.id] })} className={cn('inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-medium transition-[opacity,box-shadow] sm:h-7', ring, TONE[x.tone ?? 'neutral'], on ? 'shadow-[inset_0_0_0_1.5px_currentColor]' : 'opacity-60 hover:opacity-100')}>
{on && <Icon name="check" size={12} />}
{x.name}
</button>
);
})}
</dd>
</div>
<div className="grid gap-1.5 sm:col-span-2">
<dt className={lbl} id={`${id}-pts`}>
{t.estimate}
</dt>
<dd role="radiogroup" aria-labelledby={`${id}-pts`} className="flex flex-wrap gap-1">
{[1, 2, 3, 5, 8, 13].map((n, i, all) => (
<button
key={n}
type="button"
role="radio"
aria-checked={card.points === n}
tabIndex={card.points === n || (card.points == null && i === 0) ? 0 : -1}
onClick={() => update({ points: n })}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const j = (i + d + all.length) % all.length;
update({ points: all[j] });
(e.currentTarget.parentElement?.children[j] as HTMLElement | undefined)?.focus();
}}
className={cn('grid h-11 min-w-11 cursor-pointer place-items-center rounded-md px-2 text-[13px] font-semibold tabular-nums transition-colors sm:h-8 sm:min-w-9', ring, card.points === n ? 'bg-foreground text-background' : 'bg-[color-mix(in_oklab,var(--foreground)_6%,var(--card))] text-muted-foreground hover:text-foreground')}
>
{n}
</button>
))}
</dd>
</div>
</dl>
<div className="grid gap-1.5">
<label htmlFor={`${id}-desc`} className={lbl}>
{t.description}
</label>
<textarea id={`${id}-desc`} rows={3} value={card.description ?? ''} placeholder={t.descPlaceholder} onChange={(e) => update({ description: e.target.value })} className={cn(field, 'h-auto min-h-20 resize-y py-2 leading-relaxed sm:h-auto')} />
</div>
<section aria-labelledby={`${id}-cl`}>
<div className="flex items-center gap-3">
<h3 id={`${id}-cl`} className="text-[13.5px] font-semibold text-foreground">
{t.checklistTitle}
</h3>
<span className="text-[12px] tabular-nums text-muted-foreground">{fill(t.checklist, { done, n: list.length })}</span>
</div>
<span aria-hidden className="mt-2 block h-1.5 overflow-hidden rounded-full bg-track">
<span className={cn('block h-full rounded-full transition-[width] duration-500 ease-[var(--ease-kit)]', done === list.length && list.length ? 'bg-success' : 'bg-primary')} style={{ width: `${round(pct, 1)}%` }} />
</span>
<ul className="mt-2 grid">
{list.map((x) => (
<li key={x.id} className="kbn-in group/item flex items-center gap-1 rounded-md hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)]">
<Check on={x.done} label={x.text} onChange={() => update({ checklist: list.map((y) => (y.id === x.id ? { ...y, done: !y.done } : y)) })} />
<span className={cn('min-w-0 flex-1 text-[13.5px] transition-colors', x.done ? 'text-subtle-foreground line-through decoration-[color-mix(in_oklab,var(--foreground)_35%,transparent)]' : 'text-foreground')}>{x.text}</span>
<button type="button" aria-label={fill(t.removeItem, { item: x.text })} onClick={() => update({ checklist: list.filter((y) => y.id !== x.id) })} className={cn(iconBtn, 'text-subtle-foreground hover:text-destructive')}>
<Icon name="x" size={14} />
</button>
</li>
))}
</ul>
<form
className="mt-2 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (!item.trim()) return;
update({ checklist: [...list, { id: `i${Date.now()}`, text: item.trim(), done: false }] });
setItem('');
}}
>
<label htmlFor={`${id}-item`} className="sr-only">
{t.addItem}
</label>
<input id={`${id}-item`} value={item} onChange={(e) => setItem(e.target.value)} placeholder={t.itemPlaceholder} className={cn(field, 'w-0 flex-1')} />
<Button type="submit" size="sm" variant="outline" iconLeft="plus" className="max-sm:h-11">
<span className="max-sm:sr-only">{t.addItem}</span>
</Button>
</form>
</section>
<section aria-labelledby={`${id}-cm`}>
<h3 id={`${id}-cm`} className="text-[13.5px] font-semibold text-foreground">
{t.commentsTitle}
</h3>
<ol className="mt-3 grid gap-4">
{(card.comments ?? []).map((c) => {
const p = ctx.person(c.author);
return (
<li key={c.id} className="kbn-in flex gap-3">
<Avatar name={p?.name ?? '?'} size="sm" />
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-baseline gap-x-2 text-[13px]">
<span className="font-semibold text-foreground">{p?.name}</span>
<time dateTime={c.time} className="text-[12px] text-subtle-foreground">
{ago(locale, c.time, now, t)}
</time>
</p>
<p className="mt-0.5 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{c.text}</p>
</div>
</li>
);
})}
</ol>
<form
className="mt-4 grid gap-2"
onSubmit={(e) => {
e.preventDefault();
if (!comment.trim()) return;
update({ comments: [...(card.comments ?? []), { id: `m${Date.now()}`, author: me ?? people[0]?.id ?? '', text: comment.trim(), time: now }] }, 'comment');
setComment('');
}}
>
<label htmlFor={`${id}-comment`} className="sr-only">
{t.commentPlaceholder}
</label>
<textarea id={`${id}-comment`} rows={2} value={comment} onChange={(e) => setComment(e.target.value)} placeholder={t.commentPlaceholder} className={cn(field, 'h-auto resize-none py-2 sm:h-auto')} />
<Button type="submit" size="sm" iconLeft="send" disabled={!comment.trim()} className="justify-self-end max-sm:h-11 max-sm:w-full">
{t.post}
</Button>
</form>
</section>
</div>
);
}
/* ------------------------------------------------------------------ */
/* KanbanBoard */
/* ------------------------------------------------------------------ */
/**
* A kanban board for apps: columns with work in progress limits, cards that
* move by pointer, touch, keyboard and menu, swimlanes, a sprint with
* burndown, a detail panel with checklist and comments, and dense filters.
* <KanbanBoard variant="board" title="Website relaunch" columns={…} cards={…} people={…} tags={…} />
*/
export function KanbanBoard({ variant = 'board', sidebar, labels, className, today = '2026-10-21', ...props }: KanbanBoardProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const { title, subtitle, columns = [], tags = [], people = [], lanes: laneMode = 'person', sprint, me, onAction } = props;
const [cards, setCardsRaw] = useState<KanbanCard[]>(props.cards ?? []);
const [sel, setSel] = useState<string | null>(variant === 'card-detail' ? props.selected ?? props.cards?.[0]?.id ?? null : null);
const [sheet, setSheet] = useState(false);
const [addReq, setAddReq] = useState<{ col: string; n: number } | null>(null);
const [q, setQ] = useState('');
const [fTags, setFTags] = useState<string[]>([]);
const [fPeople, setFPeople] = useState<string[]>([]);
const [sprintState, setSprintState] = useState<'open' | 'busy' | 'done'>('open');
const wide = useWide();
const now = `${today}T15:30`;
const setCards = (next: KanbanCard[] | ((c: KanbanCard[]) => KanbanCard[])) => setCardsRaw((c) => (typeof next === 'function' ? next(c) : next));
const ctx: Ctx = useMemo(() => ({ t, locale, today, tag: (i) => tags.find((x) => x.id === i), person: (i) => people.find((x) => x.id === i) }), [t, locale, today, tags, people]);
const doneCols = columns.filter((c) => c.done).map((c) => c.id);
const lastCol = columns[columns.length - 1]?.id;
const isDone = (c: KanbanCard) => (doneCols.length ? doneCols.includes(c.column) : c.column === lastCol);
const newCard = (
<Button size="sm" iconLeft="plus" magnetic className="max-sm:w-full" onClick={() => setAddReq({ col: columns[0]?.id ?? '', n: Date.now() })}>
{t.newCard}
</Button>
);
const team = people.length > 0 && (
<span className="flex -space-x-2 max-sm:hidden" title={people.map((p) => p.name).join(', ')}>
{people.slice(0, 5).map((p) => (
<span key={p.id} className="rounded-full ring-2 ring-background">
<Avatar name={p.name} size="sm" />
</span>
))}
</span>
);
let body: React.ReactNode;
if (variant === 'swimlanes') {
const lanes: Lane[] =
laneMode === 'priority'
? (['urgent', 'high', 'medium', 'low'] as const).map((p): Lane => ({ id: p, label: t[p] })).concat(cards.some((c) => !c.priority) ? [{ id: 'none', label: t.none }] : [])
: people.map((p): Lane => ({ id: p.id, label: p.name, person: p.id })).concat(cards.some((c) => !c.assignees?.length) ? [{ id: NONE, label: t.unassigned }] : []);
body = (
<>
<Header title={title} subtitle={subtitle}>
{team}
{newCard}
</Header>
<Board
ctx={ctx}
columns={columns}
cards={cards}
setCards={setCards}
lanes={lanes}
laneOf={(c) => (laneMode === 'priority' ? c.priority ?? 'none' : c.assignees?.[0] ?? NONE)}
toLane={(c, lane) => (laneMode === 'priority' ? { ...c, priority: lane === 'none' ? undefined : (lane as KanbanPriority) } : { ...c, assignees: lane ? [lane, ...(c.assignees ?? []).filter((x) => x !== lane).slice(1)] : [] })}
onAction={onAction}
addRequest={addReq}
/>
</>
);
} else if (variant === 'sprint') {
const total = cards.reduce((s, c) => s + (c.points ?? 0), 0);
const done = cards.filter(isDone).reduce((s, c) => s + (c.points ?? 0), 0);
const days: string[] = [];
if (sprint) {
for (let d = dayNum(sprint.start); d <= dayNum(sprint.end); d++) {
const wd = new Date(d * dayMs).getUTCDay();
if (wd !== 0 && wd !== 6) days.push(new Date(d * dayMs).toISOString().slice(0, 10));
}
}
const ti = days.findIndex((d) => d >= today);
const past = sprint?.remaining ?? [];
const data = days.map((d, i) => ({
day: d,
ideal: round(total - (total * i) / Math.max(1, days.length - 1), 1),
actual: i < Math.min(past.length, ti < 0 ? days.length : ti) ? past[i] : i === ti ? total - done : null,
}));
const left = ti < 0 ? 0 : days.length - ti;
const pct = total ? (done / total) * 100 : 0;
const openCards = cards.filter((c) => !isDone(c)).length;
const elapsed = Math.max(1, ti < 0 ? days.length : ti + 1);
const gap = ti >= 0 ? Math.round(total - done - (data[ti]?.ideal ?? 0)) : 0;
const stat = 'text-[12px] text-muted-foreground';
const num = 'mt-0.5 text-[17px] font-semibold tabular-nums tracking-[-0.01em] text-foreground';
body = (
<>
<Header title={title} subtitle={subtitle}>
{team}
<Button
size="sm"
variant="outline"
iconLeft={sprintState === 'busy' ? 'loader' : sprintState === 'done' ? 'check' : 'flag'}
disabled={sprintState !== 'open'}
className="max-sm:w-full"
onClick={async () => {
setSprintState('busy');
if (onAction) await onAction('complete-sprint', { done, total });
else await new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : 800));
setSprintState('done');
}}
>
{sprintState === 'busy' ? t.completing : t.completeSprint}
</Button>
</Header>
{sprint && (
<section aria-label={sprint.name} className="kit-rise mt-6 grid grid-cols-[minmax(0,1fr)] overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<div className="flex flex-col gap-4 p-4 sm:p-5">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-[15px] font-semibold text-foreground">{sprint.name}</h2>
<span className="text-[13px] tabular-nums text-muted-foreground">
{fmtDay(sprint.start, locale)}–{fmtDay(sprint.end, locale)}
</span>
<span className="inline-flex h-6 items-center gap-1 rounded-md bg-[color-mix(in_oklab,var(--primary)_11%,var(--card))] px-2 text-[12px] font-semibold text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
<Icon name="hourglass" size={12} />
{left <= 1 ? t.lastDay : fill(t.daysLeft, { n: left })}
</span>
</div>
{sprint.goal && (
<p className="text-pretty text-[14px] leading-relaxed text-foreground">
<span className="me-1.5 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{t.goal}</span>
{sprint.goal}
</p>
)}
<dl className="grid grid-cols-3 gap-3 border-y border-border py-3">
<div>
<dt className={stat}>{t.cardsDone}</dt>
<dd className={num}>
{cards.length - openCards}/{cards.length}
</dd>
</div>
<div>
<dt className={stat}>{t.velocity}</dt>
<dd className={num}>{new Intl.NumberFormat(tag(locale), { maximumFractionDigits: 1 }).format(round(done / elapsed, 1))}</dd>
</div>
<div>
<dt className={stat}>{t.pace}</dt>
<dd className={cn(num, 'text-[14px] leading-[1.6]', gap > 0 ? 'text-warning-foreground' : 'text-success-foreground')}>{gap > 0 ? fill(t.behind, { n: gap }) : t.onTrack}</dd>
</div>
</dl>
<div className="mt-auto">
<div className="flex items-baseline justify-between gap-3">
<p className="text-[13px] text-muted-foreground" aria-live="polite">
{fill(t.pointsDone, { done, total })}
</p>
<p className="text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">{formatPercent(Math.round(pct), locale)}</p>
</div>
<span aria-hidden className="mt-2 block h-2 overflow-hidden rounded-full bg-track">
<span className="kbn-grow block h-full rounded-full bg-primary transition-[width] duration-700 ease-[var(--ease-kit)]" style={{ width: `${round(pct, 1)}%` }} />
</span>
</div>
{sprintState === 'done' && (
<p role="status" className="kbn-in flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
<Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
{fill(t.completed, { n: openCards })}
</p>
)}
</div>
<div className="border-t border-border p-4 sm:p-5 lg:border-s lg:border-t-0">
<LineChart label={t.burndown} data={data} xKey="day" xFormat={{ weekday: 'short', day: 'numeric' }} tooltipXFormat={{ weekday: 'long', day: 'numeric', month: 'long' }} series={[{ key: 'actual', label: t.remaining }, { key: 'ideal', label: t.ideal, dashed: true }]} height={168} legend />
</div>
</section>
)}
<Board ctx={ctx} columns={columns} cards={cards} setCards={setCards} onAction={onAction} addRequest={addReq} headExtra={(_, list) => <span className="me-1 text-[12px] font-medium tabular-nums text-subtle-foreground">{fill(t.pts, { n: list.reduce((s, c) => s + (c.points ?? 0), 0) })}</span>} />
</>
);
} else if (variant === 'card-detail') {
const card = cards.find((c) => c.id === sel);
const update = (patch: Partial<KanbanCard>, action = 'update') => {
if (!card) return;
setCards((cs) => cs.map((c) => (c.id === card.id ? { ...c, ...patch } : c)));
void onAction?.(action, { id: card.id, ...patch });
};
const open = (cid: string) => {
setSel(cid);
if (!wide) setSheet(true);
};
const panel = card ? <Detail key={card.id} card={card} ctx={ctx} columns={columns} tags={tags} people={people} me={me} now={now} update={update} /> : null;
body = (
<>
<Header title={title} subtitle={subtitle}>
{team}
{newCard}
</Header>
<div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_23rem] xl:grid-cols-[minmax(0,1fr)_26rem]">
{/* The board scrolls sideways under its own edge, never under the detail panel */}
<div className="min-w-0 lg:[&_[data-scroller]]:mx-0 lg:[&_[data-scroller]]:px-0">
<Board ctx={ctx} columns={columns} cards={cards} setCards={setCards} onAction={onAction} addRequest={addReq} selected={sel} onOpen={open} openOnEnter />
</div>
<aside aria-label={t.details} className="kit-rise hidden min-w-0 lg:block">
<div className="kit-scrollbar sticky top-6 mt-5 max-h-[calc(100dvh-3rem)] overflow-y-auto overscroll-contain rounded-xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)]">
{panel ?? <p className="py-10 text-center text-[13.5px] text-muted-foreground">{t.noCard}</p>}
</div>
</aside>
</div>
{!wide && (
<Sheet open={sheet} onOpenChange={setSheet} side="right" title={card?.key ? `${t.details} · ${card.key}` : t.details}>
{panel}
</Sheet>
)}
</>
);
} else if (variant === 'compact') {
const ql = q.trim().toLowerCase();
const visible = (c: KanbanCard) => (!ql || c.title.toLowerCase().includes(ql) || (c.key ?? '').toLowerCase().includes(ql)) && (!fTags.length || (c.tags ?? []).some((x) => fTags.includes(x))) && (!fPeople.length || (c.assignees ?? []).some((x) => fPeople.includes(x)));
const shown = cards.filter(visible).length;
const filtered = !!ql || fTags.length > 0 || fPeople.length > 0;
const usedTags = tags.filter((x) => cards.some((c) => c.tags?.includes(x.id)));
body = (
<>
<Header title={title} subtitle={subtitle}>
{newCard}
</Header>
<div className="kit-rise mt-5 flex flex-col gap-3 rounded-xl border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)] lg:flex-row lg:items-center">
<label className="relative block lg:w-60">
<span className="sr-only">{t.search}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(field, 'ps-9')} />
</label>
<div role="group" aria-label={t.filterLabels} className="kit-no-scrollbar -mx-3 flex gap-1.5 overflow-x-auto px-3 lg:mx-0 lg:flex-wrap lg:px-0">
{usedTags.map((x) => {
const on = fTags.includes(x.id);
return (
<button key={x.id} type="button" aria-pressed={on} aria-label={fill(t.showLabel, { name: x.name })} onClick={() => setFTags((s) => (on ? s.filter((y) => y !== x.id) : [...s, x.id]))} className={cn('inline-flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-medium transition-[opacity,box-shadow] sm:h-8', ring, TONE[x.tone ?? 'neutral'], on ? 'shadow-[inset_0_0_0_1.5px_currentColor]' : 'opacity-70 hover:opacity-100')}>
{on && <Icon name="check" size={12} />}
{x.name}
<span className="tabular-nums opacity-70">{cards.filter((c) => c.tags?.includes(x.id)).length}</span>
</button>
);
})}
</div>
<div role="group" aria-label={t.filterPeople} className="flex items-center gap-1 lg:ms-auto">
{people.map((p) => {
const on = fPeople.includes(p.id);
return (
<button key={p.id} type="button" aria-pressed={on} aria-label={fill(t.showOf, { name: p.name })} title={p.name} onClick={() => setFPeople((s) => (on ? s.filter((y) => y !== p.id) : [...s, p.id]))} className={cn('grid size-11 cursor-pointer place-items-center rounded-full transition-[opacity,transform] hover:-translate-y-0.5 sm:size-9', ring, fPeople.length && !on && 'opacity-45')}>
<span className={cn('rounded-full', on && 'ring-2 ring-primary ring-offset-2 ring-offset-card')}>
<Avatar name={p.name} size="sm" />
</span>
</button>
);
})}
<span className="ms-2 whitespace-nowrap text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
{fill(t.showing, { n: shown, total: cards.length })}
</span>
{filtered && (
<Button
size="sm"
variant="ghost"
className="ms-1"
onClick={() => {
setQ('');
setFTags([]);
setFPeople([]);
}}
>
{t.clear}
</Button>
)}
</div>
</div>
<Board ctx={ctx} columns={columns} cards={cards} setCards={setCards} onAction={onAction} addRequest={addReq} visible={visible} dense />
</>
);
} else {
body = (
<>
<Header title={title} subtitle={subtitle}>
{team}
{newCard}
</Header>
<Board ctx={ctx} columns={columns} cards={cards} setCards={setCards} onAction={onAction} addRequest={addReq} />
</>
);
}
const content = (
<>
<Style />
<Main>{body}</Main>
</>
);
if (!sidebar) return <div className={cn('min-h-dvh bg-background', className)}>{content}</div>;
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
{content}
</Sidebar>
);
}Frequent questions
How do I install Kanban board?
With aiw add @ai-website/kanban-board. The command puts components/ui/kanban-board.tsx into your project, the code is yours from then on. It brings along avatar, button, dropdown-menu, icon, line-chart, motion, sheet and sidebar. It needs no further npm packages.
Which variants does Kanban board have?
5 variants, Board, Swimlanes, Sprint, Card details and Compact with filters. You pick one with the variant prop, for example variant="swimlanes".
Can I translate the texts of Kanban board?
Yes. Kanban board carries 90 texts of its own in English and German, such as “New card”, “Add card” and “What needs doing?”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Kanban board?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Kanban board with aiw_components and install it with aiw_add @ai-website/kanban-board. Name the variant in your prompt, for example variant="board", and the assistant writes your content straight into the props.