Kanban Board für React
Kanban-Boards mit Limits je Spalte, Karten per Maus, Touch, Tastatur oder Menü verschieben, Swimlanes, Sprint mit Burndown, Kartendetails und Filter.
Karten, die jeder Eingabe folgen
Ein Kanban-Board zeigt Arbeit als Karten, die von Spalte zu Spalte wandern, und das ganze Team sieht, wo alles steht. Dieses bewegt Karten per Maus, Touch, Tastatur und Menü, eine Karte mit der Leertaste aufnehmen und mit den Pfeiltasten verschieben klappt also genauso gut wie Ziehen.
Das „Board“ hat Spalten mit Limits, die warnen, einklappen und neue Karten aufnehmen. „Swimlanes“ ergänzt eine Zeile je Person oder Priorität, eine Karte in eine andere Zeile zu ziehen übergibt sie oder ändert ihre Priorität. „Sprint“ setzt Zeitraum, Ziel, Resttage und erledigte Punkte nach oben, dazu ein Burndown gegen die Ideallinie und Punkte je Spalte, die jeder Bewegung folgen. „Kartendetails“ öffnet eine Karte neben dem Board mit Status, Personen, Fälligkeit, Labels, Punkten, Checkliste und Kommentaren, am Telefon als Sheet. „Kompakt mit Filtern“ packt lange Boards dicht und filtert nach Suche, Label und Person mit laufender Zahl.
Setz Limits auf die Spalten, in denen sich Arbeit staut, oft die Prüfung, dann zeigt die Warnung einen Stau, bevor er bremst. Halte die Labels bei wenigen Farben. Das Board nimmt Spalten, Karten, Personen und Labels als Props und zeigt deine Daten ab dem ersten Rendern.
Variants
Details
Was Kanban-Board mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Aufgaben und Planung
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/kanban-board.tsx
- Bringt mit
- utilskitavatarbuttondropdown-menuiconline-chartmotionsheetsidebar
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 90 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- SaaS-App-Screens
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle KanbanBoardProps aus components/ui/kanban-board.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalKanbanBoardVariantboardswimlanessprintcard-detailcompact
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringcolumnsoptionalKanbanColumn[]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.
Eingebaute Texte
Diese Texte schreibt Kanban-Board selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 90 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| newCard | Englisch New card | Deutsch Neue Karte |
| addCard | Englisch Add card | Deutsch Karte hinzufügen |
| addCardTo | Englisch Add a card to {column} | Deutsch Karte zu {column} hinzufügen |
| cardTitle | Englisch What needs doing? | Deutsch Was steht an? |
| add | Englisch Add card | Deutsch Karte anlegen |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| added | Englisch {card} added to {column}. | Deutsch {card} in {column} angelegt. |
| countOf | Englisch {n} of {limit} | Deutsch {n} von {limit} |
| overLimit | Englisch {n} over the limit | Deutsch {n} über dem Limit |
| atLimit | Englisch At the limit | Deutsch Limit erreicht |
| collapse | Englisch Collapse {column} | Deutsch {column} einklappen |
| expand | Englisch Expand {column} | Deutsch {column} ausklappen |
| moveTo | Englisch Move to | Deutsch Verschieben nach |
| moveUp | Englisch Move up | Deutsch Nach oben |
| moveDown | Englisch Move down | Deutsch Nach unten |
| openCard | Englisch Open details | Deutsch Details öffnen |
| cardMenu | Englisch Actions for {card} | Deutsch Aktionen für {card} |
| dragHint | Englisch Press Space or Enter to pick up a card. The arrow keys move it, Space or Enter drops it, Escape puts it back. | Deutsch Leertaste oder Enter nimmt eine Karte auf. Die Pfeiltasten verschieben sie, Leertaste oder Enter legt sie ab, Escape legt sie zurück. |
| dragHintDetail | Englisch Press Enter to open a card and Space to pick it up. The arrow keys move it, Space drops it, Escape puts it back. | Deutsch Enter öffnet eine Karte, die Leertaste nimmt sie auf. Die Pfeiltasten verschieben sie, die Leertaste legt sie ab, Escape legt sie zurück. |
| grabbed | Englisch {card} picked up. {column}, position {pos} of {n}. | Deutsch {card} aufgenommen. {column}, Position {pos} von {n}. |
| movedKb | Englisch {column}, position {pos} of {n}. | Deutsch {column}, Position {pos} von {n}. |
| dropped | Englisch {card} dropped in {column}, position {pos} of {n}. | Deutsch {card} in {column} abgelegt, Position {pos} von {n}. |
| moved | Englisch {card} moved to {column}. | Deutsch {card} nach {column} verschoben. |
| cancelled | Englisch {card} is back in {column}. | Deutsch {card} liegt wieder in {column}. |
| overNote | Englisch {column} is over its limit. | Deutsch {column} ist über dem Limit. |
| draggable | Englisch draggable card | Deutsch verschiebbare Karte |
| overdue | Englisch Overdue | Deutsch Überfällig |
| dueToday | Englisch Today | Deutsch Heute |
| dueTomorrow | Englisch Tomorrow | Deutsch Morgen |
| dueOn | Englisch Due {date} | Deutsch Fällig {date} |
| points | Englisch {n} points | Deutsch {n} Punkte |
| pts | Englisch {n} pts | Deutsch {n} Pkt. |
| checklist | Englisch {done} of {n} done | Deutsch {done} von {n} erledigt |
| comments | Englisch {n} comments | Deutsch {n} Kommentare |
| assigned | Englisch Assigned to {names} | Deutsch Zugewiesen an {names} |
| unassigned | Englisch Not assigned | Deutsch Nicht zugewiesen |
| priority | Englisch Priority {p} | Deutsch Priorität {p} |
| urgent | Englisch Urgent | Deutsch Dringend |
| high | Englisch High | Deutsch Hoch |
| medium | Englisch Medium | Deutsch Mittel |
| low | Englisch Low | Deutsch Niedrig |
| none | Englisch No priority | Deutsch Ohne Priorität |
| columns | Englisch Columns | Deutsch Spalten |
| dropHere | Englisch Drop here | Deutsch Hier ablegen |
| empty | Englisch Nothing here yet | Deutsch Noch leer |
| noMatch | Englisch No card matches | Deutsch Keine Karte passt |
| undo | Englisch Undo | Deutsch Rückgängig |
| lane | Englisch {n} cards | Deutsch {n} Karten |
| collapseLane | Englisch Collapse {lane} | Deutsch {lane} einklappen |
| expandLane | Englisch Expand {lane} | Deutsch {lane} ausklappen |
| daysLeft | Englisch {n} days left | Deutsch Noch {n} Tage |
| lastDay | Englisch Last day | Deutsch Letzter Tag |
| burndown | Englisch Burndown | Deutsch Burndown |
| ideal | Englisch Ideal | Deutsch Ideal |
| remaining | Englisch Open points | Deutsch Offene Punkte |
| pointsDone | Englisch {done} of {total} points done | Deutsch {done} von {total} Punkten erledigt |
| completeSprint | Englisch Complete sprint | Deutsch Sprint abschließen |
| completing | Englisch Closing… | Deutsch Wird abgeschlossen… |
| completed | Englisch Sprint closed. {n} open cards move to the next sprint. | Deutsch Sprint abgeschlossen. {n} offene Karten gehen in den nächsten Sprint. |
| goal | Englisch Sprint goal | Deutsch Sprintziel |
| cardsDone | Englisch Cards done | Deutsch Karten erledigt |
| velocity | Englisch Points a day | Deutsch Punkte am Tag |
| pace | Englisch Pace | Deutsch Tempo |
| onTrack | Englisch On track | Deutsch Im Plan |
| behind | Englisch {n} points behind | Deutsch {n} Punkte zurück |
| details | Englisch Card details | Deutsch Kartendetails |
| title | Englisch Title | Deutsch Titel |
| status | Englisch Status | Deutsch Status |
| assignees | Englisch People | Deutsch Personen |
| dueDate | Englisch Due date | Deutsch Fällig am |
| labelsTitle | Englisch Labels | Deutsch Labels |
| estimate | Englisch Points | Deutsch Punkte |
| description | Englisch Description | Deutsch Beschreibung |
| descPlaceholder | Englisch Add the details the team needs | Deutsch Ergänze, was das Team wissen muss |
| checklistTitle | Englisch Checklist | Deutsch Checkliste |
| addItem | Englisch Add item | Deutsch Punkt hinzufügen |
| itemPlaceholder | Englisch Add an item | Deutsch Punkt hinzufügen |
| removeItem | Englisch Remove {item} | Deutsch {item} entfernen |
| commentsTitle | Englisch Comments | Deutsch Kommentare |
| commentPlaceholder | Englisch Write a comment | Deutsch Kommentar schreiben |
| post | Englisch Post comment | Deutsch Kommentar senden |
| noCard | Englisch Pick a card to see its details here. | Deutsch Wähle eine Karte, dann stehen hier ihre Details. |
| search | Englisch Search cards | Deutsch Karten suchen |
| filterLabels | Englisch Filter by label | Deutsch Nach Label filtern |
| filterPeople | Englisch Filter by person | Deutsch Nach Person filtern |
| showing | Englisch {n} of {total} cards | Deutsch {n} von {total} Karten |
| clear | Englisch Show all | Deutsch Alle zeigen |
| showOf | Englisch Show cards of {name} | Deutsch Karten von {name} zeigen |
| showLabel | Englisch Show cards with {name} | Deutsch Karten mit {name} zeigen |
| just | Englisch just now | Deutsch gerade eben |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Kanban-Board reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Tab und Leertaste.
- Setzt aria-checked, aria-current, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed und aria-roledescription, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen button, checkbox, group, radio, radiogroup und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.791 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Kanban-Board?
Mit aiw add @ai-website/kanban-board. Der Befehl legt die Datei components/ui/kanban-board.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, button, dropdown-menu, icon, line-chart, motion, sheet und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Kanban-Board?
5 Varianten, Board, Swimlanes, Sprint, Kartendetails und Kompakt mit Filtern. Du wählst sie mit der Prop variant, zum Beispiel variant="swimlanes".
Kann ich die Texte von Kanban-Board übersetzen?
Ja. Kanban-Board bringt 90 eigene Texte auf Deutsch und Englisch mit, etwa „Neue Karte“, „Karte hinzufügen“ und „Was steht an?“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Kanban-Board einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kanban-Board mit aiw_components und installieren sie mit aiw_add @ai-website/kanban-board. Nenn im Prompt die Variante, etwa variant="board", dann schreibt der Assistent deine Inhalte direkt in die Props.