Web InnoventixFreeCode

Kanban Swimlane

Original · free

swimlane grouped board

byWeb InnoventixReact + Tailwind
kanbanswimlane
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/kanban-swimlane.json
kanban-swimlane.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type ColumnId = "triage" | "building" | "review" | "shipped";
type LaneId = "acquisition" | "platform" | "design";
type TagId = "SEO" | "Copy" | "Growth" | "Infra" | "Perf" | "A11y" | "Docs";

type Column = {
  id: ColumnId;
  label: string;
  wip: number | null;
};

type Lane = {
  id: LaneId;
  name: string;
  meta: string;
  dot: string;
  rail: string;
  chip: string;
};

type Card = {
  id: string;
  lane: LaneId;
  col: ColumnId;
  title: string;
  tag: TagId;
  owner: string;
  initials: string;
  points: number;
  blocked: boolean;
};

const COLUMNS: Column[] = [
  { id: "triage", label: "Triage", wip: null },
  { id: "building", label: "Building", wip: 3 },
  { id: "review", label: "In review", wip: null },
  { id: "shipped", label: "Shipped", wip: null },
];

const LANES: Lane[] = [
  {
    id: "acquisition",
    name: "Acquisition squad",
    meta: "Priya, Dana, Salman",
    dot: "bg-sky-500",
    rail: "bg-sky-500/70",
    chip: "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/30",
  },
  {
    id: "platform",
    name: "Platform & infra",
    meta: "Marco, Ivo",
    dot: "bg-violet-500",
    rail: "bg-violet-500/70",
    chip: "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-500/30",
  },
  {
    id: "design",
    name: "Design systems",
    meta: "Lena, Noor",
    dot: "bg-emerald-500",
    rail: "bg-emerald-500/70",
    chip: "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
  },
];

const TAG_STYLES: Record<TagId, string> = {
  SEO: "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/30",
  Copy: "bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30",
  Growth: "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/30",
  Infra: "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-500/30",
  Perf: "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
  A11y: "bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30",
  Docs: "bg-zinc-100 text-zinc-700 ring-zinc-200 dark:bg-zinc-500/10 dark:text-zinc-300 dark:ring-zinc-500/30",
};

const INITIAL_CARDS: Card[] = [
  {
    id: "acq-1",
    lane: "acquisition",
    col: "triage",
    title: "Rewrite the pricing hero for the self-serve tier",
    tag: "Copy",
    owner: "Priya Nair",
    initials: "PN",
    points: 3,
    blocked: false,
  },
  {
    id: "acq-2",
    lane: "acquisition",
    col: "triage",
    title: "Merge the duplicate /features and /product pages",
    tag: "SEO",
    owner: "Salman Riaz",
    initials: "SR",
    points: 5,
    blocked: true,
  },
  {
    id: "acq-3",
    lane: "acquisition",
    col: "building",
    title: "Ship the exit-intent offer to 10% of traffic",
    tag: "Growth",
    owner: "Dana Kovac",
    initials: "DK",
    points: 2,
    blocked: false,
  },
  {
    id: "acq-4",
    lane: "acquisition",
    col: "review",
    title: "Service schema markup on 12 landing pages",
    tag: "SEO",
    owner: "Priya Nair",
    initials: "PN",
    points: 3,
    blocked: false,
  },
  {
    id: "acq-5",
    lane: "acquisition",
    col: "shipped",
    title: "Cut blog index LCP from 4.1s to 1.8s",
    tag: "Perf",
    owner: "Dana Kovac",
    initials: "DK",
    points: 2,
    blocked: false,
  },
  {
    id: "plt-1",
    lane: "platform",
    col: "triage",
    title: "Move the image pipeline off the legacy CDN",
    tag: "Infra",
    owner: "Marco Vidal",
    initials: "MV",
    points: 8,
    blocked: false,
  },
  {
    id: "plt-2",
    lane: "platform",
    col: "building",
    title: "Rate-limit the public /api/lead endpoint",
    tag: "Infra",
    owner: "Ivo Tanev",
    initials: "IT",
    points: 3,
    blocked: true,
  },
  {
    id: "plt-3",
    lane: "platform",
    col: "building",
    title: "Connection pooling for preview databases",
    tag: "Infra",
    owner: "Marco Vidal",
    initials: "MV",
    points: 5,
    blocked: false,
  },
  {
    id: "plt-4",
    lane: "platform",
    col: "review",
    title: "Nightly backup restore drill",
    tag: "Infra",
    owner: "Ivo Tanev",
    initials: "IT",
    points: 2,
    blocked: false,
  },
  {
    id: "plt-5",
    lane: "platform",
    col: "shipped",
    title: "Structured logs on every edge function",
    tag: "Infra",
    owner: "Marco Vidal",
    initials: "MV",
    points: 3,
    blocked: false,
  },
  {
    id: "dsn-1",
    lane: "design",
    col: "triage",
    title: "Fix contrast on the amber alert tokens",
    tag: "A11y",
    owner: "Lena Fischer",
    initials: "LF",
    points: 2,
    blocked: false,
  },
  {
    id: "dsn-2",
    lane: "design",
    col: "triage",
    title: "Write the swimlane board spec for the docs site",
    tag: "Docs",
    owner: "Noor Abadi",
    initials: "NA",
    points: 3,
    blocked: false,
  },
  {
    id: "dsn-3",
    lane: "design",
    col: "building",
    title: "Focus-visible ring pass across 40 components",
    tag: "A11y",
    owner: "Lena Fischer",
    initials: "LF",
    points: 5,
    blocked: false,
  },
  {
    id: "dsn-4",
    lane: "design",
    col: "shipped",
    title: "Dark-mode tokens for dense data tables",
    tag: "Docs",
    owner: "Noor Abadi",
    initials: "NA",
    points: 3,
    blocked: false,
  },
];

