Kanban Board

Sprint 42

To do 3 cards

In progress 2 cards

Done 2 cards

@use 'sprucecss/scss/spruce' as *;

.kanban-board {
  @include layout-stack('m');

  &__header {
    @include layout-stack('xxs');
  }

  &__title {
    font-size: font-size('h4');
    margin: 0;
  }

  &__hint {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
  }

  &__columns {
    display: grid;
    gap: spacer('s');
    grid-auto-columns: minmax(16rem, 1fr);
    grid-auto-flow: column;
    overflow-x: auto;
    overscroll-behavior-inline: contain;
    padding-block-end: spacer('xs');
    scroll-snap-type: inline mandatory;
  }

  &__column {
    background-color: color('surface-muted');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('lg');
    display: flex;
    flex-direction: column;
    gap: spacer('s');
    padding: spacer('s');
    scroll-snap-align: start;
  }

  &__column-title {
    align-items: center;
    display: flex;
    font-family: inherit;
    font-size: config('font-size-base', $typography);
    gap: spacer('xs');
    margin: 0;
  }

  &__count {
    background-color: color('surface');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('pill');
    color: color('muted');
    font-size: 0.75rem;
    line-height: 1;
    padding: 0.25em 0.6em;
  }

  &__list {
    @include clear-list;
    display: flex;
    flex-direction: column;
    flex-grow: 1;
    gap: spacer('xs');
    // An empty column stays a drop target.
    min-block-size: 5rem;
  }

  &__card {
    background-color: color('surface');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('md');
    box-shadow: shadow('xs');
    margin: 0 !important;
    padding: spacer('s');
    @include layout-stack('xs');
    @include transition($property: 'box-shadow, opacity, outline-color');

    &[draggable='true'] {
      cursor: grab;
    }

    &:hover {
      box-shadow: shadow('sm');
    }

    &--dragging {
      opacity: 0.4;
    }

    &--grabbed {
      box-shadow: shadow('lg');
      outline: 2px solid color('primary');
      outline-offset: 2px;
    }
  }

  &__card-header {
    align-items: center;
    display: flex;
    justify-content: space-between;
  }

  &__tag {
    border-radius: radius('sm');
    color: color('heading');
    font-size: 0.75rem;
    font-weight: 700;
    line-height: 1;
    padding: 0.35em 0.6em;

    &--bug {
      background-color: color-alpha('danger', 0.15, 'alert');
    }

    &--feature {
      background-color: color-alpha('primary', 0.12);
    }

    &--design {
      background-color: color-alpha('info', 0.15, 'alert');
    }
  }

  &__handle {
    @include clear-btn;
    border-radius: radius('sm');
    color: color('muted');
    cursor: grab;
    display: inline-flex;
    padding: spacer('xxs');

    svg {
      block-size: 1rem;
      inline-size: 1rem;
    }

    &:hover,
    &[aria-pressed='true'] {
      background-color: color('surface-muted');
      color: color('heading');
    }

    &:focus-visible {
      outline: 2px solid color('focus-ring');
      outline-offset: 1px;
    }
  }

  &__card-title {
    font-family: inherit;
    font-size: config('font-size-base', $typography);
    font-weight: 600;

    a {
      color: color('heading');
      text-decoration: none;

      &:hover {
        text-decoration: underline;
      }
    }
  }

  &__meta {
    align-items: center;
    color: color('muted');
    display: flex;
    font-size: 0.8125rem;
    gap: spacer('xs');
  }

  &__avatar {
    align-items: center;
    aspect-ratio: 1;
    background-color: color-alpha('primary', 0.15);
    border-radius: radius('pill');
    color: color('primary');
    display: inline-flex;
    font-size: 0.6875rem;
    font-weight: 700;
    inline-size: 1.75rem;
    justify-content: center;
    margin-inline-start: auto;
  }
}
<section class="kanban-board" aria-labelledby="kanban-title" data-kanban>
    <header class="kanban-board__header">
        <h3 class="kanban-board__title" id="kanban-title">Sprint 42</h3>
        <p class="kanban-board__hint" id="kanban-hint" hidden>Drag a card to move it. With the keyboard, press the move button, use the arrow keys, then press Enter to drop or Escape to cancel.</p>
    </header>
    <div class="kanban-board__columns">
        <section class="kanban-board__column" aria-labelledby="kanban-todo" data-kanban-column>
            <h4 class="kanban-board__column-title"><span id="kanban-todo">To do</span> <span class="kanban-board__count" data-kanban-count>3<span class="sr-only"> cards</span></span></h4>
            <ul class="kanban-board__list" role="list" data-kanban-list>
                <li class="kanban-board__card" data-kanban-card>
                    <div class="kanban-board__card-header">
                        <span class="kanban-board__tag kanban-board__tag--bug">Bug</span>
                        <button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
                            <span class="sr-only">Move “Fix rounding in the tax total”</span>
                        </button>
                    </div>
                    <h5 class="kanban-board__card-title"><a href="#">Fix rounding in the tax total</a></h5>
                    <div class="kanban-board__meta">
                        <span>#212</span>
                        <span>Due <time datetime="2026-10-08">Oct 8</time></span>
                        <span class="kanban-board__avatar" title="Anna Smith"><span aria-hidden="true">AS</span><span class="sr-only">Assigned to Anna Smith</span></span>
                    </div>
                </li>
                <li class="kanban-board__card" data-kanban-card>
                    <div class="kanban-board__card-header">
                        <span class="kanban-board__tag kanban-board__tag--feature">Feature</span>
                        <button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
                            <span class="sr-only">Move “Export orders to CSV”</span>
                        </button>
                    </div>
                    <h5 class="kanban-board__card-title"><a href="#">Export orders to CSV</a></h5>
                    <div class="kanban-board__meta">
                        <span>#219</span>
                        <span>Due <time datetime="2026-10-12">Oct 12</time></span>
                        <span class="kanban-board__avatar" title="Ben King"><span aria-hidden="true">BK</span><span class="sr-only">Assigned to Ben King</span></span>
                    </div>
                </li>
                <li class="kanban-board__card" data-kanban-card>
                    <div class="kanban-board__card-header">
                        <span class="kanban-board__tag kanban-board__tag--design">Design</span>
                        <button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
                            <span class="sr-only">Move “New empty states for reports”</span>
                        </button>
                    </div>
                    <h5 class="kanban-board__card-title"><a href="#">New empty states for reports</a></h5>
                    <div class="kanban-board__meta">
                        <span>#223</span>
                        <span>Due <time datetime="2026-10-15">Oct 15</time></span>
                        <span class="kanban-board__avatar" title="Lea Moreau"><span aria-hidden="true">LM</span><span class="sr-only">Assigned to Lea Moreau</span></span>
                    </div>
                </li>
            </ul>
        </section>
        <section class="kanban-board__column" aria-labelledby="kanban-progress" data-kanban-column>
            <h4 class="kanban-board__column-title"><span id="kanban-progress">In progress</span> <span class="kanban-board__count" data-kanban-count>2<span class="sr-only"> cards</span></span></h4>
            <ul class="kanban-board__list" role="list" data-kanban-list>
                <li class="kanban-board__card" data-kanban-card>
                    <div class="kanban-board__card-header">
                        <span class="kanban-board__tag kanban-board__tag--feature">Feature</span>
                        <button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
                            <span class="sr-only">Move “Saved filters on the order list”</span>
                        </button>
                    </div>
                    <h5 class="kanban-board__card-title"><a href="#">Saved filters on the order list</a></h5>
                    <div class="kanban-board__meta">
                        <span>#204</span>
                        <span>Due <time datetime="2026-10-06">Oct 6</time></span>
                        <span class="kanban-board__avatar" title="Anna Smith"><span aria-hidden="true">AS</span><span class="sr-only">Assigned to Anna Smith</span></span>
                    </div>
                </li>
                <li class="kanban-board__card" data-kanban-card>
                    <div class="kanban-board__card-header">
                        <span class="kanban-board__tag kanban-board__tag--bug">Bug</span>
                        <button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
                            <span class="sr-only">Move “Webhook retries stop after 3 attempts”</span>
                        </button>
                    </div>
                    <h5 class="kanban-board__card-title"><a href="#">Webhook retries stop after 3 attempts</a></h5>
                    <div class="kanban-board__meta">
                        <span>#209</span>
                        <span>Due <time datetime="2026-10-05">Oct 5</time></span>
                        <span class="kanban-board__avatar" title="Jon Diaz"><span aria-hidden="true">JD</span><span class="sr-only">Assigned to Jon Diaz</span></span>
                    </div>
                </li>
            </ul>
        </section>
        <section class="kanban-board__column" aria-labelledby="kanban-done" data-kanban-column>
            <h4 class="kanban-board__column-title"><span id="kanban-done">Done</span> <span class="kanban-board__count" data-kanban-count>2<span class="sr-only"> cards</span></span></h4>
            <ul class="kanban-board__list" role="list" data-kanban-list>
                <li class="kanban-board__card" data-kanban-card>
                    <div class="kanban-board__card-header">
                        <span class="kanban-board__tag kanban-board__tag--design">Design</span>
                        <button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
                            <span class="sr-only">Move “Dark mode for the invoice PDF”</span>
                        </button>
                    </div>
                    <h5 class="kanban-board__card-title"><a href="#">Dark mode for the invoice PDF</a></h5>
                    <div class="kanban-board__meta">
                        <span>#198</span>
                        <span>Due <time datetime="2026-10-01">Oct 1</time></span>
                        <span class="kanban-board__avatar" title="Lea Moreau"><span aria-hidden="true">LM</span><span class="sr-only">Assigned to Lea Moreau</span></span>
                    </div>
                </li>
                <li class="kanban-board__card" data-kanban-card>
                    <div class="kanban-board__card-header">
                        <span class="kanban-board__tag kanban-board__tag--feature">Feature</span>
                        <button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
                            <span class="sr-only">Move “Bulk fulfillment”</span>
                        </button>
                    </div>
                    <h5 class="kanban-board__card-title"><a href="#">Bulk fulfillment</a></h5>
                    <div class="kanban-board__meta">
                        <span>#187</span>
                        <span>Due <time datetime="2026-09-29">Sep 29</time></span>
                        <span class="kanban-board__avatar" title="Ben King"><span aria-hidden="true">BK</span><span class="sr-only">Assigned to Ben King</span></span>
                    </div>
                </li>
            </ul>
        </section>
    </div>
    <p class="sr-only" aria-live="assertive" data-kanban-live></p>
