Kanban Board
A kanban board with accessible drag and drop, styled with Tailwind CSS.
import {Button, DropIndicator, GridList, GridListItem, isTextDropItem, Text, useDragAndDrop} from 'react-aria-components';
import {ListData, useListData} from 'react-stately';
import React, {ReactNode} from 'react';
export default function KanbanBoard(): ReactNode {
  let list = useListData({
    initialItems: tickets
  });
  return (
    <div className="grid grid-cols-[repeat(3,minmax(280px,1fr))] md:justify-center gap-4 -mx-8 px-8 py-8 box-border w-full overflow-auto relative snap-x snap-mandatory no-scrollbar">
      <Column status="Open" list={list} itemClassName="selected:bg-green-100 selected:border-green-500 dark:selected:bg-green-900 dark:selected:border-green-700" />
      <Column status="In Progress" list={list} itemClassName="selected:bg-blue-100 selected:border-blue-500 dark:selected:bg-blue-900 dark:selected:border-blue-700" />
      <Column status="Closed" list={list} itemClassName="selected:bg-red-100 selected:border-red-500 dark:selected:bg-red-900 dark:selected:border-red-700" />
    </div>
  );
}
interface ColumnProps {
  list: ListData<typeof tickets[0]>,
  status: string,
  itemClassName?: string
}
function Column({list, status, itemClassName}: ColumnProps) {
  let items = list.items.filter(t => t.status === status);
  let {dragAndDropHooks} = useDragAndDrop({
    // Provide drag data in a custom format as well as plain text.
    getItems(keys, items) {
      return items.map((item) => ({
        'issue-id': item.id,
        'text/plain': item.title
      }));
    },
    renderDropIndicator(target) {
      return (
        <DropIndicator target={target} className="h-0 -my-1.5 -translate-y-[5px] -mx-2 invisible drop-target:visible">
          <svg height={10} className="block w-full stroke-blue-500 fill-none forced-colors:stroke-[Highlight]">
            <circle cx={5} cy={5} r={5 - 1} strokeWidth={2} />
            <line x1={20} x2="100%" transform="translate(-10 0)" y1={5} y2={5} strokeWidth={2} />
            <circle cx="100%" cy={5} r={5 - 1} transform="translate(-5 0)" strokeWidth={2} />
          </svg>
        </DropIndicator>
      );
    },
    // Accept drops with the custom format.
    acceptedDragTypes: ['issue-id'],
    // Ensure items are always moved rather than copied.
    getDropOperation: () => 'move',
    // Handle drops between items from other lists.
    async onInsert(e) {
      let ids = await Promise.all(
        e.items.filter(isTextDropItem).map(item => item.getText('issue-id'))
      );
      for (let id of ids) {
        list.update(id, {...list.getItem(id)!, status});
      }
      if (e.target.dropPosition === 'before') {
        list.moveBefore(e.target.key, ids);
      } else if (e.target.dropPosition === 'after') {
        list.moveAfter(e.target.key, ids);
      }
    },
    // Handle drops on the collection when empty.
    async onRootDrop(e) {
      let ids = await Promise.all(
        e.items.filter(isTextDropItem).map(item => item.getText('issue-id'))
      );
      for (let id of ids) {
        list.update(id, {...list.getItem(id)!, status});
      }
    },
    // Handle reordering items within the same list.
    onReorder(e) {
      if (e.target.dropPosition === 'before') {
        list.moveBefore(e.target.key, e.keys);
      } else if (e.target.dropPosition === 'after') {
        list.moveAfter(e.target.key, e.keys);
      }
    }
  });
  return (
    <section className="flex flex-col gap-2 snap-center">
      <header>
        <h3 className="font-semibold text-zinc-800 dark:text-zinc-200 my-0">{status}</h3>
        <span className="text-sm text-zinc-700 dark:text-zinc-400">{items.length} {items.length === 1 ? 'task' : 'tasks'}</span>
      </header>
      <GridList
        items={items}
        aria-label={status}
        selectionMode="multiple"
        dragAndDropHooks={dragAndDropHooks}
        renderEmptyState={() => 'No tasks.'}
        className="h-[320px] p-2 md:p-4 overflow-y-auto overflow-x-hidden relative outline outline-0 bg-white/70 dark:bg-zinc-900/60 backdrop-blur-sm border border-black/10 dark:border-white/10 bg-clip-padding text-gray-700 dark:text-zinc-400 flex flex-col gap-3 rounded-xl shadow-xl drop-target:bg-blue-200 dark:drop-target:bg-blue-800/60 drop-target:outline-2 outline-blue-500 forced-colors:outline-[Highlight] -outline-offset-2 empty:items-center empty:justify-center">
        {item => <Card item={item} className={itemClassName} />}
      </GridList>
    </section>
  );
}
interface CardProps {
  id?: string,
  item: typeof tickets[0],
  className?: string
}
function Card({id, item, className}: CardProps) {
  return (
    <GridListItem id={id} value={item} textValue={item.title} className={`group grid grid-cols-[1fr_auto] gap-1 p-2 rounded-lg border border-solid border-black/10 hover:border-black/20 dark:border-white/10 dark:hover:border-white/20 forced-colors:border-[ButtonBorder]! bg-white/80 dark:bg-zinc-900/70 bg-clip-padding hover:shadow-md selected:shadow-md dragging:opacity-50 transition text-slate-700 dark:text-slate-200 cursor-default select-none outline outline-0 outline-offset-2 focus-visible:outline-2 outline-blue-500 forced-colors:outline-[Highlight] forced-colors:text-[ButtonText]! forced-colors:selected:bg-[Highlight]! forced-colors:selected:text-[HighlightText]! forced-color-adjust-none ${className}`}>
      <span className="font-bold truncate">{item.title}</span>
      <span className="text-sm justify-self-end">{item.id}</span>
      <Text slot="description" className="text-sm line-clamp-2 col-span-2 text-slate-500 dark:text-zinc-300 forced-colors:text-inherit!">{item.description}</Text>
      <span className="flex items-center gap-1">
        <img src={item.avatar} alt="" className="h-4 w-4 rounded-full" />
        <span className="text-sm">{item.assignee}</span>
      </span>
      <Button slot="drag" className="bg-transparent border-none text-gray-500 dark:text-zinc-300 text-base leading-none w-fit aspect-square p-0 justify-self-end outline outline-0 focus-visible:outline-2 outline-blue-500 forced-colors:outline-[Highlight] rounded-xs sr-only group-focus-visible:not-sr-only focus:not-sr-only forced-colors:group-selected:text-[HighlightText] forced-colors:group-selected:outline-[HighlightText]">≡</Button>
    </GridListItem>
  );
}