Web InnoventixFreeCode

Kanban Backlog

Original · free

backlog list with priorities

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

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

type Priority = "critical" | "high" | "medium" | "low";
type Status = "triage" | "ready" | "blocked";

type Story = {
  id: string;
  key: string;
  title: string;
  detail: string;
  priority: Priority;
  status: Status;
  points: number;
  epic: string;
  assignee: string;
  comments: number;
};

const PRIORITY_ORDER: Record<Priority, number> = {
  critical: 0,
  high: 1,
  medium: 2,
  low: 3,
};

const PRIORITY_META: Record<
  Priority,
  { label: string; chip: string; bar: string; arrows: number }
> = {
  critical: {
    label: "Critical",
    chip: "bg-rose-100 text-rose-700 ring-rose-600/20 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/25",
    bar: "bg-rose-500",
    arrows: 3,
  },
  high: {
    label: "High",
    chip: "bg-amber-100 text-amber-800 ring-amber-600/20 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/25",
    bar: "bg-amber-500",
    arrows: 2,
  },
  medium: {
    label: "Medium",
    chip: "bg-sky-100 text-sky-700 ring-sky-600/20 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/25",
    bar: "bg-sky-500",
    arrows: 1,
  },
  low: {
    label: "Low",
    chip: "bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-500/15 dark:text-slate-400 dark:ring-slate-400/20",
    bar: "bg-slate-400",
    arrows: 1,
  },
};

const STATUS_META: Record<Status, { label: string; chip: string }> = {
  triage: {
    label: "Needs triage",
    chip: "bg-violet-100 text-violet-700 ring-violet-600/20 dark:bg-violet-500/15 dark:text-violet-300 dark:ring-violet-400/25",
  },
  ready: {
    label: "Ready for sprint",
    chip: "bg-emerald-100 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/25",
  },
  blocked: {
    label: "Blocked",
    chip: "bg-rose-100 text-rose-700 ring-rose-600/20 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/25",
  },
};

const INITIAL_STORIES: Story[] = [
  {
    id: "s1",
    key: "PAY-418",
    title: "Retry webhook delivery with exponential backoff",
    detail:
      "Stripe events fail silently when our worker returns a 502. Queue failed deliveries and retry at 1s, 8s, 64s before paging on-call.",
    priority: "critical",
    status: "ready",
    points: 5,
    epic: "Billing reliability",
    assignee: "Priya N.",
    comments: 7,
  },
  {
    id: "s2",
    key: "AUTH-207",
    title: "Session cookie drops on Safari 17 after refresh",
    detail:
      "SameSite=None without Partitioned is rejected in ITP. Add CHIPS attribute and backfill an integration test on the login flow.",
    priority: "critical",
    status: "blocked",
    points: 3,
    epic: "Auth hardening",
    assignee: "Marcus L.",
    comments: 12,
  },
  {
    id: "s3",
    key: "SRCH-092",
    title: "Typo tolerance for two-character queries",
    detail:
      "Trigram index skips anything under three chars, so \"ai\" returns nothing. Add a prefix fallback path with a 40ms budget.",
    priority: "high",
    status: "ready",
    points: 8,
    epic: "Search quality",
    assignee: "Dana O.",
    comments: 3,
  },
  {
    id: "s4",
    key: "ONB-311",
    title: "Skip workspace naming step for invited teammates",
    detail:
      "Invitees already join a named workspace but still see the naming screen. Cuts three taps from the invited-user funnel.",
    priority: "high",
    status: "triage",
    points: 2,
    epic: "Onboarding",
    assignee: "Ravi S.",
    comments: 1,
  },
  {
    id: "s5",
    key: "DASH-540",
    title: "Virtualise the activity feed past 200 rows",
    detail:
      "Feed renders every row on mount; INP crosses 400ms on mid-tier Android. Window the list and cache row heights.",
    priority: "high",
    status: "ready",
    points: 5,
    epic: "Performance",
    assignee: "Priya N.",
    comments: 4,
  },
  {
    id: "s6",
    key: "EXP-128",
    title: "CSV export loses UTF-8 accents in Excel",
    detail:
      "Excel assumes CP-1252 without a byte-order mark. Prefix the stream with a BOM and add a fixture for Latin-1 names.",
    priority: "medium",
    status: "ready",
    points: 2,
    epic: "Reporting",
    assignee: "Dana O.",
    comments: 2,
  },
  {
    id: "s7",
    key: "A11Y-064",
    title: "Focus escapes the command palette on Tab",
    detail:
      "No focus trap, so Tab walks into the page behind the dialog. Wire a roving trap and restore focus to the trigger on close.",
    priority: "medium",
    status: "triage",
    points: 3,
    epic: "Accessibility",
    assignee: "Marcus L.",
    comments: 5,
  },
  {
    id: "s8",
    key: "INFRA-77",
    title: "Split the migrations job out of the deploy pipeline",
    detail:
      "Long migrations block rollbacks for the whole fleet. Run them as a gated pre-deploy job with its own timeout.",
    priority: "medium",
    status: "blocked",
    points: 8,
    epic: "Platform",
    assignee: "Ravi S.",
    comments: 9,
  },
  {
    id: "s9",
    key: "DOC-019",
    title: "Document the rate-limit headers we actually send",
    detail:
      "Public docs list X-RateLimit-Reset in seconds; we send an epoch timestamp. Fix the reference and add a curl example.",
    priority: "low",
    status: "ready",
    points: 1,
    epic: "Developer docs",
    assignee: "Dana O.",
    comments: 0,
  },
  {
    id: "s10",
    key: "UI-455",
    title: "Empty state for saved views has no illustration",
    detail:
      "The panel drops to a bare sentence. Add the shared empty-state block plus a link to the saved-views guide.",
    priority: "low",
    status: "triage",
    points: 1,
    epic: "Design system",
    assignee: "Ravi S.",
    comments: 2,
  },
];

