Skip to content
On this page Column summary

Display

Kanban

A stage board on top of the drag engine: columns that scroll on their own, cards that move by mouse, touch or keyboard, per-column loading and a mobile tab strip.

<script>
  import { Kanban } from "@codeatlasdev/ui";

  let columns = $state([
    { id: "new", label: "New", color: "var(--ca-info-main)", total: 42, hasMore: true, items },
    { id: "won", label: "Won", color: "var(--ca-success-main)", items: [] },
  ]);
</script>

<Kanban {columns} getId={(card) => card.id} onmove={({ card, from, to }) => {}} onloadmore={(columnId) => {}}>
  {#snippet card(item, { dragging })}…{/snippet}
</Kanban>

Kanban is the board around useDnd: you pass columns of cards and apply onmove to your data; the component owns the layout, the drag, the keyboard map and the touch alternative. Give it a parent with a height (a flex child or height), because every column list scrolls on its own. A plain wrapper with a scoped class is enough, no :global needed:

A plain wrapper with a scoped class is enough, no :global needed:

<div class="board"><Kanban {columns} getId={(card) => card.id} …>…</Kanban></div>

<style>
  .board { display: flex; height: 28rem; }
</style>
#760

Leads pipeline

Drag a card to another column, lift it with Space and move it with the arrows, or use the menu on the card. Scroll the New column to load more. Narrow the window below 600px for the tab strip.

Press Space to lift, the arrow keys to move, Enter to drop and Escape to cancel. Touch: press and hold, then drag.

New

24
  • N lead 1 Atlas Co
  • N lead 2 Atlas Co
  • N lead 3 Atlas Co
  • N lead 4 Atlas Co
  • N lead 5 Atlas Co
  • N lead 6 Atlas Co
  • N lead 7 Atlas Co
  • N lead 8 Atlas Co

Talking

3
  • T lead 1 Atlas Co
  • T lead 2 Atlas Co
  • T lead 3 Atlas Co

Won

0
  • Drop a lead here to win it

Reference #760 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Kanban, type KanbanColumn, type KanbanMove } from '@codeatlasdev/ui';

  type Lead = { id: string; name: string; company: string };
  const mk = (prefix: string, n: number): Lead[] =>
    Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i}`, name: `${prefix.toUpperCase()} lead ${i + 1}`, company: 'Atlas Co' }));

  let columns = $state<KanbanColumn<Lead>[]>([
    { id: 'new', label: 'New', color: 'var(--ca-info-main)', total: 24, hasMore: true, items: mk('n', 8) },
    { id: 'talking', label: 'Talking', color: 'var(--ca-warning-main)', items: mk('t', 3) },
    { id: 'won', label: 'Won', color: 'var(--ca-success-main)', items: [] },
  ]);

  function onmove({ card, from, to }: KanbanMove<Lead>) {
    const src = columns.find((c) => c.id === from.column);
    const dst = columns.find((c) => c.id === to.column);
    if (!src || !dst) return;
    src.items = src.items.filter((l) => l.id !== card.id);
    // The server orders each column newest first: a drop never picks a position.
    dst.items = [card, ...dst.items];
    if (src.total !== undefined) src.total -= 1;
    if (dst.total !== undefined) dst.total += 1;
  }

  function onloadmore(id: string) {
    const col = columns.find((c) => c.id === id);
    if (!col || col.loadingMore) return;
    col.loadingMore = true;
    setTimeout(() => {
      col.items = [...col.items, ...mk(`${id}p${col.items.length}`, 8)];
      col.loadingMore = false;
      col.hasMore = col.items.length < (col.total ?? 0);
    }, 600);
  }
</script>

<div class="kb-demo">
<Kanban {columns} getId={(l) => l.id} {onmove} {onloadmore} label="Leads pipeline">
  {#snippet card(lead)}
    <strong class="kb-demo__name">{lead.name}</strong>
    <span class="kb-demo__company">{lead.company}</span>
  {/snippet}
  {#snippet empty(column, { canMove })}
    {canMove ? `Drop a lead here to win it` : `Nothing in ${column.label}`}
  {/snippet}
</Kanban>
</div>

<style>
  .kb-demo { display: flex; height: 28rem; }
  .kb-demo__name { display: block; font-size: var(--ca-font-size-body2); color: var(--ca-text-primary); }
  .kb-demo__company { font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); }
</style>

Column summary

Pass a summary snippet to show an aggregate (a money total, a weight, hours) after each column’s count. The colour rail, title and count stay; header is for replacing the whole row and drops all three. The snippet receives the column, so compute from column.items; for a server-paged column (total larger than the loaded items) send the aggregate with the column data instead. On the mobile tab layout the summary shows as a row above the active column’s cards.

#761

Money total per column

Each column shows the sum of its deals next to the count. Move a card and both totals follow.

Press Space to lift, the arrow keys to move, Enter to drop and Escape to cancel. Touch: press and hold, then drag.

New

3 $5,400
  • N deal 1 $1,200
  • N deal 2 $3,400
  • N deal 3 $800

Talking

2 $24,800
  • T deal 1 $9,800
  • T deal 2 $15,000

Won

1 $22,000
  • W deal 1 $22,000

Reference #761 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Kanban, type KanbanColumn } from '@codeatlasdev/ui';

  type Deal = { id: string; name: string; value: number };
  const deals = (prefix: string, values: number[]): Deal[] =>
    values.map((value, i) => ({ id: `${prefix}${i}`, name: `${prefix.toUpperCase()} deal ${i + 1}`, value }));

  let columns = $state<KanbanColumn<Deal>[]>([
    { id: 'new', label: 'New', color: 'var(--ca-info-main)', items: deals('n', [1200, 3400, 800]) },
    { id: 'talking', label: 'Talking', color: 'var(--ca-warning-main)', items: deals('t', [9800, 15000]) },
    { id: 'won', label: 'Won', color: 'var(--ca-success-main)', items: deals('w', [22000]) },
  ]);

  const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
  const sum = (c: KanbanColumn<Deal>) => money.format(c.items.reduce((acc, d) => acc + d.value, 0));
</script>

<div class="kb-totals">
  <Kanban
    {columns}
    getId={(d) => d.id}
    label="Deals pipeline"
    onmove={({ card, from, to }) => {
      const src = columns.find((c) => c.id === from.column);
      const dst = columns.find((c) => c.id === to.column);
      if (!src || !dst) return;
      src.items = src.items.filter((d) => d.id !== card.id);
      dst.items = [card, ...dst.items];
    }}
  >
    {#snippet card(deal)}
      <strong class="kb-totals__name">{deal.name}</strong>
      <span class="kb-totals__value">{money.format(deal.value)}</span>
    {/snippet}
    {#snippet summary(column)}
      {sum(column)}
    {/snippet}
  </Kanban>
</div>

<style>
  .kb-totals { display: flex; height: 22rem; }
  .kb-totals__name { display: block; font-size: var(--ca-font-size-body2); color: var(--ca-text-primary); }
  .kb-totals__value { font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); font-variant-numeric: tabular-nums; }
</style>

Drops

By default a column is the drop target as a whole (drop="container"): it is highlighted, no insertion line is drawn and to.index is always 0. That is right for columns the server orders (newest first), where dropping “between cards” would mean nothing. Set drop="position" to let the card land between cards; the line appears and to.index is the position.

Moving cards

Input How it works
Mouse and pen Press a card and move 4px. The empty board background pans sideways; a vertical wheel over the gaps scrolls the board sideways; trackpad deltaX stays native.
Touch Press and hold, then drag. Near the left or right edge the board scrolls, faster the closer you are.
Keyboard Focus a card, Space lifts it, Left and Right move it to the same index of the neighbouring column (an empty column receives it), Up, Down, Home and End move inside a column (with drop="position"), Enter drops, Escape cancels. Every step is announced.
Menu Every card has a “Move to…” button that opens a menu of the other columns. It is the touch and screen reader alternative to dragging and announces the result.

Loading more

A column with hasMore fires onloadmore(columnId) when its list scrolls within nearEnd pixels of the end (once per page: it waits until the item count changes) and shows a “Load more (N)” button, where N is total - items.length. Set loadingMore on the column while the page loads to show skeleton cards, and loading for the first page.

Heading level

Each column title is a heading, <h2> by default (inside a PageShell it follows the page’s section level), so a board under the page <h1> keeps the heading order for axe and screen readers. Pass headingLevel={3} when the board sits under an <h2> of its own. Only the tag changes, never the look. A custom header snippet replaces the title row, so you choose its tag; the mobile tab layout renders no column headings.

Mobile

Below 600px of its own width (or with compact) the board shows one column at a time. A role="tablist" strip with a colour dot, the label and the count selects the column (arrow keys move between tabs; every tab is 44px tall), and the board never scrolls sideways. The “Move to…” menu replaces dragging between columns.

Props

PropTypeDefaultDescription
columnsKanbanColumn<T>[]required`{ id, label, color?, items, total?, hasMore?, loading?, loadingMore? }` per column. `color` is any CSS colour or token.
getId(card: T) => stringrequiredStable id of a card.
onmove(move: { card, from, to }) => voidundefinedA card moved by drag, keyboard or the menu. `from` and `to` are `{ column, index }`.
onloadmore(columnId: string) => voidundefinedA column wants its next page.
canMovebooleantrue`false` turns dragging off and hides the menu.
drop'container' | 'position''container'Whole-column drop (index 0, no line) or positional drop.
compactbooleanundefinedForce the mobile tab layout. Default: automatic below 600px of the board's width.
labelstring'Board'Accessible name of the board region.
headingLevel2 | 3 | 4 | 5 | 62Heading level of the column titles; only the tag changes. 2 under a page `<h1>`, 3 under an `<h2>`. Not used by a custom `header` or the mobile tab layout.
nearEndnumber240Distance from the end of a list (px) at which `onloadmore` fires.
cardSnippet<[T, { dragging, active }]>requiredCard content; the wrapper, focus, drag state and menu are the board's.
emptySnippet<[KanbanColumn<T>, { canMove }]>undefinedContent of an empty column.
headerSnippet<[KanbanColumn<T>]>undefinedReplaces the colour rail, title and count.
summarySnippet<[KanbanColumn<T>]>undefinedAggregate shown after the count (a money total); rail, title and count stay. A row above the cards on mobile. Ignored when `header` is set.