Skip to content
Brok UI

Loading…

No results

Reorder

Open source

Drag-to-arrange surfaces — a kanban board, a sortable list and a swap-in-place grid.

Version
v1.5.1
Stability
stable
License
MIT
Related
Sortable
Swappable 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

Orientation
Variant
previews.components.kanban.default.blade.php 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" />

Orientation options

Horizontal Current
Vertical Current

Variant options

Default Current
Plain Current
Outline Current

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/kanban.blade.php
  • js resources/js/ui/kanban.js
  • blade resources/views/components/ui/kanban/card-body.blade.php
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

kanban.md
# 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.blade.php Blade
{{-- `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.blade.php Blade
{{-- `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" />
linked.blade.php Blade
{{-- `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" />
plain.blade.php Blade
{{-- `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" />
projects.blade.php Blade
{{-- `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.blade.php Blade
{{-- `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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

kanban kanban-add kanban-card kanban-card-body kanban-card-link kanban-column kanban-column-status kanban-ghost

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

Card-based organization

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
root kanban-card-body kanban-column kanban-column-status kanban-add kanban-card kanban-ghost
Theming hooks
kanban kanban-add kanban-card kanban-card-body kanban-card-link kanban-column kanban-column-status kanban-ghost

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/kanban.blade.php Blade
{{--
    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>
resources/js/ui/kanban.js JS
/**
 * 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);
        },
    }));
});
resources/views/components/ui/kanban/card-body.blade.php Blade
{{-- 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