const FILTERS: { value: Priority | "all"; label: string }[] = [
  { value: "all", label: "All" },
  { value: "critical", label: "Critical" },
  { value: "high", label: "High" },
  { value: "medium", label: "Medium" },
  { value: "low", label: "Low" },
];

function PriorityArrows({ priority }: { priority: Priority }) {
  const { arrows } = PRIORITY_META[priority];
  const down = priority === "low";
  return (
    <span className="inline-flex items-center" aria-hidden="true">
      {Array.from({ length: arrows }).map((_, i) => (
        <svg
          key={i}
          viewBox="0 0 12 12"
          className={`h-3 w-3 ${down ? "rotate-180" : ""} ${i > 0 ? "-ml-1.5" : ""}`}
          fill="none"
          stroke="currentColor"
          strokeWidth={2}
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <path d="M2 7.5 6 3.5l4 4" />
        </svg>
      ))}
    </span>
  );
}

function ChevronIcon({ open }: { open: boolean }) {
  return (
    <svg
      viewBox="0 0 16 16"
      className={`h-4 w-4 transition-transform duration-200 ${open ? "rotate-90" : ""}`}
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M6 3.5 10.5 8 6 12.5" />
    </svg>
  );
}

function CommentIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.5}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M14 9.5a2 2 0 0 1-2 2H6l-3.5 2.5v-2.5H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z" />
    </svg>
  );
}

function ArrowMoveIcon({ dir }: { dir: "up" | "down" }) {
  return (
    <svg
      viewBox="0 0 16 16"
      className={`h-3.5 w-3.5 ${dir === "down" ? "rotate-180" : ""}`}
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M8 13V3.5M4 7l4-4 4 4" />
    </svg>
  );
}

