Reorder
Drag-to-arrange surfaces — a kanban board, a sortable list and a swap-in-place grid.
Kanban — A board of columns (or horizontal lanes) with cards that lift and follow the pointer, a live placeholder and cards that glide out of the way; keyboard reordering, soft status pills with a per-status tint, WIP limits, identifiers, priorities, labels, leading icons, assignees (with optional names), due dates, date ranges, amounts, plain/outline variants, and move/change events with hidden inputs for saving.
Preview
{{-- Drag a card: it lifts and follows the pointer while the others glide out of
the way; release settles it. Status glyphs, WIP limits (In progress is at
its limit), identifiers, priorities, labels, assignees and due dates. --}}
@php
$kanbanColumns = [
['id' => 'backlog', 'title' => 'Backlog', 'status' => 'backlog', 'cards' => [
['id' => 'b1', 'title' => 'Audit the empty states', 'identifier' => 'ENG-148', 'priority' => 'low', 'labels' => [['name' => 'Design', 'tone' => 'primary']]],
['id' => 'b2', 'title' => 'Rate-limit the search index', 'identifier' => 'ENG-151', 'priority' => 'medium', 'labels' => [['name' => 'Infra', 'tone' => 'info']], 'assignee' => ['name' => 'Priya Nair']],
]],
['id' => 'todo', 'title' => 'Todo', 'status' => 'todo', 'limit' => 4, 'cards' => [
['id' => 't1', 'title' => 'Draft release notes', 'description' => 'Summarise the audit fixes and the new pickers.', 'identifier' => 'ENG-142', 'priority' => 'high', 'labels' => [['name' => 'Docs', 'tone' => 'neutral']], 'assignee' => ['name' => 'Avery Stone'], 'due' => 'Sep 24'],
['id' => 't2', 'title' => 'Triage open issues', 'identifier' => 'ENG-143', 'priority' => 'medium', 'assignee' => ['name' => 'Jordan Lee']],
['id' => 't3', 'title' => 'Migrate the starter to the new form guard', 'identifier' => 'ENG-146', 'priority' => 'low', 'labels' => [['name' => 'Starter', 'tone' => 'success']]],
]],
['id' => 'doing', 'title' => 'In progress', 'status' => 'in-progress', 'limit' => 2, 'cards' => [
['id' => 'd1', 'title' => 'Wire up the registry guard', 'description' => 'Diff the registry directories against the catalog.', 'identifier' => 'ENG-139', 'priority' => 'urgent', 'labels' => [['name' => 'Bug', 'tone' => 'destructive']], 'assignee' => ['name' => 'Sasha Okafor'], 'due' => 'Today'],
]],
['id' => 'done', 'title' => 'Done', 'status' => 'done', 'cards' => [
['id' => 'c1', 'title' => 'Surface the orphaned component', 'identifier' => 'ENG-131', 'priority' => 'medium', 'assignee' => ['name' => 'Avery Stone']],
['id' => 'c2', 'title' => 'Ship the date range picker', 'identifier' => 'ENG-127', 'priority' => 'high', 'labels' => [['name' => 'Release', 'tone' => 'warning']]],
]],
];
@endphp
<x-ui.kanban :columns="$kanbanColumns" addable name="board" />
Orientation options
Variant options
Installation
php artisan ui:add kanban
Note
This component ships an Alpine behavior module at
resources/js/ui/kanban.js. Import it once from your bundle so it registers on alpine:init:
import './kanban.js';
Registry contract
php artisan ui:add kanban
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/kanban.blade.php -
resources/js/ui/kanban.js -
resources/views/components/ui/kanban/card-body.blade.php
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Kanban (`kanban`)
A board of columns (or horizontal lanes) with cards that lift and follow the pointer, a live placeholder and cards that glide out of the way; keyboard reordering, soft status pills with a per-status tint, WIP limits, identifiers, priorities, labels, leading icons, assignees (with optional names), due dates, date ranges, amounts, plain/outline variants, and move/change events with hidden inputs for saving.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add kanban
```
## Usage
```blade
{{-- Drag a card: it lifts and follows the pointer while the others glide out of
the way; release settles it. Status glyphs, WIP limits (In progress is at
its limit), identifiers, priorities, labels, assignees and due dates. --}}
@php
$kanbanColumns = [
['id' => 'backlog', 'title' => 'Backlog', 'status' => 'backlog', 'cards' => [
['id' => 'b1', 'title' => 'Audit the empty states', 'identifier' => 'ENG-148', 'priority' => 'low', 'labels' => [['name' => 'Design', 'tone' => 'primary']]],
['id' => 'b2', 'title' => 'Rate-limit the search index', 'identifier' => 'ENG-151', 'priority' => 'medium', 'labels' => [['name' => 'Infra', 'tone' => 'info']], 'assignee' => ['name' => 'Priya Nair']],
]],
['id' => 'todo', 'title' => 'Todo', 'status' => 'todo', 'limit' => 4, 'cards' => [
['id' => 't1', 'title' => 'Draft release notes', 'description' => 'Summarise the audit fixes and the new pickers.', 'identifier' => 'ENG-142', 'priority' => 'high', 'labels' => [['name' => 'Docs', 'tone' => 'neutral']], 'assignee' => ['name' => 'Avery Stone'], 'due' => 'Sep 24'],
['id' => 't2', 'title' => 'Triage open issues', 'identifier' => 'ENG-143', 'priority' => 'medium', 'assignee' => ['name' => 'Jordan Lee']],
['id' => 't3', 'title' => 'Migrate the starter to the new form guard', 'identifier' => 'ENG-146', 'priority' => 'low', 'labels' => [['name' => 'Starter', 'tone' => 'success']]],
]],
['id' => 'doing', 'title' => 'In progress', 'status' => 'in-progress', 'limit' => 2, 'cards' => [
['id' => 'd1', 'title' => 'Wire up the registry guard', 'description' => 'Diff the registry directories against the catalog.', 'identifier' => 'ENG-139', 'priority' => 'urgent', 'labels' => [['name' => 'Bug', 'tone' => 'destructive']], 'assignee' => ['name' => 'Sasha Okafor'], 'due' => 'Today'],
]],
['id' => 'done', 'title' => 'Done', 'status' => 'done', 'cards' => [
['id' => 'c1', 'title' => 'Surface the orphaned component', 'identifier' => 'ENG-131', 'priority' => 'medium', 'assignee' => ['name' => 'Avery Stone']],
['id' => 'c2', 'title' => 'Ship the date range picker', 'identifier' => 'ENG-127', 'priority' => 'high', 'labels' => [['name' => 'Release', 'tone' => 'warning']]],
]],
];
@endphp
<x-ui.kanban :columns="$kanbanColumns" addable name="board" />
```
## Props
- `columns` (array, default `[…]`) — Array of columns, each with id, title, an optional status (backlog/todo/in-progress/done/cancelled), an optional limit, and a cards array. A card may carry icon (folder/file/doc), identifier, priority, labels, assignee ({name, avatar}), showAssignee, due, start, end, amount and href — every key is an opt-in; the card renders only the ones it sets. With href the title becomes a link whose overlay makes the whole card clickable. Only relative, http(s) and mailto hrefs are kept.
- `orientation` (string, default `vertical`) — vertical lays columns side by side; horizontal renders them as stacked swimlanes.
- `variant` (default|plain|outline, default `default`) — Column surface style: default (tinted background), plain (headers directly on the page), or outline (bordered columns).
- `tint` (none|status, default `none`) — status faintly tints each column's background, and the header pill, with that column's status tone (reusing the status/badge soft tones); none leaves the variant's own surface alone.
- `density` (string, default `comfortable`) — Card padding: comfortable or compact.
- `addable` (bool, default `false`) — Shows a + button in each column header that dispatches a kanban:add event with the column id.
- `name` (mixed|null, default `null`) — When set, posts each column's card ids as name[column][] hidden inputs, in order, for plain form submission.
- `assigneeNames` (bool, default `false`) — Shows every card's assignee name beside its avatar. A single card can opt in on its own with a showAssignee: true key even when this is off.
- `readonly` (bool, default `false`) — Cards cannot be moved by pointer or keyboard, for a viewer without permission to reorder; they stay focusable and clickable, with no grab cursor and no aria-grabbed state.
- `fill` (bool, default `false`) — Vertical boards: the columns take the kanban's full height and each card list scrolls inside it, instead of stopping at 24rem. Give the kanban a height (a class, or min-h-0 flex-1 in a flex column).
- `overflowCue` (bool, default `false`) — Vertical boards: fade the edge where more columns are to the side (a mask, RTL-aware). The root carries data-overflow-start / data-overflow-end either way, and scrollByColumn(1 | -1) scrolls by one column for host buttons.
- `ref` (string, default `card', 'link' => true, 'assigneeNames' => false`) — Declared by @props in the registry Blade source.
## Use when
- Use to group related content into scannable units for browsing, prioritization, or rearrangement.
- Building a drag-and-drop board of work items organized into status columns or swimlanes.
- Needing built-in WIP limits, priorities, labels, assignees, and due dates on cards, plus keyboard-accessible reordering.
## Avoid when
- Do not use card-like structures when dense row-by-row comparison would be clearer in a list or table.
- The items are a single ordered list with no columns; use sortable instead.
- The layout is a uniform grid of draggable tiles with no status grouping; use swappable-grid.
## Anti-patterns
- Turning every object into a card
## Rules
- Use the `<brok:kanban>` tag (or `<x-ui.kanban>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/kanban
- Registry JSON (files, props, contract): https://brokui.dev/r/open/kanban.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- `fill`: the card lists use the board's full height instead of stopping at
24rem. `overflow-cue` fades the edge where more columns are to the side;
the two buttons scroll by one column with scrollByColumn(). --}}
@php
$kanbanColumns = [
['id' => 'backlog', 'title' => 'Backlog', 'status' => 'backlog', 'cards' => [
['id' => 'b1', 'title' => 'Audit the empty states', 'identifier' => 'ENG-148', 'priority' => 'low', 'labels' => [['name' => 'Design', 'tone' => 'primary']]],
['id' => 'b2', 'title' => 'Rate-limit the search index', 'identifier' => 'ENG-151', 'priority' => 'medium', 'labels' => [['name' => 'Infra', 'tone' => 'info']], 'assignee' => ['name' => 'Priya Nair']],
]],
['id' => 'todo', 'title' => 'Todo', 'status' => 'todo', 'limit' => 4, 'cards' => [
['id' => 't1', 'title' => 'Draft release notes', 'description' => 'Summarise the audit fixes and the new pickers.', 'identifier' => 'ENG-142', 'priority' => 'high', 'labels' => [['name' => 'Docs', 'tone' => 'neutral']], 'assignee' => ['name' => 'Avery Stone'], 'due' => 'Sep 24'],
['id' => 't2', 'title' => 'Triage open issues', 'identifier' => 'ENG-143', 'priority' => 'medium', 'assignee' => ['name' => 'Jordan Lee']],
['id' => 't3', 'title' => 'Migrate the starter to the new form guard', 'identifier' => 'ENG-146', 'priority' => 'low', 'labels' => [['name' => 'Starter', 'tone' => 'success']]],
]],
['id' => 'doing', 'title' => 'In progress', 'status' => 'in-progress', 'limit' => 2, 'cards' => [
['id' => 'd1', 'title' => 'Wire up the registry guard', 'description' => 'Diff the registry directories against the catalog.', 'identifier' => 'ENG-139', 'priority' => 'urgent', 'labels' => [['name' => 'Bug', 'tone' => 'destructive']], 'assignee' => ['name' => 'Sasha Okafor'], 'due' => 'Today'],
]],
['id' => 'done', 'title' => 'Done', 'status' => 'done', 'cards' => [
['id' => 'c1', 'title' => 'Surface the orphaned component', 'identifier' => 'ENG-131', 'priority' => 'medium', 'assignee' => ['name' => 'Avery Stone']],
['id' => 'c2', 'title' => 'Ship the date range picker', 'identifier' => 'ENG-127', 'priority' => 'high', 'labels' => [['name' => 'Release', 'tone' => 'warning']]],
]],
];
@endphp
<div x-data class="flex h-[28rem] w-full max-w-2xl flex-col gap-2">
<div class="flex justify-end gap-2">
<x-ui.button variant="ghost" size="sm" x-on:click="Alpine.$data($el.closest('div').nextElementSibling).scrollByColumn(-1)" :aria-label="__('Scroll to the previous column')">
<svg viewBox="0 0 24 24" class="size-4 rtl:rotate-180" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
</x-ui.button>
<x-ui.button variant="ghost" size="sm" x-on:click="Alpine.$data($el.closest('div').nextElementSibling).scrollByColumn(1)" :aria-label="__('Scroll to the next column')">
<svg viewBox="0 0 24 24" class="size-4 rtl:rotate-180" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
</x-ui.button>
</div>
<x-ui.kanban :columns="$kanbanColumns" fill overflow-cue class="min-h-0 flex-1" />
</div>
Horizontal
{{-- `orientation="horizontal"`: swimlanes. Each column becomes a row, cards flow
sideways, Left/Right reorder within the lane and Up/Down move across lanes. --}}
@php
$kanbanColumns = [
['id' => 'backlog', 'title' => 'Backlog', 'status' => 'backlog', 'cards' => [
['id' => 'b1', 'title' => 'Audit the empty states', 'identifier' => 'ENG-148', 'priority' => 'low', 'labels' => [['name' => 'Design', 'tone' => 'primary']]],
['id' => 'b2', 'title' => 'Rate-limit the search index', 'identifier' => 'ENG-151', 'priority' => 'medium', 'labels' => [['name' => 'Infra', 'tone' => 'info']], 'assignee' => ['name' => 'Priya Nair']],
]],
['id' => 'todo', 'title' => 'Todo', 'status' => 'todo', 'limit' => 4, 'cards' => [
['id' => 't1', 'title' => 'Draft release notes', 'description' => 'Summarise the audit fixes and the new pickers.', 'identifier' => 'ENG-142', 'priority' => 'high', 'labels' => [['name' => 'Docs', 'tone' => 'neutral']], 'assignee' => ['name' => 'Avery Stone'], 'due' => 'Sep 24'],
['id' => 't2', 'title' => 'Triage open issues', 'identifier' => 'ENG-143', 'priority' => 'medium', 'assignee' => ['name' => 'Jordan Lee']],
['id' => 't3', 'title' => 'Migrate the starter to the new form guard', 'identifier' => 'ENG-146', 'priority' => 'low', 'labels' => [['name' => 'Starter', 'tone' => 'success']]],
]],
['id' => 'doing', 'title' => 'In progress', 'status' => 'in-progress', 'limit' => 2, 'cards' => [
['id' => 'd1', 'title' => 'Wire up the registry guard', 'description' => 'Diff the registry directories against the catalog.', 'identifier' => 'ENG-139', 'priority' => 'urgent', 'labels' => [['name' => 'Bug', 'tone' => 'destructive']], 'assignee' => ['name' => 'Sasha Okafor'], 'due' => 'Today'],
]],
['id' => 'done', 'title' => 'Done', 'status' => 'done', 'cards' => [
['id' => 'c1', 'title' => 'Surface the orphaned component', 'identifier' => 'ENG-131', 'priority' => 'medium', 'assignee' => ['name' => 'Avery Stone']],
['id' => 'c2', 'title' => 'Ship the date range picker', 'identifier' => 'ENG-127', 'priority' => 'high', 'labels' => [['name' => 'Release', 'tone' => 'warning']]],
]],
];
@endphp
<x-ui.kanban :columns="$kanbanColumns" orientation="horizontal" variant="outline" class="w-full max-w-4xl" />
{{-- `href` on a card: the title is a link whose overlay makes the whole card
clickable. A drag never opens it, and Tab reaches the card (Space grabs it
to move) and then its link (Enter opens it). --}}
@php
$kanbanColumns = [
['id' => 'backlog', 'title' => 'Backlog', 'status' => 'backlog', 'cards' => [
['id' => 'b1', 'title' => 'Audit the empty states', 'identifier' => 'ENG-148', 'priority' => 'low', 'labels' => [['name' => 'Design', 'tone' => 'primary']]],
['id' => 'b2', 'title' => 'Rate-limit the search index', 'identifier' => 'ENG-151', 'priority' => 'medium', 'labels' => [['name' => 'Infra', 'tone' => 'info']], 'assignee' => ['name' => 'Priya Nair']],
]],
['id' => 'todo', 'title' => 'Todo', 'status' => 'todo', 'limit' => 4, 'cards' => [
['id' => 't1', 'title' => 'Draft release notes', 'description' => 'Summarise the audit fixes and the new pickers.', 'identifier' => 'ENG-142', 'priority' => 'high', 'labels' => [['name' => 'Docs', 'tone' => 'neutral']], 'assignee' => ['name' => 'Avery Stone'], 'due' => 'Sep 24'],
['id' => 't2', 'title' => 'Triage open issues', 'identifier' => 'ENG-143', 'priority' => 'medium', 'assignee' => ['name' => 'Jordan Lee']],
['id' => 't3', 'title' => 'Migrate the starter to the new form guard', 'identifier' => 'ENG-146', 'priority' => 'low', 'labels' => [['name' => 'Starter', 'tone' => 'success']]],
]],
['id' => 'doing', 'title' => 'In progress', 'status' => 'in-progress', 'limit' => 2, 'cards' => [
['id' => 'd1', 'title' => 'Wire up the registry guard', 'description' => 'Diff the registry directories against the catalog.', 'identifier' => 'ENG-139', 'priority' => 'urgent', 'labels' => [['name' => 'Bug', 'tone' => 'destructive']], 'assignee' => ['name' => 'Sasha Okafor'], 'due' => 'Today'],
]],
['id' => 'done', 'title' => 'Done', 'status' => 'done', 'cards' => [
['id' => 'c1', 'title' => 'Surface the orphaned component', 'identifier' => 'ENG-131', 'priority' => 'medium', 'assignee' => ['name' => 'Avery Stone']],
['id' => 'c2', 'title' => 'Ship the date range picker', 'identifier' => 'ENG-127', 'priority' => 'high', 'labels' => [['name' => 'Release', 'tone' => 'warning']]],
]],
];
$kanbanColumns = array_map(static function (array $column): array {
$column['cards'] = array_map(static fn (array $card): array => $card + ['href' => '#issue-'.($card['identifier'] ?? $card['id'])], $column['cards']);
return $column;
}, $kanbanColumns);
@endphp
<x-ui.kanban :columns="$kanbanColumns" variant="plain" density="compact" />
{{-- `variant="plain"`: no column surfaces, headers sit on the page — the Linear look. Compact density. --}}
@php
$kanbanColumns = [
['id' => 'backlog', 'title' => 'Backlog', 'status' => 'backlog', 'cards' => [
['id' => 'b1', 'title' => 'Audit the empty states', 'identifier' => 'ENG-148', 'priority' => 'low', 'labels' => [['name' => 'Design', 'tone' => 'primary']]],
['id' => 'b2', 'title' => 'Rate-limit the search index', 'identifier' => 'ENG-151', 'priority' => 'medium', 'labels' => [['name' => 'Infra', 'tone' => 'info']], 'assignee' => ['name' => 'Priya Nair']],
]],
['id' => 'todo', 'title' => 'Todo', 'status' => 'todo', 'limit' => 4, 'cards' => [
['id' => 't1', 'title' => 'Draft release notes', 'description' => 'Summarise the audit fixes and the new pickers.', 'identifier' => 'ENG-142', 'priority' => 'high', 'labels' => [['name' => 'Docs', 'tone' => 'neutral']], 'assignee' => ['name' => 'Avery Stone'], 'due' => 'Sep 24'],
['id' => 't2', 'title' => 'Triage open issues', 'identifier' => 'ENG-143', 'priority' => 'medium', 'assignee' => ['name' => 'Jordan Lee']],
['id' => 't3', 'title' => 'Migrate the starter to the new form guard', 'identifier' => 'ENG-146', 'priority' => 'low', 'labels' => [['name' => 'Starter', 'tone' => 'success']]],
]],
['id' => 'doing', 'title' => 'In progress', 'status' => 'in-progress', 'limit' => 2, 'cards' => [
['id' => 'd1', 'title' => 'Wire up the registry guard', 'description' => 'Diff the registry directories against the catalog.', 'identifier' => 'ENG-139', 'priority' => 'urgent', 'labels' => [['name' => 'Bug', 'tone' => 'destructive']], 'assignee' => ['name' => 'Sasha Okafor'], 'due' => 'Today'],
]],
['id' => 'done', 'title' => 'Done', 'status' => 'done', 'cards' => [
['id' => 'c1', 'title' => 'Surface the orphaned component', 'identifier' => 'ENG-131', 'priority' => 'medium', 'assignee' => ['name' => 'Avery Stone']],
['id' => 'c2', 'title' => 'Ship the date range picker', 'identifier' => 'ENG-127', 'priority' => 'high', 'labels' => [['name' => 'Release', 'tone' => 'warning']]],
]],
];
@endphp
<x-ui.kanban :columns="$kanbanColumns" variant="plain" density="compact" />
{{-- `tint="status"` faintly tints each column by its status and colours the
header pill's dot to match; cards use the icon, assignee name, date range
and amount keys. --}}
@php
$projects = [
['id' => 'not-started', 'title' => 'Not started', 'status' => 'backlog', 'cards' => [
['id' => 'pr1', 'title' => 'Website Redesign', 'icon' => 'folder', 'assignee' => ['name' => 'Maria Chen'], 'start' => 'December 2, 2026', 'end' => 'December 12, 2026', 'amount' => '$3,200.00'],
]],
['id' => 'started', 'title' => 'Started', 'status' => 'todo', 'cards' => [
['id' => 'pr2', 'title' => 'Mobile App Onboarding', 'icon' => 'doc', 'assignee' => ['name' => 'Devon Cole'], 'start' => 'November 18, 2026', 'end' => 'December 5, 2026', 'amount' => '$5,450.00'],
]],
['id' => 'in-progress', 'title' => 'In progress', 'status' => 'in-progress', 'cards' => [
['id' => 'pr3', 'title' => 'Brand Style Guide', 'icon' => 'file', 'assignee' => ['name' => 'Priya Nair'], 'start' => 'November 3, 2026', 'end' => 'November 28, 2026', 'amount' => '$1,875.00'],
]],
['id' => 'completed', 'title' => 'Completed', 'status' => 'done', 'cards' => [
['id' => 'pr4', 'title' => 'Marketing Landing Page', 'icon' => 'folder', 'assignee' => ['name' => 'Sasha Okafor'], 'start' => 'October 9, 2026', 'end' => 'October 30, 2026', 'amount' => '$2,600.00'],
]],
];
@endphp
<x-ui.kanban :columns="$projects" tint="status" assignee-names class="w-full max-w-5xl" />
{{-- `readonly`: a viewer without permission to reorder sees the same board, but
cards cannot be dragged by pointer or keyboard. Cards stay focusable. --}}
@php
$kanbanColumns = [
['id' => 'backlog', 'title' => 'Backlog', 'status' => 'backlog', 'cards' => [
['id' => 'b1', 'title' => 'Audit the empty states', 'identifier' => 'ENG-148', 'priority' => 'low', 'labels' => [['name' => 'Design', 'tone' => 'primary']]],
['id' => 'b2', 'title' => 'Rate-limit the search index', 'identifier' => 'ENG-151', 'priority' => 'medium', 'labels' => [['name' => 'Infra', 'tone' => 'info']], 'assignee' => ['name' => 'Priya Nair']],
]],
['id' => 'todo', 'title' => 'Todo', 'status' => 'todo', 'limit' => 4, 'cards' => [
['id' => 't1', 'title' => 'Draft release notes', 'description' => 'Summarise the audit fixes and the new pickers.', 'identifier' => 'ENG-142', 'priority' => 'high', 'labels' => [['name' => 'Docs', 'tone' => 'neutral']], 'assignee' => ['name' => 'Avery Stone'], 'due' => 'Sep 24'],
['id' => 't2', 'title' => 'Triage open issues', 'identifier' => 'ENG-143', 'priority' => 'medium', 'assignee' => ['name' => 'Jordan Lee']],
['id' => 't3', 'title' => 'Migrate the starter to the new form guard', 'identifier' => 'ENG-146', 'priority' => 'low', 'labels' => [['name' => 'Starter', 'tone' => 'success']]],
]],
['id' => 'doing', 'title' => 'In progress', 'status' => 'in-progress', 'limit' => 2, 'cards' => [
['id' => 'd1', 'title' => 'Wire up the registry guard', 'description' => 'Diff the registry directories against the catalog.', 'identifier' => 'ENG-139', 'priority' => 'urgent', 'labels' => [['name' => 'Bug', 'tone' => 'destructive']], 'assignee' => ['name' => 'Sasha Okafor'], 'due' => 'Today'],
]],
['id' => 'done', 'title' => 'Done', 'status' => 'done', 'cards' => [
['id' => 'c1', 'title' => 'Surface the orphaned component', 'identifier' => 'ENG-131', 'priority' => 'medium', 'assignee' => ['name' => 'Avery Stone']],
['id' => 'c2', 'title' => 'Ship the date range picker', 'identifier' => 'ENG-127', 'priority' => 'high', 'labels' => [['name' => 'Release', 'tone' => 'warning']]],
]],
];
@endphp
<x-ui.kanban :columns="$kanbanColumns" variant="plain" density="compact" readonly />
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | array | […] | Array of columns, each with id, title, an optional status (backlog/todo/in-progress/done/cancelled), an optional limit, and a cards array. A card may carry icon (folder/file/doc), identifier, priority, labels, assignee ({name, avatar}), showAssignee, due, start, end, amount and href — every key is an opt-in; the card renders only the ones it sets. With href the title becomes a link whose overlay makes the whole card clickable. Only relative, http(s) and mailto hrefs are kept. |
| orientation | string | vertical | vertical lays columns side by side; horizontal renders them as stacked swimlanes. |
| variant | default | plain | outline | default | Column surface style: default (tinted background), plain (headers directly on the page), or outline (bordered columns). |
| tint | none | status | none | status faintly tints each column's background, and the header pill, with that column's status tone (reusing the status/badge soft tones); none leaves the variant's own surface alone. |
| density | string | comfortable | Card padding: comfortable or compact. |
| addable | bool | false | Shows a + button in each column header that dispatches a kanban:add event with the column id. |
| name | mixed | null | null | When set, posts each column's card ids as name[column][] hidden inputs, in order, for plain form submission. |
| assigneeNames | bool | false | Shows every card's assignee name beside its avatar. A single card can opt in on its own with a showAssignee: true key even when this is off. |
| readonly | bool | false | Cards cannot be moved by pointer or keyboard, for a viewer without permission to reorder; they stay focusable and clickable, with no grab cursor and no aria-grabbed state. |
| fill | bool | false | Vertical boards: the columns take the kanban's full height and each card list scrolls inside it, instead of stopping at 24rem. Give the kanban a height (a class, or min-h-0 flex-1 in a flex column). |
| overflowCue | bool | false | Vertical boards: fade the edge where more columns are to the side (a mask, RTL-aware). The root carries data-overflow-start / data-overflow-end either way, and scrollByColumn(1 | -1) scrolls by one column for host buttons. |
| ref | string | card', 'link' => true, 'assigneeNames' => false | Declared by @props in the registry Blade source. |
Slots
default—x-ui.kanban.card-body— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Dragging a card lifts it, shows a live placeholder in its origin slot, and other cards glide out of the way to make room.
- Cards also support keyboard reordering: focusing a card and using the keyboard moves it between positions and columns.
- Every move dispatches kanban:move with the card and its from/to columns, and kanban:change with the full board state, so the host can persist it.
- A polite live region announces moves to assistive tech as they happen.
- A card with href is a stretched link: the title is a real <a> whose ::after overlay covers the card, so a click anywhere on it opens the link. A card lifts only after the pointer travels 4px, and the click that ends a drag is swallowed, so a drop never navigates. Keys pressed on the link are its own: Enter opens it and does not grab the card.
- With readonly the cards carry no drag handlers: a pointer press does not start a drag, Space and Enter do not grab, the cursor stays default, and no kanban:move or kanban:change is dispatched. The root carries data-readonly="true".
- A vertical board tracks its sideways scroll: data-overflow-start / data-overflow-end mark cut columns, overflow-cue fades that edge, and scrollByColumn(step) scrolls by one column (smoothly unless reduced motion is set).
- Supports pointer drag-and-drop, keyboard reordering, column move announcements, and drag teardown.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Organize browsable objects into scannable units.
Use when
- Use to group related content into scannable units for browsing, prioritization, or rearrangement.
- Building a drag-and-drop board of work items organized into status columns or swimlanes.
- Needing built-in WIP limits, priorities, labels, assignees, and due dates on cards, plus keyboard-accessible reordering.
Avoid when
- Do not use card-like structures when dense row-by-row comparison would be clearer in a list or table.
- The items are a single ordered list with no columns; use sortable instead.
- The layout is a uniform grid of draggable tiles with no status grouping; use swappable-grid.
Use instead
- Table for exact comparison
- List for compact scanning
Anti-patterns
- Turning every object into a card
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter Escape
- Focus
managed
- Cards are focusable (tabindex="0") and expose aria-grabbed, and a polite live region announces each move, so the board is usable without a pointer.
- Column headings are proper headings referenced by aria-labelledby on each column region.
- A linked card has two tab stops: the card itself (Space grabs it to move) and its title link (Enter opens it). The link's focus ring is drawn on the overlay, around the whole card, with the focus ring tokens.
- A readonly board drops aria-grabbed and the grab announcements, so assistive technology is not told a card can be moved.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="kanban-{{ $record->id }}">
{{-- Drag a card: it lifts and follows the pointer while the others glide out of
the way; release settles it. Status glyphs, WIP limits (In progress is at
its limit), identifiers, priorities, labels, assignees and due dates. --}}
@php
$kanbanColumns = [
['id' => 'backlog', 'title' => 'Backlog', 'status' => 'backlog', 'cards' => [
['id' => 'b1', 'title' => 'Audit the empty states', 'identifier' => 'ENG-148', 'priority' => 'low', 'labels' => [['name' => 'Design', 'tone' => 'primary']]],
['id' => 'b2', 'title' => 'Rate-limit the search index', 'identifier' => 'ENG-151', 'priority' => 'medium', 'labels' => [['name' => 'Infra', 'tone' => 'info']], 'assignee' => ['name' => 'Priya Nair']],
]],
['id' => 'todo', 'title' => 'Todo', 'status' => 'todo', 'limit' => 4, 'cards' => [
['id' => 't1', 'title' => 'Draft release notes', 'description' => 'Summarise the audit fixes and the new pickers.', 'identifier' => 'ENG-142', 'priority' => 'high', 'labels' => [['name' => 'Docs', 'tone' => 'neutral']], 'assignee' => ['name' => 'Avery Stone'], 'due' => 'Sep 24'],
['id' => 't2', 'title' => 'Triage open issues', 'identifier' => 'ENG-143', 'priority' => 'medium', 'assignee' => ['name' => 'Jordan Lee']],
['id' => 't3', 'title' => 'Migrate the starter to the new form guard', 'identifier' => 'ENG-146', 'priority' => 'low', 'labels' => [['name' => 'Starter', 'tone' => 'success']]],
]],
['id' => 'doing', 'title' => 'In progress', 'status' => 'in-progress', 'limit' => 2, 'cards' => [
['id' => 'd1', 'title' => 'Wire up the registry guard', 'description' => 'Diff the registry directories against the catalog.', 'identifier' => 'ENG-139', 'priority' => 'urgent', 'labels' => [['name' => 'Bug', 'tone' => 'destructive']], 'assignee' => ['name' => 'Sasha Okafor'], 'due' => 'Today'],
]],
['id' => 'done', 'title' => 'Done', 'status' => 'done', 'cards' => [
['id' => 'c1', 'title' => 'Surface the orphaned component', 'identifier' => 'ENG-131', 'priority' => 'medium', 'assignee' => ['name' => 'Avery Stone']],
['id' => 'c2', 'title' => 'Ship the date range picker', 'identifier' => 'ENG-127', 'priority' => 'high', 'labels' => [['name' => 'Release', 'tone' => 'warning']]],
]],
];
@endphp
<x-ui.kanban :columns="$kanbanColumns" addable name="board" />
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:kanban
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Props:
- columns: [['id', 'title', 'status' => backlog|todo|in-progress|done|cancelled,
'limit' => int, 'cards' => [...]]]. A card: ['id', 'title', 'description',
'icon' => folder|file|doc, 'identifier' => 'ENG-142',
'priority' => none|low|medium|high|urgent,
'labels' => [['name', 'tone' => neutral|info|success|warning|destructive|primary]],
'assignee' => ['name', 'avatar'], 'showAssignee' => bool, 'due' => 'Sep 24',
'start' => 'Dec 2, 2026', 'end' => 'Dec 12, 2026', 'amount' => '$3,200.00',
'href' => '/issues/ENG-142'].
With `href` the title becomes a link whose overlay makes the whole card
clickable (a stretched link); a drag never follows it. Only relative,
http(s) and mailto hrefs are kept; any other scheme is dropped.
- orientation: vertical (columns side by side) or horizontal (swimlanes).
- variant: default (tinted columns), plain (headers on the page, Linear-style)
or outline (bordered columns). density: comfortable or compact.
- tint: none (default) or status — status faintly tints each column's
background and its header pill with that column's status tone.
- assigneeNames: shows every card's assignee name beside its avatar
(a single card can opt in on its own with `showAssignee: true`).
- addable: a + in each header that dispatches `kanban:add` { column }.
- name: posts each column's card ids as `name[column][]` in order.
- readonly: cards cannot be dragged, by pointer or keyboard (for a viewer
without permission to move them); they stay focusable and clickable.
- fill: the columns take the kanban's full height and each card list
scrolls inside it, instead of stopping at 24rem. Give the kanban a
height (a class, or a flex column parent). Vertical orientation only.
- overflow-cue: fade the edge where more columns are to the side. The
root always carries data-overflow-start / data-overflow-end while
columns are cut, and scrollByColumn(1 | -1) scrolls by one column, so
a host can add its own buttons.
Every move dispatches `kanban:move` { card, from, to } and `kanban:change`.
--}}
@props([
'columns' => [
['id' => 'todo', 'title' => 'To do', 'cards' => []],
['id' => 'doing', 'title' => 'In progress', 'cards' => []],
['id' => 'done', 'title' => 'Done', 'cards' => []],
],
'orientation' => 'vertical',
'variant' => 'default',
'tint' => 'none',
'density' => 'comfortable',
'addable' => false,
'name' => null,
'assigneeNames' => false,
'readonly' => false,
'fill' => false,
'overflowCue' => false,
])
@php
// Accept a string, a backed enum or a Stringable for `variant`/`tint`.
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$orientation = $orientation === 'horizontal' ? 'horizontal' : 'vertical';
$variant = in_array($variant, ['default', 'plain', 'outline'], true) ? $variant : 'default';
$tint = $styles['normalizeVariant']($tint);
$tint = $tint === 'status' ? 'status' : 'none';
$density = $density === 'compact' ? 'compact' : 'comfortable';
$horizontal = $orientation === 'horizontal';
$assigneeNames = (bool) $assigneeNames;
// The status tint (Alpine, per column) replaces the variant's own
// background so the two never stack into two conflicting `bg-*`
// utilities on the same element; the variant still supplies the shape.
$columnShape = match ($variant) {
'plain' => '',
'outline' => 'rounded-lg border border-border',
default => 'rounded-lg',
};
$columnBg = $tint === 'status' ? '' : match ($variant) {
'plain' => '',
'outline' => 'bg-card',
default => 'bg-muted/40',
};
$columnSurface = trim($columnShape.' '.$columnBg);
$cardPad = $density === 'compact' ? 'px-3 py-2' : 'p-3';
$fill = ! $horizontal && filter_var($fill, FILTER_VALIDATE_BOOL);
$overflowCue = ! $horizontal && filter_var($overflowCue, FILTER_VALIDATE_BOOL);
$rootClass = $horizontal
? 'relative flex w-full flex-col gap-3'
: 'relative flex w-full gap-4 overflow-x-auto pb-2'.($fill ? ' h-full min-h-0' : '');
$columnClass = $horizontal
? 'flex min-w-0 items-stretch gap-3 '.$columnSurface
: 'flex w-72 shrink-0 flex-col '.($fill ? 'min-h-0 ' : '').$columnSurface;
$listClass = $horizontal
? 'flex min-h-16 min-w-0 flex-1 flex-row items-start gap-2 overflow-x-auto p-2'
: 'flex '.($fill ? '' : 'max-h-96 ').'min-h-12 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2';
$cardWidth = $horizontal ? 'w-64 shrink-0' : '';
$readonly = filter_var($readonly, FILTER_VALIDATE_BOOL);
// A card href becomes a real link, so refuse script-capable schemes.
$columns = array_map(static function ($column) {
$column['cards'] = array_map(static function ($card) {
if (array_key_exists('href', $card)) {
$href = is_scalar($card['href']) ? trim((string) $card['href']) : '';
// Browsers drop tabs and newlines inside a URL, so judge the scheme without them.
$probe = preg_replace('/[\x00-\x20\x7f]+/', '', $href);
$scheme = preg_match('/^([a-z][a-z0-9+.-]*):/i', $probe, $match) ? strtolower($match[1]) : null;
$card['href'] = $href !== '' && ($scheme === null || in_array($scheme, ['http', 'https', 'mailto'], true)) ? $href : null;
}
return $card;
}, array_values($column['cards'] ?? []));
return $column;
}, array_values($columns));
$cardDrag = $readonly
? 'cursor-default'
: 'cursor-grab touch-none select-none active:cursor-grabbing';
@endphp
<div
x-data="uiKanban({ columns: @js(array_values($columns)), horizontal: @js($horizontal), tintStatus: @js($tint === 'status'), overflowCue: @js($overflowCue) })"
x-bind:data-overflow-start="overflowStart ? 'true' : null"
x-bind:data-overflow-end="overflowEnd ? 'true' : null"
x-bind:style="fadeStyle"
@if ($fill) data-fill="true" @endif
data-slot="kanban"
data-orientation="{{ $orientation }}"
data-variant="{{ $variant }}"
data-density="{{ $density }}"
data-tint="{{ $tint }}"
@if ($readonly) data-readonly="true" @endif
{{ $attributes->merge(['class' => $rootClass]) }}
>
{{-- Columns (or lanes). --}}
<template x-for="(col, colIndex) in columns" :key="col.id">
<section
data-slot="kanban-column"
x-bind:data-column-id="col.id"
x-bind:data-over="isColumnOver(col.id) ? 'true' : null"
x-bind:data-over-limit="overLimit(col) ? 'true' : null"
x-bind:aria-labelledby="'kanban-col-' + col.id"
x-bind:class="tintStatus ? statusTone(col.status).tint : ''"
class="{{ $columnClass }} transition-colors data-[over=true]:bg-accent/60 motion-reduce:transition-none"
>
{{-- Header: a soft status pill (dot + title), count (with WIP limit), add. --}}
<header class="flex items-center gap-2 {{ $horizontal ? 'w-48 shrink-0 self-start px-3 py-3' : 'px-3 py-2' }}">
<h3
data-slot="kanban-column-status"
x-bind:id="'kanban-col-' + col.id"
x-bind:class="statusTone(col.status).pill"
class="inline-flex min-w-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
>
<span class="size-2 shrink-0 rounded-full" x-bind:class="statusTone(col.status).dot" aria-hidden="true"></span>
<span class="min-w-0 truncate" x-text="col.title"></span>
</h3>
<span
class="text-xs tabular-nums text-muted-foreground"
x-bind:class="overLimit(col) ? 'font-medium text-warning' : ''"
x-text="col.limit ? col.cards.length + '/' + col.limit : col.cards.length"
x-bind:aria-label="col.cards.length + ' ' + @js(__('cards'))"
></span>
@if ($addable)
<button
type="button"
data-slot="kanban-add"
@click="$dispatch('kanban:add', { column: col.id })"
x-bind:aria-label="@js(__('Add to')) + ' ' + col.title"
class="ms-auto inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
</button>
@endif
</header>
{{-- Card list. --}}
<ul role="list" class="{{ $listClass }}">
<template x-for="(card, index) in col.cards" :key="card.id">
<li
data-slot="kanban-card"
tabindex="0"
x-bind:data-card-id="card.id"
x-bind:data-column-id="col.id"
x-bind:data-index="index"
@unless ($readonly)
x-bind:data-placeholder="isDragging(card.id) ? 'true' : null"
x-bind:data-grabbed="isGrabbed(col.id, index) ? 'true' : null"
x-bind:aria-grabbed="isGrabbed(col.id, index) ? 'true' : 'false'"
@pointerdown="startPointerDrag($event, col.id, index)"
@keydown="onKey($event)"
@endunless
class="{{ $cardWidth }} {{ $cardPad }} {{ $cardDrag }} group/card relative rounded-md border border-border bg-card text-card-foreground shadow-xs outline-none transition-[box-shadow,border-color] hover:border-input focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring data-[grabbed=true]:ring-[length:var(--ring-width)] data-[grabbed=true]:ring-ring data-[placeholder=true]:border-dashed data-[placeholder=true]:bg-muted/60 data-[placeholder=true]:shadow-none data-[placeholder=true]:[&>*]:invisible motion-reduce:transition-none"
>
<x-ui.kanban.card-body ref="card" :assignee-names="$assigneeNames" />
</li>
</template>
{{-- Empty column: a target while dragging, quiet otherwise. --}}
<li
x-show="col.cards.length === 0"
x-bind:class="dragging ? 'border-dashed border-ring text-foreground' : 'border-dashed border-border text-muted-foreground'"
class="{{ $cardWidth }} rounded-md border px-3 py-6 text-center text-xs transition-colors motion-reduce:transition-none"
x-text="dragging ? @js(__('Drop here')) : @js(__('No cards'))"
></li>
</ul>
</section>
</template>
{{-- Floating card that follows the pointer. --}}
<template x-if="ghost">
<div
data-slot="kanban-ghost"
aria-hidden="true"
class="pointer-events-none fixed z-overlay rotate-1 scale-[1.02] rounded-md border border-border bg-card {{ $cardPad }} text-card-foreground shadow-xl ring-1 ring-ring/40"
x-bind:style="`left:${ghost.x}px; top:${ghost.y}px; width:${ghost.w}px; height:${ghost.h}px;`"
>
<template x-if="dragging">
<div>
<x-ui.kanban.card-body ref="dragging.card" :assignee-names="$assigneeNames" :link="false" />
</div>
</template>
</div>
</template>
@if ($name)
<template x-for="col in columns" :key="'f-' + col.id">
<span class="contents">
<template x-for="card in col.cards" :key="'f-' + card.id">
<input type="hidden" x-bind:name="{{ Js::from($name) }} + '[' + col.id + '][]'" x-bind:value="card.id" />
</template>
</span>
</template>
@endif
{{-- Polite status region announces moves to assistive tech. --}}
<div class="sr-only" role="status" aria-live="polite" x-text="status"></div>
</div>
/**
* Kanban board behavior.
*
* Holds a board of columns, each with an ordered list of cards. Cards move
* across and within columns by pointer or keyboard, in vertical columns or
* horizontal lanes (`horizontal`).
*
* Pointer drag: the lifted card becomes a floating ghost under the pointer and
* its list entry turns into a hollow placeholder. The placeholder moves LIVE to
* wherever the drop would land (the card is moved in the data as the pointer
* moves), and the other cards glide around it — FLIP: their positions are
* measured before and after each move and the difference animated away. On
* release the ghost settles onto the placeholder. A card lifts only after the
* pointer travels 4px, so a plain click on a card with an `href` opens its
* link; the click that ends a drag is swallowed so a drop never navigates.
* Keyboard: Space/Enter grabs the focused card, Arrows move it (Up/Down within
* the column, Left/Right to the neighbour column — swapped in lanes;
* RTL-aware), Space/Enter drops, Escape cancels. Every move is announced
* through the polite live region and dispatched as `kanban:move` and
* `kanban:change`. Reduced motion skips the glides.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiKanban', (config = {}) => ({
// Deep clone so the configured prop is never mutated in place.
columns: (config.columns ?? []).map((col) => ({
...col,
cards: (col.cards ?? []).map((card) => ({ ...card })),
})),
horizontal: Boolean(config.horizontal),
tintStatus: Boolean(config.tintStatus),
// Columns cut off at the scroll start / end (vertical boards only).
overflowStart: false,
overflowEnd: false,
overflowCue: Boolean(config.overflowCue),
_onScroll: null,
_resizeObserver: null,
dragging: null, // { id, col, index, card, origin: { col, index } }
ghost: null, // { x, y, w, h, dx, dy }
over: null, // { col, index }
grabbed: null, // keyboard: { col, index }
status: '',
root: null,
_reduced: false,
_onMove: null,
_onUp: null,
_onClick: null,
_pending: null, // pointer is down on a card but has not travelled far enough to lift
_suppressClick: false,
_clickTimer: null,
init() {
this.root = this.$el;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._onMove = (e) => this.onPointerMove(e);
this._onUp = () => this.onPointerUp();
this._onClick = (e) => {
if (!this._suppressClick) return;
this._suppressClick = false;
e.preventDefault();
e.stopPropagation();
};
this.root.addEventListener('click', this._onClick, true);
if (!this.horizontal) {
this._onScroll = () => this.measureOverflow();
this.root.addEventListener('scroll', this._onScroll, { passive: true });
if (typeof ResizeObserver !== 'undefined') {
this._resizeObserver = new ResizeObserver(this._onScroll);
this._resizeObserver.observe(this.root);
}
this.$nextTick(() => this.measureOverflow());
}
},
/** Whether columns are cut at the start or end of the sideways scroll (RTL-safe). */
measureOverflow() {
const el = this.root;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
const position = Math.abs(el.scrollLeft);
this.overflowStart = max > 1 && position > 1;
this.overflowEnd = max > 1 && position < max - 1;
},
/** With overflow-cue: a mask that fades the edge where columns are cut. */
get fadeStyle() {
if (!this.overflowCue || (!this.overflowStart && !this.overflowEnd)) return {};
const rtl = this.root && getComputedStyle(this.root).direction === 'rtl';
const start = this.overflowStart ? '3rem' : '0px';
const end = this.overflowEnd ? 'calc(100% - 3rem)' : '100%';
const mask = `linear-gradient(${rtl ? 'to left' : 'to right'}, transparent, black ${start}, black ${end}, transparent)`;
return { maskImage: mask, webkitMaskImage: mask };
},
/** Scroll the board by one column towards the end (1) or the start (-1). */
scrollByColumn(step = 1) {
const el = this.root;
const column = el?.querySelector('[data-slot="kanban-column"]');
if (!el || !column) return;
const gap = parseFloat(getComputedStyle(el).columnGap) || 0;
const rtl = getComputedStyle(el).direction === 'rtl';
const distance = (column.getBoundingClientRect().width + gap) * Math.sign(step) * (rtl ? -1 : 1);
el.scrollBy({ left: distance, behavior: this._reduced ? 'auto' : 'smooth' });
},
rtl() {
return getComputedStyle(this.root).direction === 'rtl';
},
column(colId) {
return this.columns.find((c) => c.id === colId);
},
columnIndex(colId) {
return this.columns.findIndex((c) => c.id === colId);
},
overLimit(col) {
return Boolean(col.limit) && col.cards.length > col.limit;
},
// Soft badge/status tone classes for a column's status — shared by
// the header pill (always) and the column background (only when
// `tint="status"`). Unknown/missing statuses fall back to neutral.
statusTone(status) {
const tones = {
backlog: { pill: 'bg-surface-2 text-muted-foreground', dot: 'bg-muted-foreground/70', tint: 'bg-surface-2/70' },
todo: { pill: 'bg-info-soft text-info-text', dot: 'bg-info', tint: 'bg-info-soft/50' },
'in-progress': { pill: 'bg-warning-soft text-warning-text', dot: 'bg-warning', tint: 'bg-warning-soft/50' },
done: { pill: 'bg-success-soft text-success-text', dot: 'bg-success', tint: 'bg-success-soft/50' },
cancelled: { pill: 'bg-surface-2 text-muted-foreground', dot: 'bg-muted-foreground/70', tint: 'bg-surface-2/70' },
};
return tones[status] ?? tones.backlog;
},
initials(name) {
return String(name || '')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0].toUpperCase())
.join('');
},
announce(card, colId, index) {
const col = this.column(colId);
this.status = `Moved ${card.title} to ${col ? col.title : colId}, position ${index + 1}`;
},
emit(card, from, to) {
this.$dispatch('kanban:move', { card: { ...card }, from, to });
this.$dispatch('kanban:change', {
columns: this.columns.map((c) => ({ id: c.id, cards: c.cards.map((x) => x.id) })),
});
},
// ---- FLIP: glide cards from where they were to where they are ------
cardRects() {
const rects = new Map();
for (const el of this.root.querySelectorAll('[data-slot="kanban-card"]')) {
rects.set(el.dataset.cardId, el.getBoundingClientRect());
}
return rects;
},
glide(before) {
if (this._reduced) return;
this.$nextTick(() => {
for (const el of this.root.querySelectorAll('[data-slot="kanban-card"]')) {
const prev = before.get(el.dataset.cardId);
if (!prev || el.dataset.placeholder === 'true') continue;
const now = el.getBoundingClientRect();
const dx = prev.left - now.left;
const dy = prev.top - now.top;
if (!dx && !dy) continue;
el.getAnimations().forEach((a) => a.cancel());
el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: 200, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' });
}
});
},
// ---- Pointer drag ----------------------------------------------------
isDragging(cardId) {
return this.dragging !== null && this.dragging.id === cardId;
},
isColumnOver(colId) {
return this.dragging !== null && this.dragging.col === colId;
},
startPointerDrag(e, colId, index) {
if (e.button !== undefined && e.button !== 0) return;
// Inner controls keep their own press; the card link is the card itself.
const control = e.target.closest('a, button, input, textarea, select, [role="button"], [role="checkbox"], [role="menuitem"]');
if (control && !control.matches('[data-slot="kanban-card-link"]')) return;
// Lift only once the pointer travels past a small threshold, so a plain
// click (or tap) on a linked card still opens it.
this._pending = { colId, index, el: e.currentTarget, x: e.clientX, y: e.clientY };
this._suppressClick = false;
window.addEventListener('pointermove', this._onMove);
window.addEventListener('pointerup', this._onUp);
window.addEventListener('pointercancel', this._onUp);
e.preventDefault();
},
lift(clientX, clientY) {
const { colId, index, el, x, y } = this._pending;
this._pending = null;
const card = this.column(colId)?.cards[index];
if (!card) return;
const r = el.getBoundingClientRect();
this.grabbed = null;
this.dragging = { id: card.id, col: colId, index, card, origin: { col: colId, index } };
this.ghost = { w: r.width, h: r.height, dx: x - r.left, dy: y - r.top, x: clientX - (x - r.left), y: clientY - (y - r.top) };
},
onPointerMove(e) {
if (this._pending) {
if (Math.hypot(e.clientX - this._pending.x, e.clientY - this._pending.y) < 4) return;
this.lift(e.clientX, e.clientY);
}
if (!this.dragging) return;
this.ghost.x = e.clientX - this.ghost.dx;
this.ghost.y = e.clientY - this.ghost.dy;
const target = this.targetAt(e.clientX, e.clientY);
if (!target) return;
if (target.col !== this.dragging.col || target.index !== this.dragging.index) {
const before = this.cardRects();
const dest = this.moveCard(this.dragging.col, this.dragging.index, target.col, target.index);
if (dest) {
this.dragging.col = dest.col;
this.dragging.index = dest.index;
this.glide(before);
}
}
},
/** The column under the pointer and the slot the card would land in. */
targetAt(clientX, clientY) {
const cols = [...this.root.querySelectorAll('[data-slot="kanban-column"]')];
const colEl = cols.find((el) => {
const r = el.getBoundingClientRect();
return this.horizontal
? clientY >= r.top && clientY <= r.bottom
: clientX >= r.left && clientX <= r.right;
});
if (!colEl) return null;
const colId = colEl.dataset.columnId;
const col = this.column(colId);
const cards = [...colEl.querySelectorAll('[data-slot="kanban-card"]')].filter((c) => c.dataset.placeholder !== 'true');
const rtl = this.rtl();
let index = col.cards.length - (colId === this.dragging.col ? 1 : 0);
for (const cardEl of cards) {
const r = cardEl.getBoundingClientRect();
const before = this.horizontal
? (rtl ? clientX > r.left + r.width / 2 : clientX < r.left + r.width / 2)
: clientY < r.top + r.height / 2;
if (before) {
const i = Number(cardEl.dataset.index);
// Positions after the placeholder shift down by one once it leaves.
index = colId === this.dragging.col && i > this.dragging.index ? i - 1 : i;
break;
}
}
// Express the slot as an index in the column with the dragged card removed.
return { col: colId, index: Math.max(0, index) };
},
onPointerUp() {
window.removeEventListener('pointermove', this._onMove);
window.removeEventListener('pointerup', this._onUp);
window.removeEventListener('pointercancel', this._onUp);
this._pending = null;
if (!this.dragging) return;
// A drag ends with a click on the card under the pointer; swallow it so
// a linked card does not open when it is dropped.
this._suppressClick = true;
clearTimeout(this._clickTimer);
this._clickTimer = setTimeout(() => {
this._suppressClick = false;
}, 0);
const { card, col, index, origin } = this.dragging;
const placeholder = this.root.querySelector(`[data-slot="kanban-card"][data-card-id="${card.id}"]`);
const ghostEl = this.root.querySelector('[data-slot="kanban-ghost"]');
const finish = () => {
this.dragging = null;
this.ghost = null;
if (origin.col !== col || origin.index !== index) {
this.announce(card, col, index);
this.emit(card, origin, { col, index });
}
};
if (ghostEl && placeholder && !this._reduced) {
// Settle the ghost onto the placeholder, then swap them.
const to = placeholder.getBoundingClientRect();
const anim = ghostEl.animate(
[{ left: `${this.ghost.x}px`, top: `${this.ghost.y}px`, transform: 'rotate(1deg) scale(1.02)' }, { left: `${to.left}px`, top: `${to.top}px`, transform: 'none' }],
{ duration: 180, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'forwards' },
);
anim.onfinish = finish;
anim.oncancel = finish;
} else {
finish();
}
},
// ---- Shared move primitive (toIndex counts the column without the moving card) ----
moveCard(fromColId, fromIndex, toColId, toIndex) {
const fromCol = this.column(fromColId);
const toCol = this.column(toColId);
if (!fromCol || !toCol) return null;
const card = fromCol.cards[fromIndex];
if (!card) return null;
fromCol.cards.splice(fromIndex, 1);
const target = Math.max(0, Math.min(toIndex, toCol.cards.length));
toCol.cards.splice(target, 0, card);
return { col: toColId, index: target };
},
// ---- Keyboard --------------------------------------------------------
isGrabbed(colId, index) {
return this.grabbed !== null && this.grabbed.col === colId && this.grabbed.index === index;
},
onKey(e) {
// Keys pressed on the card link (or another inner control) are theirs:
// Enter on the link opens it instead of grabbing the card.
if (e.target !== e.currentTarget) return;
const colId = e.currentTarget.dataset.columnId;
const index = Number(e.currentTarget.dataset.index);
const card = this.column(colId).cards[index];
const within = this.horizontal ? ['ArrowLeft', 'ArrowRight'] : ['ArrowUp', 'ArrowDown'];
const across = this.horizontal ? ['ArrowUp', 'ArrowDown'] : ['ArrowLeft', 'ArrowRight'];
const rtl = this.rtl();
if (e.key === ' ' || e.key === 'Spacebar' || e.key === 'Enter') {
e.preventDefault();
if (this.isGrabbed(colId, index)) {
this.grabbed = null;
this.status = `Dropped ${card.title}.`;
} else {
this.grabbed = { col: colId, index };
this.status = `Grabbed ${card.title}. Use the arrow keys to move, space to drop.`;
}
return;
}
if (e.key === 'Escape' && this.isGrabbed(colId, index)) {
e.preventDefault();
this.grabbed = null;
this.status = 'Cancelled.';
return;
}
if (!this.isGrabbed(colId, index)) return;
let dir = 0;
if (within.includes(e.key)) {
const back = e.key === within[0];
dir = this.horizontal && rtl ? (back ? 1 : -1) : back ? -1 : 1;
e.preventDefault();
this.moveGrabbed(colId, index, colId, index + dir);
} else if (across.includes(e.key)) {
const back = e.key === across[0];
dir = !this.horizontal && rtl ? (back ? 1 : -1) : back ? -1 : 1;
e.preventDefault();
const target = this.columns[this.columnIndex(colId) + dir];
if (target) this.moveGrabbed(colId, index, target.id, Math.min(index, target.cards.length));
}
},
moveGrabbed(fromCol, fromIndex, toCol, toIndex) {
const col = this.column(toCol);
const max = fromCol === toCol ? col.cards.length - 1 : col.cards.length;
if (toIndex < 0 || toIndex > max) return;
const card = this.column(fromCol).cards[fromIndex];
const before = this.cardRects();
const dest = this.moveCard(fromCol, fromIndex, toCol, toIndex);
if (!dest) return;
this.grabbed = dest;
this.announce(card, dest.col, dest.index);
this.emit(card, { col: fromCol, index: fromIndex }, dest);
this.glide(before);
this.$nextTick(() => this.root.querySelector(`[data-slot="kanban-card"][data-card-id="${card.id}"]`)?.focus());
},
destroy() {
this.root?.removeEventListener('click', this._onClick, true);
if (this._onScroll) this.root?.removeEventListener('scroll', this._onScroll);
this._resizeObserver?.disconnect();
clearTimeout(this._clickTimer);
window.removeEventListener('pointermove', this._onMove);
window.removeEventListener('pointerup', this._onUp);
window.removeEventListener('pointercancel', this._onUp);
},
}));
});
{{-- The card's content, shared by the list card and the floating ghost.
`ref` is the Alpine expression that yields the card: `card` inside the
list's x-for, `dragging.card` for the ghost. `link` renders a card's
`href` as the title link whose overlay covers the whole card (a stretched
link); the ghost passes false so it holds no focusable link.
`assigneeNames` (board prop `assignee-names`) forces the assignee's name
next to the avatar on every card; a card can opt in on its own with
`showAssignee: true` even when the board prop is off. --}}
@props(['ref' => 'card', 'link' => true, 'assigneeNames' => false])
@php($ref = preg_match('/^[a-zA-Z_.]+$/', $ref) ? $ref : 'card')
@php($assigneeNamesJs = $assigneeNames ? 'true' : 'false')
<div data-slot="kanban-card-body" {{ $attributes->merge(['class' => 'min-w-0']) }}>
<div class="flex items-start gap-2">
<span class="shrink-0 pt-px text-muted-foreground" x-show="{{ $ref }}.icon" aria-hidden="true">
{{-- A closed set of icon names, never raw markup from card data — an
`icon` value can only select one of these, it can never inject. --}}
<svg x-show="{{ $ref }}.icon === 'folder'" viewBox="0 0 16 16" class="size-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4.75A1.25 1.25 0 0 1 3.25 3.5h2.69c.3 0 .59.11.82.32l.98.88h5.01c.69 0 1.25.56 1.25 1.25v6.1c0 .69-.56 1.25-1.25 1.25H3.25c-.69 0-1.25-.56-1.25-1.25v-7.3Z" /></svg>
<svg x-show="{{ $ref }}.icon === 'file'" viewBox="0 0 16 16" class="size-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4.25 2h4.5L12 4.75v8.5a.75.75 0 0 1-.75.75h-7a.75.75 0 0 1-.75-.75v-10.5A.75.75 0 0 1 4.25 2Z" /><path d="M8.5 2v2.75h2.75" /></svg>
<svg x-show="{{ $ref }}.icon === 'doc'" viewBox="0 0 16 16" class="size-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4.25 2h4.5L12 4.75v8.5a.75.75 0 0 1-.75.75h-7a.75.75 0 0 1-.75-.75v-10.5A.75.75 0 0 1 4.25 2Z" /><path d="M5.5 8h5M5.5 10.5h3.5" /></svg>
</span>
<span class="shrink-0 pt-px text-muted-foreground" x-show="{{ $ref }}.priority && {{ $ref }}.priority !== 'none'" aria-hidden="true">
{{-- Priority as signal bars; urgent is a filled box with a mark. --}}
<svg x-show="{{ $ref }}.priority === 'urgent'" viewBox="0 0 16 16" class="size-4 text-destructive" fill="currentColor"><rect x="1" y="1" width="14" height="14" rx="3" /><path d="M8 4.5v4M8 11.5v.01" stroke="hsl(var(--background))" stroke-width="1.6" stroke-linecap="round" /></svg>
<svg x-show="['low', 'medium', 'high'].includes({{ $ref }}.priority)" viewBox="0 0 16 16" class="size-4" fill="currentColor"><rect x="1.5" y="9" width="3" height="5" rx="1" /><rect x="6.5" y="6" width="3" height="8" rx="1" x-bind:opacity="{{ $ref }}.priority === 'low' ? 0.3 : 1" /><rect x="11.5" y="3" width="3" height="11" rx="1" x-bind:opacity="{{ $ref }}.priority === 'high' ? 1 : 0.3" /></svg>
</span>
@if (filter_var($link, FILTER_VALIDATE_BOOL))
<template x-if="{{ $ref }}.href">
<a
data-slot="kanban-card-link"
x-bind:href="{{ $ref }}.href"
draggable="false"
class="min-w-0 flex-1 text-sm font-medium leading-snug text-foreground outline-none after:absolute after:inset-0 after:rounded-md after:content-[''] hover:underline focus-visible:after:ring-[length:var(--ring-width)] focus-visible:after:ring-ring"
x-text="{{ $ref }}.title"
></a>
</template>
<template x-if="!{{ $ref }}.href">
<p class="min-w-0 flex-1 text-sm font-medium leading-snug text-foreground" x-text="{{ $ref }}.title"></p>
</template>
@else
<p class="min-w-0 flex-1 text-sm font-medium leading-snug text-foreground" x-text="{{ $ref }}.title"></p>
@endif
</div>
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground" x-show="{{ $ref }}.description" x-text="{{ $ref }}.description"></p>
<div class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1" x-show="{{ $ref }}.identifier || ({{ $ref }}.labels && {{ $ref }}.labels.length) || {{ $ref }}.assignee || {{ $ref }}.due">
<span class="font-mono text-2xs uppercase tracking-wide text-muted-foreground" x-show="{{ $ref }}.identifier" x-text="{{ $ref }}.identifier"></span>
<template x-for="label in ({{ $ref }}.labels || [])" :key="label.name">
<span class="inline-flex items-center gap-1 rounded-full border border-border px-2 py-px text-2xs text-muted-foreground">
<span class="size-2 rounded-full" x-bind:class="{ 'bg-info': label.tone === 'info', 'bg-success': label.tone === 'success', 'bg-warning': label.tone === 'warning', 'bg-destructive': label.tone === 'destructive', 'bg-primary': label.tone === 'primary', 'bg-muted-foreground': !label.tone || label.tone === 'neutral' }"></span>
<span x-text="label.name"></span>
</span>
</template>
{{-- Trailing meta sits at the end of the row; when the assignee is shown
by name and nothing leads the row, it reads as its own line at the start. --}}
<span class="flex items-center gap-2" x-bind:class="({{ $ref }}.identifier || ({{ $ref }}.labels && {{ $ref }}.labels.length) || !({{ $ref }}.assignee && {{ $ref }}.assignee.name && ({{ $ref }}.showAssignee || {{ $assigneeNamesJs }}))) ? 'ms-auto' : ''">
<span class="text-2xs text-muted-foreground" x-show="{{ $ref }}.due" x-text="{{ $ref }}.due"></span>
<template x-if="{{ $ref }}.assignee">
<span class="relative inline-flex min-w-0 items-center gap-1">
<span class="inline-flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-2xs font-medium text-muted-foreground" x-bind:title="{{ $ref }}.assignee.name">
<template x-if="{{ $ref }}.assignee.avatar"><img x-bind:src="{{ $ref }}.assignee.avatar" alt="" class="size-full object-cover" /></template>
<template x-if="!{{ $ref }}.assignee.avatar"><span x-text="initials({{ $ref }}.assignee.name)"></span></template>
</span>
{{-- Avatar-only by default; a card's own `showAssignee: true`
or the board's `assignee-names` prop adds the name. --}}
<span class="min-w-0 truncate text-2xs text-muted-foreground" x-show="{{ $ref }}.assignee.name && ({{ $ref }}.showAssignee || {{ $assigneeNamesJs }})" x-text="{{ $ref }}.assignee.name"></span>
</span>
</template>
</span>
</div>
{{-- Date range: preformatted `start`/`end` strings either side of a decorative
arrow. Each date is nowrap so a narrow card wraps the gap between the two
dates, never inside one. --}}
<div class="mt-2 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-2xs text-muted-foreground" x-show="{{ $ref }}.start || {{ $ref }}.end">
<span class="whitespace-nowrap" x-show="{{ $ref }}.start" x-text="{{ $ref }}.start"></span>
<span x-show="{{ $ref }}.start && {{ $ref }}.end" aria-hidden="true">
<svg viewBox="0 0 16 16" class="size-3 rtl:-scale-x-100" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8h10m-3.5-3.5L13 8l-3.5 3.5" /></svg>
</span>
<span class="sr-only" x-show="{{ $ref }}.start && {{ $ref }}.end">{{ __('to') }}</span>
<span class="whitespace-nowrap" x-show="{{ $ref }}.end" x-text="{{ $ref }}.end"></span>
</div>
{{-- Amount: a preformatted string on its own row, in tabular numbers. --}}
<p class="mt-2 text-xs font-medium tabular-nums text-foreground" x-show="{{ $ref }}.amount" x-text="{{ $ref }}.amount"></p>
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.5.1 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0