const HINT_ID = "kbsw-hint";

function ChevronIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" aria-hidden="true" fill="none" className={className}>
      <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function ArrowIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" aria-hidden="true" fill="none" className={className}>
      <path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function BlockedIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" aria-hidden="true" fill="none" className={className}>
      <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.6" />
      <path d="M4.2 4.2l7.6 7.6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function ResetIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" aria-hidden="true" fill="none" className={className}>
      <path
        d="M13 8a5 5 0 11-1.6-3.7M13 2v3h-3"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function colIndex(id: ColumnId): number {
  return COLUMNS.findIndex((c) => c.id === id);
}

function laneName(id: LaneId): string {
  const lane = LANES.find((l) => l.id === id);
  return lane ? lane.name : id;
}

function columnLabel(id: ColumnId): string {
  const col = COLUMNS.find((c) => c.id === id);
  return col ? col.label : id;
}

function reorderWithin(list: Card[], id: string, delta: number): Card[] {
  const card = list.find((c) => c.id === id);
  if (!card) return list;
  const positions: number[] = [];
  list.forEach((c, i) => {
    if (c.lane === card.lane && c.col === card.col) positions.push(i);
  });
  const at = positions.indexOf(list.indexOf(card));
  const to = at + delta;
  if (at < 0 || to < 0 || to >= positions.length) return list;
  const next = list.slice();
  const a = positions[at];
  const b = positions[to];
  const tmp = next[a];
  next[a] = next[b];
  next[b] = tmp;
  return next;
}