export default function KanbanBacklog() {
  const reduce = useReducedMotion();
  const headingId = useId();
  const listId = useId();
  const [stories, setStories] = useState<Story[]>(INITIAL_STORIES);
  const [filter, setFilter] = useState<Priority | "all">("all");
  const [openId, setOpenId] = useState<string | null>("s1");
  const [selected, setSelected] = useState<Set<string>>(() => new Set(["s1", "s3"]));
  const [announce, setAnnounce] = useState("");
  const rowRefs = useRef<Map<string, HTMLLIElement | null>>(new Map());

  const visible = useMemo(
    () =>
      stories.filter((s) => (filter === "all" ? true : s.priority === filter)),
    [stories, filter],
  );

  const counts = useMemo(() => {
    const base: Record<Priority | "all", number> = {
      all: stories.length,
      critical: 0,
      high: 0,
      medium: 0,
      low: 0,
    };
    for (const s of stories) base[s.priority] += 1;
    return base;
  }, [stories]);

  const selectedPoints = useMemo(
    () =>
      stories.reduce((sum, s) => (selected.has(s.id) ? sum + s.points : sum), 0),
    [stories, selected],
  );

  const toggleSelected = useCallback((story: Story) => {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(story.id)) {
        next.delete(story.id);
        setAnnounce(`${story.key} removed from sprint draft.`);
      } else {
        next.add(story.id);
        setAnnounce(`${story.key} added to sprint draft.`);
      }
      return next;
    });
  }, []);

  const move = useCallback(
    (id: string, dir: -1 | 1) => {
      setStories((prev) => {
        const from = prev.findIndex((s) => s.id === id);
        const to = from + dir;
        if (from < 0 || to < 0 || to >= prev.length) return prev;
        const next = [...prev];
        const [item] = next.splice(from, 1);
        next.splice(to, 0, item);
        setAnnounce(`${item.key} moved to rank ${to + 1} of ${next.length}.`);
        return next;
      });
      window.requestAnimationFrame(() => rowRefs.current.get(id)?.focus());
    },
    [],
  );

  const sortByPriority = useCallback(() => {
    setStories((prev) =>
      [...prev].sort(
        (a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority],
      ),
    );
    setAnnounce("Backlog re-ranked by priority, critical first.");
  }, []);

  const onRowKeyDown = useCallback(
    (e: KeyboardEvent<HTMLLIElement>, story: Story) => {
      if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
        e.preventDefault();
        move(story.id, e.key === "ArrowUp" ? -1 : 1);
        return;
      }
      if (e.key === "ArrowDown" || e.key === "ArrowUp") {
        e.preventDefault();
        const i = visible.findIndex((s) => s.id === story.id);
        const nxt = visible[i + (e.key === "ArrowDown" ? 1 : -1)];
        if (nxt) rowRefs.current.get(nxt.id)?.focus();
      }
    },
    [move, visible],
  );

  return (
    <section
      className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 lg:py-28 dark:bg-slate-950 dark:text-slate-100"
      aria-labelledby={headingId}
    >
      <style>{`
        @keyframes kbBacklogRise {
          from { opacity: 0; transform: translateY(10px); }
          to { opacity: 1; transform: none; }
        }
        @keyframes kbBacklogPulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.45; transform: scale(0.72); }
        }
        .kb-backlog-rise { animation: kbBacklogRise 520ms cubic-bezier(0.22, 1, 0.36, 1) both; }
        .kb-backlog-pulse { animation: kbBacklogPulse 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .kb-backlog-rise, .kb-backlog-pulse { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <header className="kb-backlog-rise flex flex-col gap-5 border-b border-slate-200 pb-8 dark:border-slate-800">
          <div className="flex flex-wrap items-end justify-between gap-4">
            <div>
              <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
                Sprint 34 planning
              </p>
              <h2
                id={headingId}
                className="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl"
              >
                Product backlog
              </h2>
              <p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                Ranked top to bottom. Drag is optional here &mdash; use the rank
                buttons, or focus a row and press{" "}
                <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
                  Alt
                </kbd>{" "}
                +{" "}
                <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
                  &uarr;
                </kbd>
                {" / "}
                <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
                  &darr;
                </kbd>
                .
              </p>
            </div>
            <div className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-right shadow-sm dark:border-slate-800 dark:bg-slate-900">
              <p className="text-xs font-medium text-slate-500 dark:text-slate-400">
                Sprint draft
              </p>
              <p className="mt-0.5 text-2xl font-semibold tabular-nums">
                {selectedPoints}
                <span className="ml-1 text-sm font-normal text-slate-500 dark:text-slate-400">
                  / 21 pts
                </span>
              </p>
            </div>
          </div>

          <div className="flex flex-wrap items-center gap-3">
            <div
              role="group"
              aria-label="Filter backlog by priority"
              className="flex flex-wrap gap-1.5"
            >
              {FILTERS.map((f) => {
                const active = filter === f.value;
                return (
                  <button
                    key={f.value}
                    type="button"
                    aria-pressed={active}
                    onClick={() => setFilter(f.value)}
                    className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ring-1 transition-colors 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:focus-visible:ring-offset-slate-950 ${
                      active
                        ? "bg-slate-900 text-white ring-slate-900 dark:bg-slate-100 dark:text-slate-900 dark:ring-slate-100"
                        : "bg-white text-slate-600 ring-slate-200 hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-300 dark:ring-slate-800 dark:hover:bg-slate-800"
                    }`}
                  >
                    {f.label}
                    <span
                      className={`tabular-nums ${active ? "opacity-70" : "text-slate-400 dark:text-slate-500"}`}
                    >
                      {counts[f.value]}
                    </span>
                  </button>
                );
              })}
            </div>
            <button
              type="button"
              onClick={sortByPriority}
              className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs 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"
            >
              <svg
                viewBox="0 0 16 16"
                className="h-3.5 w-3.5"
                fill="none"
                stroke="currentColor"
                strokeWidth={1.75}
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M4 13V3M1.5 5.5 4 3l2.5 2.5M12 3v10m2.5-2.5L12 13l-2.5-2.5" />
              </svg>
              Re-rank by priority
            </button>
          </div>
        </header>

        <ul id={listId} className="mt-6 flex flex-col gap-2.5">
          <AnimatePresence initial={false} mode="popLayout">
            {visible.map((story, index) => {
              const p = PRIORITY_META[story.priority];
              const st = STATUS_META[story.status];
              const open = openId === story.id;
              const picked = selected.has(story.id);
              const rank = stories.findIndex((s) => s.id === story.id);
              return (
                <motion.li
                  key={story.id}
                  ref={(el: HTMLLIElement | null) => {
                    rowRefs.current.set(story.id, el);
                  }}
                  layout={!reduce}
                  initial={reduce ? false : { opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}
                  transition={
                    reduce
                      ? { duration: 0 }
                      : { type: "spring", stiffness: 420, damping: 34 }
                  }
                  tabIndex={0}
                  onKeyDown={(e) => onRowKeyDown(e, story)}
                  aria-label={`Rank ${rank + 1}, ${story.key}, ${story.title}, ${p.label} priority`}
                  className={`group relative overflow-hidden rounded-xl border bg-white shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-slate-900 dark:focus-visible:ring-offset-slate-950 ${
                    picked
                      ? "border-indigo-300 dark:border-indigo-500/50"
                      : "border-slate-200 hover:border-slate-300 dark:border-slate-800 dark:hover:border-slate-700"
                  }`}
                >
                  <span
                    className={`absolute inset-y-0 left-0 w-1 ${p.bar}`}
                    aria-hidden="true"
                  />

                  <div className="flex items-start gap-3 py-3 pl-5 pr-3 sm:pr-4">
                    <span className="mt-1 w-6 shrink-0 text-right text-xs font-medium tabular-nums text-slate-400 dark:text-slate-600">
                      {rank + 1}
                    </span>

                    <label className="mt-0.5 flex shrink-0 cursor-pointer items-center">
                      <input
                        type="checkbox"
                        checked={picked}
                        onChange={() => toggleSelected(story)}
                        className="h-4 w-4 cursor-pointer rounded border-slate-300 text-indigo-600 accent-indigo-600 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-600 dark:focus-visible:ring-offset-slate-900"
                      />
                      <span className="sr-only">
                        Add {story.key} to the sprint draft
                      </span>
                    </label>

                    <div className="min-w-0 flex-1">
                      <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
                        <span className="font-mono text-[11px] font-medium tracking-tight text-slate-500 dark:text-slate-400">
                          {story.key}
                        </span>
                        <span
                          className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 ring-inset ${p.chip}`}
                        >
                          <PriorityArrows priority={story.priority} />
                          {p.label}
                        </span>
                        <span
                          className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 ring-inset ${st.chip}`}
                        >
                          {story.status === "blocked" && (
                            <span
                              className="kb-backlog-pulse h-1.5 w-1.5 rounded-full bg-rose-500 dark:bg-rose-400"
                              aria-hidden="true"
                            />
                          )}
                          {st.label}
                        </span>
                      </div>

                      <button
                        type="button"
                        onClick={() => setOpenId(open ? null : story.id)}
                        aria-expanded={open}
                        aria-controls={`${listId}-panel-${story.id}`}
                        className="mt-1 flex w-full items-start gap-1.5 rounded text-left text-sm font-medium leading-snug text-slate-900 transition-colors hover:text-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-100 dark:hover:text-indigo-300"
                      >
                        <span className="mt-0.5 shrink-0 text-slate-400 dark:text-slate-500">
                          <ChevronIcon open={open} />
                        </span>
                        <span className="min-w-0">{story.title}</span>
                      </button>

                      <AnimatePresence initial={false}>
                        {open && (
                          <motion.div
                            id={`${listId}-panel-${story.id}`}
                            initial={reduce ? false : { height: 0, opacity: 0 }}
                            animate={{ height: "auto", opacity: 1 }}
                            exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                            transition={
                              reduce ? { duration: 0 } : { duration: 0.24, ease: [0.22, 1, 0.36, 1] }
                            }
                            className="overflow-hidden"
                          >
                            <p className="pb-1 pl-5 pt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                              {story.detail}
                            </p>
                          </motion.div>
                        )}
                      </AnimatePresence>

                      <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 pl-5 text-[11px] text-slate-500 dark:text-slate-400">
                        <span className="rounded bg-slate-100 px-1.5 py-0.5 font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                          {story.epic}
                        </span>
                        <span>{story.assignee}</span>
                        <span className="inline-flex items-center gap-1">
                          <CommentIcon />
                          <span className="tabular-nums">{story.comments}</span>
                          <span className="sr-only">comments</span>
                        </span>
                      </div>
                    </div>

                    <div className="flex shrink-0 items-center gap-2 self-center">
                      <span className="inline-flex h-6 min-w-6 items-center justify-center rounded-md bg-slate-100 px-1.5 text-xs font-semibold tabular-nums text-slate-700 dark:bg-slate-800 dark:text-slate-200">
                        {story.points}
                        <span className="sr-only"> story points</span>
                      </span>
                      <div className="flex flex-col gap-0.5">
                        <button
                          type="button"
                          onClick={() => move(story.id, -1)}
                          disabled={rank === 0}
                          aria-label={`Move ${story.key} up one rank`}
                          className="rounded p-1 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:hover:bg-slate-800 dark:hover:text-slate-200"
                        >
                          <ArrowMoveIcon dir="up" />
                        </button>
                        <button
                          type="button"
                          onClick={() => move(story.id, 1)}
                          disabled={rank === stories.length - 1}
                          aria-label={`Move ${story.key} down one rank`}
                          className="rounded p-1 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:hover:bg-slate-800 dark:hover:text-slate-200"
                        >
                          <ArrowMoveIcon dir="down" />
                        </button>
                      </div>
                    </div>
                  </div>

                  {index === 2 && filter === "all" && (
                    <div className="flex items-center gap-2 border-t border-dashed border-indigo-300 bg-indigo-50/60 px-5 py-1.5 text-[11px] font-medium text-indigo-700 dark:border-indigo-500/40 dark:bg-indigo-500/10 dark:text-indigo-300">
                      <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" aria-hidden="true" />
                      Sprint capacity line &mdash; 21 points
                    </div>
                  )}
                </motion.li>
              );
            })}
          </AnimatePresence>
        </ul>

        {visible.length === 0 && (
          <p className="mt-6 rounded-xl border border-dashed border-slate-300 bg-white px-6 py-10 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
            Nothing at this priority. Clear the filter to see the full backlog.
          </p>
        )}

        <p aria-live="polite" className="sr-only">
          {announce}
        </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 →