</section>
(() => {
  document.querySelectorAll('[data-kanban]').forEach((board) => {
    const columns = [...board.querySelectorAll('[data-kanban-column]')];
    const live = board.querySelector('[data-kanban-live]');
    let dragged = null;
    let grabbed = null;
    let origin = null;

    const listOf = (column) => column.querySelector('[data-kanban-list]');
    const titleOf = (card) => card.querySelector('.kanban-board__card-title').textContent.trim();
    const columnName = (column) => document.getElementById(column.getAttribute('aria-labelledby')).textContent.trim();

    const updateCounts = () => {
      columns.forEach((column) => {
        column.querySelector('[data-kanban-count]').firstChild.nodeValue = listOf(column).children.length;
      });
    };

    const announce = (card, prefix = '') => {
      const column = card.closest('[data-kanban-column]');
      const cards = [...listOf(column).children];

      live.textContent = `${prefix}“${titleOf(card)}” is in ${columnName(column)}, position ${cards.indexOf(card) + 1} of ${cards.length}.`;
      updateCounts();
      board.dispatchEvent(new CustomEvent('kanban:move', { detail: { card, column } }));
    };

    // Enhance: the move buttons and dragging only work with JavaScript.
    board.querySelector('.kanban-board__hint').hidden = false;
    board.querySelectorAll('[data-kanban-card]').forEach((card) => {
      card.draggable = true;
      card.querySelector('[data-kanban-handle]').hidden = false;
    });

    // Pointer: native drag and drop.
    board.addEventListener('dragstart', (event) => {
      dragged = event.target.closest('[data-kanban-card]');

      if (!dragged) {
        return;
      }

      event.dataTransfer.effectAllowed = 'move';
      event.dataTransfer.setData('text/plain', titleOf(dragged));
      requestAnimationFrame(() => dragged.classList.add('kanban-board__card--dragging'));
    });

    board.addEventListener('dragover', (event) => {
      const list = event.target.closest('[data-kanban-list]');

      if (!dragged || !list) {
        return;
      }

      event.preventDefault();
      // Insert before the first card whose middle is below the pointer.
      const next = [...list.children].find((card) => {
        const box = card.getBoundingClientRect();
        return card !== dragged && event.clientY < box.top + box.height / 2;
      });

      if (next !== dragged.nextElementSibling || dragged.parentElement !== list) {
        list.insertBefore(dragged, next || null);
      }
    });

    board.addEventListener('drop', (event) => event.preventDefault());

    board.addEventListener('dragend', () => {
      if (!dragged) {
        return;
      }

      dragged.classList.remove('kanban-board__card--dragging');
      announce(dragged);
      dragged = null;
    });

    // Keyboard: press the move button, use the arrow keys, Enter (or the button again) drops, Escape cancels.
    const drop = (cancel = false) => {
      const handle = grabbed.querySelector('[data-kanban-handle]');

      if (cancel) {
        origin.list.insertBefore(grabbed, origin.next);
        handle.focus();
      }

      grabbed.classList.remove('kanban-board__card--grabbed');
      handle.setAttribute('aria-pressed', 'false');
      announce(grabbed, cancel ? 'Move cancelled. ' : 'Dropped. ');
      grabbed = null;
    };

    board.addEventListener('click', (event) => {
      const handle = event.target.closest('[data-kanban-handle]');

      if (!handle) {
        return;
      }

      if (grabbed) {
        drop();
        return;
      }

      grabbed = handle.closest('[data-kanban-card]');
      origin = { list: grabbed.parentElement, next: grabbed.nextElementSibling };
      grabbed.classList.add('kanban-board__card--grabbed');
      handle.setAttribute('aria-pressed', 'true');
      live.textContent = `Grabbed “${titleOf(grabbed)}”. Use the arrow keys to move, Enter to drop, Escape to cancel.`;
    });

    board.addEventListener('keydown', (event) => {
      if (!grabbed || !event.target.closest('[data-kanban-handle]')) {
        return;
      }

      const list = grabbed.parentElement;
      const column = grabbed.closest('[data-kanban-column]');
      const isRtl = getComputedStyle(board).direction === 'rtl';
      const step = { ArrowLeft: isRtl ? 1 : -1, ArrowRight: isRtl ? -1 : 1 }[event.key];

      if (event.key === 'Escape') {
        drop(true);
      } else if (event.key === 'Enter') {
        // The click event of the button drops the card.
        return;
      } else if (event.key === 'ArrowUp' && grabbed.previousElementSibling) {
        list.insertBefore(grabbed, grabbed.previousElementSibling);
      } else if (event.key === 'ArrowDown' && grabbed.nextElementSibling) {
        list.insertBefore(grabbed.nextElementSibling, grabbed);
      } else if (step) {
        const target = columns[columns.indexOf(column) + step];

        if (!target) {
          return;
        }

        // Keep the position in the new column when it is possible.
        const index = [...list.children].indexOf(grabbed);
        const targetList = listOf(target);

        targetList.insertBefore(grabbed, targetList.children[index] || null);
      } else {
        return;
      }

      event.preventDefault();

      if (grabbed) {
        // Moving an element in the DOM can drop the focus.
        grabbed.querySelector('[data-kanban-handle]').focus();
        grabbed.scrollIntoView({ block: 'nearest', inline: 'nearest' });
        announce(grabbed);
      }
    });

    // Leaving the card with Tab drops it where it is.
    board.addEventListener('focusout', (event) => {
      if (grabbed && !grabbed.contains(event.relatedTarget) && event.relatedTarget) {
        drop();
      }
    });
  });
})();

Kanban Board

A task board with columns and cards that you can move with drag and drop or entirely with the keyboard. Dependency-free: the native HTML drag and drop API and a few lines of JavaScript.

Anatomy

  • .kanban-board__column: a section labelled by its title, with a __count badge and the __list of cards.
  • .kanban-board__card: the __tag, the move button (__handle), the linked __card-title and the __meta (number, due date, assignee).
  • [data-kanban-live]: a visually hidden live region for the announcements.

Accessibility and Usability

  • Keyboard: every card has a move button (aria-pressed). Press it to pick the card up, move it with the arrow keys (up and down within the column, left and right between columns), then press Enter to drop it or Escape to put it back. Tabbing away drops the card where it is.
  • Every move is announced: "“Export orders to CSV” is in In progress, position 2 of 3."
  • The usage hint is connected to the move buttons with aria-describedby.
  • The left and right arrows follow the reading direction in right-to-left languages.
  • The columns are sections labelled by their titles, and the card count has hidden text ("3 cards").

Technical Details

  • Progressive enhancement: without JavaScript, the board is a readable list of columns and linked cards. The script adds draggable, reveals the move buttons and the hint.
  • After every move, the board dispatches a kanban:move event with the card and the column in detail. Listen to it to save the new order:
document.querySelector('[data-kanban]').addEventListener('kanban:move', (event) => {
  const { card, column } = event.detail;
  // fetch('/api/tasks/…', { method: 'PATCH', body: … })
});
  • On narrow screens, the columns scroll horizontally with scroll snapping.
  • The HTML drag and drop API doesn't work with touch in every mobile browser; the move button works everywhere.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.