Lattice Grid Buy a licence

demo D270

A board (kanban) view

The same rows as cards in status columns: drag or move by keyboard, points per column, a work-in-progress limit, lanes, sprints and a pop-out child grid

createKanban

Building…
Loading a live grid…

The configuration

'kanban-board': () => ({
  rows: [],
  config: {},
  foot: [
    'statuses as columns, points summed per column, and one status outside the configured set kept in its own column',
    'drag a card between columns or reorder within one, or move it from the keyboard: grab with Space, arrows to choose, Space to drop',
    'In progress holds a work-in-progress limit, so a move into a full column is turned away',
    'open a card to pop its subtasks out as a live grid, switch to lanes by assignee, pick a sprint, or search the board',
  ],
  mount: (el: HTMLElement, LG: any) => {
    const rows = seed();
    let readonly = false;
    let swimlanes = false;
    let board: any;
    let disposed = false;
    const bar = document.createElement('div');
    bar.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px';
    const mkBtn = (label: string) => {
      const b = document.createElement('button');
      b.type = 'button';
      b.textContent = label;
      b.style.cssText = 'font:inherit;padding:5px 11px;border:1px solid var(--rule);background:var(--paper);color:var(--ink);border-radius:7px;cursor:pointer';
      return b;
    };
    const diffBtn = mkBtn('Apply a live change');
    const roBtn = mkBtn('Toggle read only');
    const laneBtn = mkBtn('Group into lanes');
    const sprintWrap = document.createElement('label');
    sprintWrap.style.cssText = 'display:inline-flex;align-items:center;gap:6px;font-size:.8125rem;color:var(--ink-2)';
    sprintWrap.append('Sprint');
    const sprintSel = document.createElement('select');
    sprintSel.setAttribute('aria-label', 'Sprint');
    sprintSel.style.cssText = 'font:inherit;padding:4px 8px;border:1px solid var(--rule);background:var(--paper);color:var(--ink);border-radius:7px';
    sprintWrap.append(sprintSel);
    const search = document.createElement('input');
    search.type = 'search';
    search.placeholder = 'Search the board';
    search.setAttribute('aria-label', 'Search the board');
    search.style.cssText = 'font:inherit;padding:4px 10px;border:1px solid var(--rule);background:var(--paper);color:var(--ink);border-radius:7px;flex:1;min-width:160px';
    bar.append(diffBtn, roBtn, laneBtn, sprintWrap, search);
    const boardEl = document.createElement('div');
    boardEl.style.cssText = 'height:520px;border:1px solid var(--rule);border-radius:9px;background:var(--paper);overflow:hidden';
    el.append(bar, boardEl);
    loadKanban()
      .then((KB: any) => {
        if (disposed) return;
        const build = () =>
          KB.createKanban(boardEl, {
            rows,
            rowKey: 'id',
            columnProperty: 'status',
            columns: [
              { id: 'backlog', title: 'Backlog' },
              { id: 'todo', title: 'To do' },
              { id: 'doing', title: 'In progress', wipLimit: 2, color: '#f0a020' },
              { id: 'review', title: 'In review' },
              { id: 'done', title: 'Done', color: '#2e7d32' },
            ],
            pointsProperty: 'points',
            showPoints: true,
            orderProperty: 'order',
            swimlaneProperty: 'assignee',
            swimlanes,
            sprintProperty: 'sprint',
            epicProperty: 'epic',
            card: { title: { field: 'title', edit: true }, subtitle: 'assignee', labels: 'tags', badges: 'epic' },
            doneColumns: ['done'],
            addCard: true,
            readonly,
            ariaLabel: 'Sprint S-24 board',
            emptyText: 'No cards',
            children: {
              present: 'drawer',
              load: (card: any) => [
                { id: `${card.key}.1`, title: `${card.fields.title}: spec`, status: 'done' },
                { id: `${card.key}.2`, title: `${card.fields.title}: build`, status: 'doing' },
                { id: `${card.key}.3`, title: `${card.fields.title}: test`, status: 'todo' },
              ],
              title: (card: any) => `${card.key}: subtasks`,
              factory: (container: HTMLElement, opts: any) =>
                LG.createGrid(container, {
                  rowKey: 'id',
                  columns: [
                    { field: 'title', title: 'Subtask', layout: { flex: 1 } },
                    { field: 'status', title: 'Status', layout: { width: 110 }, edit: true },
                  ],
                  rows: opts.rows,
                }),
            },
            onAddCard: (columnId: string) => ({ id: `T-${Date.now()}`, status: columnId, title: '', points: 0, sprint: 'S-24', epic: 'Accounts', assignee: 'Ana', tags: '' }),
            onBeforeMove: (_card: any, from: string, to: string) => {
              const col = board.column(to);
              if (col && col.wipLimit != null && col.count >= col.wipLimit && from !== to) return false;
              return true;
            },
          });
        board = build();
        const fillSprints = () => {
          const values = board.sprints().filter((s: unknown) => s != null) as string[];
          sprintSel.innerHTML = '';
          const all = document.createElement('option');
          all.value = '';
          all.textContent = 'All sprints';
          sprintSel.append(all);
          const backlog = document.createElement('option');
          backlog.value = '__backlog';
          backlog.textContent = 'Backlog';
          sprintSel.append(backlog);
          for (const s of values) {
            const o = document.createElement('option');
            o.value = s;
            o.textContent = s;
            sprintSel.append(o);
          }
        };
        fillSprints();
        diffBtn.addEventListener('click', () => {
          board.rows.apply({
            add: [{ id: 'T-9', status: 'todo', points: 3, sprint: 'S-24', epic: 'Accounts', assignee: 'Ana', title: 'Multi-factor prompt', tags: 'auth' }],
            update: [{ id: 'T-3', status: 'done', points: 2, sprint: 'S-24', epic: 'Billing', assignee: 'Ana', title: 'Invoice PDF export', tags: 'billing' }],
            remove: ['T-8'],
          });
        });
        roBtn.addEventListener('click', () => {
          readonly = !readonly;
          board.destroy();
          board = build();
          fillSprints();
        });
        laneBtn.addEventListener('click', () => {
          swimlanes = !swimlanes;
          laneBtn.textContent = swimlanes ? 'Flat columns' : 'Group into lanes';
          board.destroy();
          board = build();
          fillSprints();
        });
        sprintSel.addEventListener('change', () => {
          const v = sprintSel.value;
          board.setSprint(v === '' ? undefined : v === '__backlog' ? board.BACKLOG : v);
        });
        search.addEventListener('input', () => board.setQuickFilter(search.value));
      })
      .catch((err) => console.error('[kanban]', err));
    return () => {
      disposed = true;
      board?.destroy?.();
    };
  },
})