export default function KanbanSwimlane() {
  const reduce = useReducedMotion();
  const [cards, setCards] = useState<Card[]>(INITIAL_CARDS);
  const [openLanes, setOpenLanes] = useState<Record<LaneId, boolean>>({
    acquisition: true,
    platform: true,
    design: true,
  });
  const [blockedOnly, setBlockedOnly] = useState(false);
  const [grabbedId, setGrabbedId] = useState<string | null>(null);
  const [message, setMessage] = useState("");

  const snapshot = useRef<Card[]>(INITIAL_CARDS);
  const cardRefs = useRef<Map<string, HTMLButtonElement | null>>(new Map());
  const pendingFocus = useRef<string | null>(null);

  useEffect(() => {
    const id = pendingFocus.current;
    if (!id) return;
    pendingFocus.current = null;
    cardRefs.current.get(id)?.focus();
  });

  const visible = useMemo(
    () => (blockedOnly ? cards.filter((c) => c.blocked) : cards),
    [cards, blockedOnly],
  );

  const groupOf = (lane: LaneId, col: ColumnId): Card[] =>
    visible.filter((c) => c.lane === lane && c.col === col);

  const columnTotals = useMemo(() => {
    const totals: Record<ColumnId, number> = { triage: 0, building: 0, review: 0, shipped: 0 };
    for (const c of visible) totals[c.col] += 1;
    return totals;
  }, [visible]);

  const grabbed = grabbedId ? cards.find((c) => c.id === grabbedId) ?? null : null;

  function pickUp(card: Card) {
    snapshot.current = cards;
    setGrabbedId(card.id);
    setMessage(`Picked up ${card.title}. Use arrow keys to move it, Enter to drop, Escape to cancel.`);
  }

  function drop(card: Card) {
    setGrabbedId(null);
    setMessage(`Dropped ${card.title} in ${columnLabel(card.col)}, ${laneName(card.lane)}.`);
  }

  function cancel(card: Card) {
    setCards(snapshot.current);
    setGrabbedId(null);
    pendingFocus.current = card.id;
    setMessage(`Move cancelled. ${card.title} is back in its original place.`);
  }

  function moveToColumn(card: Card, col: ColumnId) {
    if (col === card.col) return;
    setCards((prev) => prev.map((c) => (c.id === card.id ? { ...c, col } : c)));
    pendingFocus.current = card.id;
    setMessage(`${card.title} moved to ${columnLabel(col)} in ${laneName(card.lane)}.`);
  }

  function shiftColumn(card: Card, dir: -1 | 1) {
    const next = COLUMNS[colIndex(card.col) + dir];
    if (!next) {
      setMessage(`${card.title} is already in ${columnLabel(card.col)}.`);
      return;
    }
    moveToColumn(card, next.id);
  }

  function shiftRow(card: Card, delta: -1 | 1) {
    setCards((prev) => reorderWithin(prev, card.id, delta));
    pendingFocus.current = card.id;
    setMessage(`${card.title} moved ${delta < 0 ? "up" : "down"} in ${columnLabel(card.col)}.`);
  }

  function focusNeighbour(card: Card, dx: number, dy: number) {
    if (dy !== 0) {
      const column = groupOf(card.lane, card.col);
      const at = column.findIndex((c) => c.id === card.id);
      const target = column[at + dy];
      if (target) cardRefs.current.get(target.id)?.focus();
      return;
    }
    const group = groupOf(card.lane, card.col);
    const at = Math.max(0, group.findIndex((c) => c.id === card.id));
    let i = colIndex(card.col) + dx;
    while (i >= 0 && i < COLUMNS.length) {
      const candidates = groupOf(card.lane, COLUMNS[i].id);
      if (candidates.length > 0) {
        const target = candidates[Math.min(at, candidates.length - 1)];
        cardRefs.current.get(target.id)?.focus();
        return;
      }
      i += dx;
    }
  }

  function onCardKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>, card: Card) {
    const held = grabbedId === card.id;
    switch (event.key) {
      case "ArrowLeft":
        event.preventDefault();
        if (held) shiftColumn(card, -1);
        else focusNeighbour(card, -1, 0);
        break;
      case "ArrowRight":
        event.preventDefault();
        if (held) shiftColumn(card, 1);
        else focusNeighbour(card, 1, 0);
        break;
      case "ArrowUp":
        event.preventDefault();
        if (held) shiftRow(card, -1);
        else focusNeighbour(card, 0, -1);
        break;
      case "ArrowDown":
        event.preventDefault();
        if (held) shiftRow(card, 1);
        else focusNeighbour(card, 0, 1);
        break;
      case "Escape":
        if (held) {
          event.preventDefault();
          cancel(card);
        }
        break;
      default:
        break;
    }
  }

  function toggleLane(id: LaneId) {
    setOpenLanes((prev) => ({ ...prev, [id]: !prev[id] }));
    setGrabbedId(null);
  }

  function resetBoard() {
    setCards(INITIAL_CARDS);
    snapshot.current = INITIAL_CARDS;
    setGrabbedId(null);
    setBlockedOnly(false);
    setMessage("Board reset to the start of the sprint.");
  }

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes kbsw-hold {
          0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5); }
          50% { box-shadow: 0 0 0 7px rgba(99, 102, 241, 0); }
        }
        @keyframes kbsw-slot-in {
          from { opacity: 0; transform: translateY(4px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .kbsw-hold { animation: kbsw-hold 1.9s ease-out infinite; }
        .kbsw-slot { animation: kbsw-slot-in 220ms ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .kbsw-hold, .kbsw-slot { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <header className="mb-8 flex flex-col gap-6 sm:mb-10 lg:flex-row lg:items-end lg:justify-between">
          <div>
            <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
              Sprint 24 &middot; day 6 of 10
            </p>
            <h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
              Delivery board
            </h2>
            <p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
              Three squads, one board. Each swimlane keeps its own row so a stuck ticket in Platform never
              hides behind Acquisition&rsquo;s backlog.
            </p>
          </div>

          <div className="flex flex-wrap items-center gap-3">
            <button
              type="button"
              role="switch"
              aria-checked={blockedOnly}
              onClick={() => {
                setBlockedOnly((v) => !v);
                setGrabbedId(null);
                setMessage(blockedOnly ? "Showing all cards." : "Showing blocked cards only.");
              }}
              className="inline-flex items-center gap-2.5 rounded-full border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
            >
              <span
                aria-hidden="true"
                className={`relative h-5 w-9 rounded-full transition-colors ${
                  blockedOnly ? "bg-rose-500" : "bg-slate-300 dark:bg-slate-700"
                }`}
              >
                <span
                  className="absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all duration-200"
                  style={{ left: blockedOnly ? "1.125rem" : "0.125rem" }}
                />
              </span>
              Blocked only
            </button>

            <button
              type="button"
              onClick={resetBoard}
              className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
            >
              <ResetIcon className="h-4 w-4" />
              Reset board
            </button>
          </div>
        </header>

        <p
          id={HINT_ID}
          className="mb-4 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs leading-relaxed text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400"
        >
          {grabbed ? (
            <span className="font-medium text-indigo-700 dark:text-indigo-300">
              Moving &ldquo;{grabbed.title}&rdquo; &mdash; &larr; &rarr; changes column, &uarr; &darr; reorders,
              Enter drops, Esc cancels.
            </span>
          ) : (
            <>
              Focus a card and press <kbd className="rounded border border-slate-300 px-1 font-sans dark:border-slate-700">Enter</kbd>{" "}
              to pick it up, then use the arrow keys. Arrow keys alone move focus between cards.
            </>
          )}
        </p>

        <div className="overflow-x-auto pb-2">
          <div className="min-w-[56rem]">
            <div className="grid grid-cols-4 gap-3">
              {COLUMNS.map((col) => {
                const count = columnTotals[col.id];
                const over = col.wip !== null && count > col.wip;
                return (
                  <div
                    key={col.id}
                    className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 dark:border-slate-800 dark:bg-slate-900"
                  >
                    <span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{col.label}</span>
                    <span
                      className={`rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ring-1 ring-inset ${
                        over
                          ? "bg-amber-50 text-amber-700 ring-amber-300 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/40"
                          : "bg-slate-100 text-slate-600 ring-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:ring-slate-700"
                      }`}
                      title={col.wip !== null ? `WIP limit ${col.wip}` : undefined}
                    >
                      {col.wip !== null ? `${count}/${col.wip}` : count}
                    </span>
                  </div>
                );
              })}
            </div>

            <div className="mt-3 space-y-3">
              {LANES.map((lane) => {
                const open = openLanes[lane.id];
                const laneCards = visible.filter((c) => c.lane === lane.id);
                const points = laneCards.reduce((sum, c) => sum + c.points, 0);
                const done = laneCards.filter((c) => c.col === "shipped").length;

                return (
                  <section
                    key={lane.id}
                    aria-labelledby={`kbsw-lane-${lane.id}`}
                    className="overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900/60"
                  >
                    <h3 id={`kbsw-lane-${lane.id}`} className="m-0">
                      <button
                        type="button"
                        aria-expanded={open}
                        aria-controls={`kbsw-lanebody-${lane.id}`}
                        onClick={() => toggleLane(lane.id)}
                        className="flex w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:hover:bg-slate-800/60"
                      >
                        <ChevronIcon
                          className={`h-4 w-4 shrink-0 text-slate-500 transition-transform duration-200 dark:text-slate-400 ${
                            open ? "" : "-rotate-90"
                          }`}
                        />
                        <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${lane.dot}`} aria-hidden="true" />
                        <span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{lane.name}</span>
                        <span className="hidden text-xs text-slate-500 sm:inline dark:text-slate-500">{lane.meta}</span>
                        <span className="ml-auto flex items-center gap-2">
                          <span className={`rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${lane.chip}`}>
                            {points} pts
                          </span>
                          <span className="text-xs tabular-nums text-slate-500 dark:text-slate-500">
                            {done}/{laneCards.length} shipped
                          </span>
                        </span>
                      </button>
                    </h3>

                    <AnimatePresence initial={false}>
                      {open && (
                        <motion.div
                          key="body"
                          id={`kbsw-lanebody-${lane.id}`}
                          initial={reduce ? false : { height: 0, opacity: 0 }}
                          animate={{ height: "auto", opacity: 1 }}
                          exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                          transition={{ duration: reduce ? 0 : 0.28, ease: "easeOut" }}
                          className="overflow-hidden"
                        >
                          <div className="relative grid grid-cols-4 gap-3 border-t border-slate-200 p-3 dark:border-slate-800">
                            <span
                              aria-hidden="true"
                              className={`absolute left-0 top-0 h-full w-0.5 ${lane.rail}`}
                            />
                            {COLUMNS.map((col) => {
                              const group = groupOf(lane.id, col.id);
                              const canDrop = grabbed !== null && grabbed.lane === lane.id && grabbed.col !== col.id;
                              return (
                                <div key={col.id} className="min-w-0">
                                  <ul className="flex list-none flex-col gap-2 p-0">
                                    {group.map((card) => {
                                      const held = grabbedId === card.id;
                                      const left = COLUMNS[colIndex(card.col) - 1];
                                      const right = COLUMNS[colIndex(card.col) + 1];
                                      return (
                                        <motion.li
                                          key={card.id}
                                          layoutId={reduce ? undefined : `kbsw-card-${card.id}`}
                                          transition={{ duration: reduce ? 0 : 0.26, ease: "easeOut" }}
                                          className={`relative rounded-lg border bg-white transition-colors dark:bg-slate-900 ${
                                            held
                                              ? "kbsw-hold border-indigo-400 dark:border-indigo-500"
                                              : "border-slate-200 dark:border-slate-800"
                                          }`}
                                        >
                                          <button
                                            type="button"
                                            ref={(el) => {
                                              cardRefs.current.set(card.id, el);
                                            }}
                                            aria-pressed={held}
                                            aria-describedby={HINT_ID}
                                            onClick={() => (held ? drop(card) : pickUp(card))}
                                            onKeyDown={(event) => onCardKeyDown(event, card)}
                                            className="block w-full rounded-lg p-2.5 pr-14 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
                                          >
                                            <span className="block text-[13px] font-medium leading-snug text-slate-900 dark:text-slate-100">
                                              {card.title}
                                            </span>
                                            <span className="mt-2 flex flex-wrap items-center gap-1.5">
                                              <span
                                                className={`rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ring-inset ${TAG_STYLES[card.tag]}`}
                                              >
                                                {card.tag}
                                              </span>
                                              {card.blocked && (
                                                <span className="inline-flex items-center gap-1 rounded bg-rose-50 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-700 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/30">
                                                  <BlockedIcon className="h-2.5 w-2.5" />
                                                  Blocked
                                                </span>
                                              )}
                                            </span>
                                            <span className="mt-2.5 flex items-center gap-2">
                                              <span className="flex h-5 w-5 items-center justify-center rounded-full bg-slate-200 text-[9px] font-bold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
                                                {card.initials}
                                              </span>
                                              <span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
                                                {card.owner}
                                              </span>
                                              <span className="ml-auto text-[11px] font-semibold tabular-nums text-slate-400 dark:text-slate-500">
                                                {card.points}
                                              </span>
                                            </span>
                                          </button>

                                          <div className="absolute right-1.5 top-1.5 z-10 flex gap-0.5">
                                            <button
                                              type="button"
                                              disabled={!left}
                                              onClick={() => shiftColumn(card, -1)}
                                              aria-label={
                                                left
                                                  ? `Move ${card.title} to ${left.label}`
                                                  : `${card.title} is in the first column`
                                              }
                                              className="flex h-6 w-6 items-center justify-center rounded text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-30 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200"
                                            >
                                              <ArrowIcon className="h-3.5 w-3.5 rotate-180" />
                                            </button>
                                            <button
                                              type="button"
                                              disabled={!right}
                                              onClick={() => shiftColumn(card, 1)}
                                              aria-label={
                                                right
                                                  ? `Move ${card.title} to ${right.label}`
                                                  : `${card.title} is in the last column`
                                              }
                                              className="flex h-6 w-6 items-center justify-center rounded text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-30 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200"
                                            >
                                              <ArrowIcon className="h-3.5 w-3.5" />
                                            </button>
                                          </div>
                                        </motion.li>
                                      );
                                    })}

                                    {canDrop && grabbed && (
                                      <li className="kbsw-slot list-none">
                                        <button
                                          type="button"
                                          onClick={() => {
                                            moveToColumn(grabbed, col.id);
                                            setGrabbedId(null);
                                          }}
                                          className="w-full rounded-lg border border-dashed border-indigo-400 bg-indigo-50/60 px-2 py-3 text-[11px] font-medium text-indigo-700 transition-colors hover:bg-indigo-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-indigo-500/60 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20"
                                        >
                                          Drop in {col.label}
                                        </button>
                                      </li>
                                    )}

                                    {group.length === 0 && !canDrop && (
                                      <li className="rounded-lg border border-dashed border-slate-200 px-2 py-4 text-center text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-600">
                                        Empty
                                      </li>
                                    )}
                                  </ul>
                                </div>
                              );
                            })}
                          </div>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </section>
                );
              })}
            </div>
          </div>
        </div>

        <p aria-live="polite" className="sr-only">
          {message}
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →