Web InnoventixFreeCode

Kanban Labels

Original · free

board cards with coloured labels

byWeb InnoventixReact + Tailwind
kanbanlabels
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-labels.json
kanban-labels.tsx
"use client";

import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type LabelId =
  | "bug"
  | "design"
  | "backend"
  | "research"
  | "billing"
  | "a11y"
  | "docs";

type ColumnId = "triage" | "building" | "review" | "shipped";

type Label = {
  id: LabelId;
  name: string;
  chip: string;
  dot: string;
  ring: string;
};

type Card = {
  id: string;
  title: string;
  detail: string;
  column: ColumnId;
  labels: LabelId[];
  assignee: string;
  points: number;
  due: string;
};

const LABELS: Label[] = [
  {
    id: "bug",
    name: "Bug",
    chip: "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-300",
    dot: "bg-rose-500",
    ring: "focus-visible:ring-rose-500",
  },
  {
    id: "design",
    name: "Design",
    chip: "bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-300",
    dot: "bg-violet-500",
    ring: "focus-visible:ring-violet-500",
  },
  {
    id: "backend",
    name: "Backend",
    chip: "bg-indigo-100 text-indigo-800 dark:bg-indigo-500/15 dark:text-indigo-300",
    dot: "bg-indigo-500",
    ring: "focus-visible:ring-indigo-500",
  },
  {
    id: "research",
    name: "Research",
    chip: "bg-sky-100 text-sky-800 dark:bg-sky-500/15 dark:text-sky-300",
    dot: "bg-sky-500",
    ring: "focus-visible:ring-sky-500",
  },
  {
    id: "billing",
    name: "Billing",
    chip: "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-300",
    dot: "bg-amber-500",
    ring: "focus-visible:ring-amber-500",
  },
  {
    id: "a11y",
    name: "Accessibility",
    chip: "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300",
    dot: "bg-emerald-500",
    ring: "focus-visible:ring-emerald-500",
  },
  {
    id: "docs",
    name: "Docs",
    chip: "bg-zinc-200 text-zinc-800 dark:bg-zinc-500/20 dark:text-zinc-300",
    dot: "bg-zinc-500",
    ring: "focus-visible:ring-zinc-500",
  },
];

const LABEL_MAP: Record<LabelId, Label> = LABELS.reduce(
  (acc, label) => {
    acc[label.id] = label;
    return acc;
  },
  {} as Record<LabelId, Label>,
);

const COLUMNS: { id: ColumnId; name: string; hint: string }[] = [
  { id: "triage", name: "Triage", hint: "Unowned, needs a decision" },
  { id: "building", name: "Building", hint: "Branch open, work in flight" },
  { id: "review", name: "In review", hint: "Waiting on a second pair of eyes" },
  { id: "shipped", name: "Shipped", hint: "Live on production this week" },
];

const INITIAL_CARDS: Card[] = [
  {
    id: "WI-412",
    title: "Checkout retries charge twice on 3DS timeout",
    detail:
      "Stripe webhook fires before the redirect resolves, so the order row gets inserted a second time.",
    column: "triage",
    labels: ["bug", "billing", "backend"],
    assignee: "Priya N.",
    points: 5,
    due: "Jul 21",
  },
  {
    id: "WI-398",
    title: "Pricing table collapses under 360px",
    detail:
      "The comparison grid forces a 3-column min-width; Galaxy S8 users get a horizontal scrollbar.",
    column: "triage",
    labels: ["design", "bug"],
    assignee: "Marco L.",
    points: 2,
    due: "Jul 23",
  },
  {
    id: "WI-377",
    title: "Interview 6 agency owners about reporting exports",
    detail:
      "Four calls booked. Looking for whether CSV beats a live share link for client handoffs.",
    column: "triage",
    labels: ["research"],
    assignee: "Ada K.",
    points: 3,
    due: "Aug 04",
  },
  {
    id: "WI-401",
    title: "Keyboard traps in the invoice date picker",
    detail:
      "Tab escapes the popover but focus never returns to the trigger. Screen readers announce nothing on close.",
    column: "building",
    labels: ["a11y", "bug"],
    assignee: "Ada K.",
    points: 3,
    due: "Jul 19",
  },
  {
    id: "WI-405",
    title: "Move usage aggregation to a nightly rollup job",
    detail:
      "Dashboard queries scan 40M event rows per load. Precompute per-workspace daily totals instead.",
    column: "building",
    labels: ["backend"],
    assignee: "Sam O.",
    points: 8,
    due: "Jul 28",
  },
  {
    id: "WI-388",
    title: "Empty states for the new board view",
    detail:
      "Three variants: no cards, filtered-to-zero, and first-run. Copy is drafted, illustrations pending.",
    column: "building",
    labels: ["design", "docs"],
    assignee: "Marco L.",
    points: 2,
    due: "Jul 22",
  },
  {
    id: "WI-369",
    title: "Proration preview on plan downgrade",
    detail:
      "Shows the credit amount before the user confirms. Needs a second review on the rounding path.",
    column: "review",
    labels: ["billing", "backend"],
    assignee: "Priya N.",
    points: 5,
    due: "Jul 18",
  },
  {
    id: "WI-352",
    title: "Contrast pass on the dark theme chips",
    detail:
      "Raised every label token to at least 4.5:1 against the card surface. Verified with axe.",
    column: "review",
    labels: ["a11y", "design"],
    assignee: "Ada K.",
    points: 1,
    due: "Jul 17",
  },
  {
    id: "WI-340",
    title: "Webhook signing key rotation guide",
    detail:
      "Step-by-step for rotating without dropping events, plus the 24h dual-key window.",
    column: "shipped",
    labels: ["docs", "backend"],
    assignee: "Sam O.",
    points: 2,
    due: "Jul 11",
  },
  {
    id: "WI-331",
    title: "Fix dropped events during deploy restarts",
    detail:
      "Added a drain phase to the worker so in-flight jobs finish before SIGTERM.",
    column: "shipped",
    labels: ["bug", "backend"],
    assignee: "Sam O.",
    points: 5,
    due: "Jul 09",
  },
];

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      aria-hidden="true"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.25}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M3 8.5 6.25 12 13 4.5" />
    </svg>
  );
}

function TagIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      aria-hidden="true"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.5}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M7.4 1.8H2.6a.8.8 0 0 0-.8.8v4.8c0 .21.084.416.234.566l6.2 6.2a.8.8 0 0 0 1.132 0l4.8-4.8a.8.8 0 0 0 0-1.132l-6.2-6.2a.8.8 0 0 0-.566-.234Z" />
      <circle cx="5" cy="5" r="1" fill="currentColor" stroke="none" />
    </svg>
  );
}

function ArrowIcon({ dir }: { dir: "left" | "right" }) {
  return (
    <svg
      viewBox="0 0 16 16"
      aria-hidden="true"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      style={{ transform: dir === "left" ? "rotate(180deg)" : undefined }}
    >
      <path d="M3 8h10M9 4l4 4-4 4" />
    </svg>
  );
}

export default function KanbanLabels() {
  const reduced = useReducedMotion();
  const uid = useId();
  const [cards, setCards] = useState<Card[]>(INITIAL_CARDS);
  const [active, setActive] = useState<LabelId[]>([]);
  const [matchAll, setMatchAll] = useState(false);
  const liveRef = useRef<HTMLParagraphElement | null>(null);

  const toggleLabel = useCallback((id: LabelId) => {
    setActive((prev) =>
      prev.includes(id) ? prev.filter((l) => l !== id) : [...prev, id],
    );
  }, []);

  const visible = useMemo(() => {
    if (active.length === 0) return cards;
    return cards.filter((card) =>
      matchAll
        ? active.every((l) => card.labels.includes(l))
        : active.some((l) => card.labels.includes(l)),
    );
  }, [cards, active, matchAll]);

  const counts = useMemo(() => {
    const map = {} as Record<LabelId, number>;
    for (const label of LABELS) map[label.id] = 0;
    for (const card of cards) for (const l of card.labels) map[l] += 1;
    return map;
  }, [cards]);

  const move = useCallback((cardId: string, dir: -1 | 1) => {
    setCards((prev) =>
      prev.map((card) => {
        if (card.id !== cardId) return card;
        const i = COLUMNS.findIndex((c) => c.id === card.column);
        const next = Math.min(COLUMNS.length - 1, Math.max(0, i + dir));
        if (next === i) return card;
        const target = COLUMNS[next];
        if (liveRef.current) {
          liveRef.current.textContent = `${card.id} moved to ${target.name}.`;
        }
        return { ...card, column: target.id };
      }),
    );
  }, []);

  const ease = [0.22, 1, 0.36, 1] as const;

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 lg:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes wikl-sheen {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(320%); }
        }
        @keyframes wikl-pulse {
          0%, 100% { opacity: 0.45; transform: scale(0.85); }
          50% { opacity: 1; transform: scale(1); }
        }
        .wikl-sheen::after {
          content: "";
          position: absolute;
          inset: 0;
          background: linear-gradient(100deg, transparent 35%, rgba(255,255,255,0.5) 50%, transparent 65%);
          transform: translateX(-120%);
          pointer-events: none;
        }
        .wikl-sheen:hover::after {
          animation: wikl-sheen 900ms ease-out;
        }
        .wikl-live-dot {
          animation: wikl-pulse 2.4s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .wikl-sheen:hover::after,
          .wikl-live-dot {
            animation: none !important;
          }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 h-64 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.10),transparent_70%)] dark:bg-[radial-gradient(60%_100%_at_50%_0%,rgba(129,140,248,0.14),transparent_70%)]"
      />

      <div className="relative mx-auto w-full max-w-6xl">
        <header className="mb-8 flex flex-col gap-4 sm:mb-10">
          <div className="inline-flex w-fit items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="wikl-live-dot h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Sprint 24 · week 2 of 2
          </div>
          <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
            <div className="max-w-xl">
              <h2 className="text-balance text-2xl font-semibold tracking-tight sm:text-3xl">
                Billing platform board
              </h2>
              <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                Filter by label to see who is blocked on what. Cards move with
                the arrow buttons or the arrow keys once a card is focused.
              </p>
            </div>
            <dl className="flex shrink-0 gap-6">
              <div>
                <dt className="text-xs font-medium text-slate-500 dark:text-slate-400">
                  Showing
                </dt>
                <dd className="text-xl font-semibold tabular-nums">
                  {visible.length}
                  <span className="text-sm font-normal text-slate-500 dark:text-slate-400">
                    /{cards.length}
                  </span>
                </dd>
              </div>
              <div>
                <dt className="text-xs font-medium text-slate-500 dark:text-slate-400">
                  Points
                </dt>
                <dd className="text-xl font-semibold tabular-nums">
                  {visible.reduce((sum, c) => sum + c.points, 0)}
                </dd>
              </div>
            </dl>
          </div>
        </header>

        <div className="mb-8 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="flex flex-wrap items-center gap-3">
            <span
              id={`${uid}-filter-label`}
              className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
            >
              <TagIcon />
              Labels
            </span>

            <div
              role="group"
              aria-labelledby={`${uid}-filter-label`}
              className="flex flex-wrap items-center gap-2"
            >
              {LABELS.map((label) => {
                const on = active.includes(label.id);
                return (
                  <button
                    key={label.id}
                    type="button"
                    aria-pressed={on}
                    onClick={() => toggleLabel(label.id)}
                    className={[
                      "group inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition",
                      "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
                      label.ring,
                      on
                        ? `border-transparent ${label.chip}`
                        : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:border-slate-600 dark:hover:bg-slate-800",
                    ].join(" ")}
                  >
                    <span
                      aria-hidden="true"
                      className={`h-2 w-2 rounded-full ${label.dot} ${on ? "" : "opacity-70"}`}
                    />
                    {label.name}
                    <span className="tabular-nums opacity-60">
                      {counts[label.id]}
                    </span>
                    {on ? (
                      <span aria-hidden="true" className="-mr-0.5">
                        <CheckIcon />
                      </span>
                    ) : null}
                  </button>
                );
              })}
            </div>

            <div className="ml-auto flex items-center gap-3">
              <button
                type="button"
                role="switch"
                aria-checked={matchAll}
                onClick={() => setMatchAll((v) => !v)}
                className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
              >
                <span
                  aria-hidden="true"
                  className={`relative h-4 w-7 rounded-full transition-colors ${
                    matchAll
                      ? "bg-indigo-600"
                      : "bg-slate-300 dark:bg-slate-700"
                  }`}
                >
                  <span
                    className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${
                      matchAll ? "left-3.5" : "left-0.5"
                    }`}
                  />
                </span>
                Match all
              </button>

              <button
                type="button"
                onClick={() => setActive([])}
                disabled={active.length === 0}
                className="rounded-full px-2.5 py-1 text-xs font-medium text-slate-500 transition hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-slate-500 dark:text-slate-400 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
              >
                Clear
              </button>
            </div>
          </div>
        </div>

        <p ref={liveRef} aria-live="polite" className="sr-only" />

        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
          {COLUMNS.map((column, colIndex) => {
            const columnCards = visible.filter((c) => c.column === column.id);
            return (
              <section
                key={column.id}
                aria-labelledby={`${uid}-col-${column.id}`}
                className="flex flex-col rounded-2xl border border-slate-200 bg-slate-100/60 p-3 dark:border-slate-800 dark:bg-slate-900/50"
              >
                <div className="mb-3 flex items-baseline justify-between gap-2 px-1">
                  <h3
                    id={`${uid}-col-${column.id}`}
                    className="text-sm font-semibold tracking-tight"
                  >
                    {column.name}
                  </h3>
                  <span className="rounded-full bg-white px-2 py-0.5 text-xs font-medium tabular-nums text-slate-600 dark:bg-slate-800 dark:text-slate-400">
                    {columnCards.length}
                  </span>
                </div>
                <p className="mb-3 px-1 text-xs text-slate-500 dark:text-slate-500">
                  {column.hint}
                </p>

                <ul className="flex flex-1 flex-col gap-3">
                  <AnimatePresence initial={false} mode="popLayout">
                    {columnCards.map((card) => (
                      <motion.li
                        key={card.id}
                        layout={!reduced}
                        initial={
                          reduced ? false : { opacity: 0, y: 8, scale: 0.98 }
                        }
                        animate={{ opacity: 1, y: 0, scale: 1 }}
                        exit={
                          reduced
                            ? { opacity: 0 }
                            : { opacity: 0, scale: 0.96, y: -4 }
                        }
                        transition={
                          reduced
                            ? { duration: 0 }
                            : { duration: 0.28, ease }
                        }
                      >
                        <article
                          tabIndex={0}
                          aria-label={`${card.id}: ${card.title}. Column ${column.name}. Labels: ${card.labels
                            .map((l) => LABEL_MAP[l].name)
                            .join(", ")}.`}
                          onKeyDown={(event) => {
                            if (event.key === "ArrowRight") {
                              event.preventDefault();
                              move(card.id, 1);
                            } else if (event.key === "ArrowLeft") {
                              event.preventDefault();
                              move(card.id, -1);
                            }
                          }}
                          className="wikl-sheen group relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 shadow-sm transition hover:border-slate-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700 dark:focus-visible:ring-offset-slate-950"
                        >
                          <div className="flex flex-wrap gap-1.5">
                            {card.labels.map((id) => {
                              const label = LABEL_MAP[id];
                              const dimmed =
                                active.length > 0 && !active.includes(id);
                              return (
                                <span
                                  key={id}
                                  className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium leading-4 transition-opacity ${label.chip} ${
                                    dimmed ? "opacity-45" : "opacity-100"
                                  }`}
                                >
                                  <span
                                    aria-hidden="true"
                                    className={`h-1.5 w-1.5 rounded-full ${label.dot}`}
                                  />
                                  {label.name}
                                </span>
                              );
                            })}
                          </div>

                          <h4 className="mt-2.5 text-pretty text-sm font-medium leading-snug">
                            {card.title}
                          </h4>
                          <p className="mt-1.5 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
                            {card.detail}
                          </p>

                          <div className="mt-3 flex items-center justify-between gap-2 border-t border-slate-100 pt-2.5 dark:border-slate-800">
                            <div className="flex min-w-0 items-center gap-2">
                              <span
                                aria-hidden="true"
                                className="grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-200 text-[10px] font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-300"
                              >
                                {card.assignee.charAt(0)}
                              </span>
                              <span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
                                {card.id} · {card.points} pts · {card.due}
                              </span>
                            </div>
                            <div className="flex shrink-0 items-center gap-1">
                              <button
                                type="button"
                                onClick={() => move(card.id, -1)}
                                disabled={colIndex === 0}
                                aria-label={`Move ${card.id} to ${
                                  COLUMNS[Math.max(0, colIndex - 1)].name
                                }`}
                                className="grid h-6 w-6 place-items-center rounded-md text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
                              >
                                <ArrowIcon dir="left" />
                              </button>
                              <button
                                type="button"
                                onClick={() => move(card.id, 1)}
                                disabled={colIndex === COLUMNS.length - 1}
                                aria-label={`Move ${card.id} to ${
                                  COLUMNS[
                                    Math.min(COLUMNS.length - 1, colIndex + 1)
                                  ].name
                                }`}
                                className="grid h-6 w-6 place-items-center rounded-md text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
                              >
                                <ArrowIcon dir="right" />
                              </button>
                            </div>
                          </div>
                        </article>
                      </motion.li>
                    ))}
                  </AnimatePresence>

                  {columnCards.length === 0 ? (
                    <li className="grid flex-1 place-items-center rounded-xl border border-dashed border-slate-300 px-3 py-8 text-center text-xs text-slate-500 dark:border-slate-700 dark:text-slate-500">
                      {active.length > 0
                        ? "No cards match this label filter."
                        : "Nothing here yet."}
                    </li>
                  ) : null}
                </ul>
              </section>
            );
          })}
        </div>
      </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 →