The same rows your team already tracks, laid out as a board

A backlog is a table until someone needs to see the work moving. This board shows the very same rows as cards, grouped into the columns your status field already uses, so a plan reads as lanes of cards a person can pick up and move. Drag a card from one column to the next, or reorder it within a column, and the move is written straight back to the row underneath. Prefer the keyboard? Focus a card, grab it, choose where it lands with the arrows, and drop it, with every move read aloud for a screen reader.

Each column carries its own count and, where you track effort, the sum of its points, so a glance tells you how loaded a stage is. Set a work-in-progress limit on a column and the board turns away a move that would push it over, so a rule you agreed as a team is one the board keeps. Columns you name are always shown, even when empty, and a status that falls outside your set gets its own column rather than being dropped, so nothing goes missing.

Switch to lanes to split the same board by owner, filter to one sprint or the backlog, or search across every card. Open a card and its subtasks pop out as a full working grid you can sort and edit in place, so an epic opens into its stories without leaving the board. Because the board reads changes the same way a grid does, one live feed can drive it beside a grid and a chart at once, and a card can appear, move or update under you without losing your place.

How do I show my data as a kanban board?

Load the board module and call createKanban(element, config). Name the field that decides the columns with columnProperty, list the columns you want in columns (each with a title and an optional work-in-progress limit), and map the card face with card. Point pointsProperty at your effort field for the per-column sum, name swimlaneProperty, sprintProperty and epicProperty to unlock lanes, sprints and epics, and hand children a loader and a grid factory to let a card pop its subtasks out. The board writes a move back through the same edit path your grid uses, so it fits the schema you already have.