Web InnoventixFreeCode

Kanban Compact

Original · free

compact mini board

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

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

type LaneId = "triage" | "doing" | "review" | "shipped";
type Priority = "high" | "med" | "low";
type Density = "compact" | "cozy";
type Board = Record<LaneId, string[]>;
type Axis = "up" | "down" | "left" | "right";

type Person = {
  id: string;
  initials: string;
  name: string;
  chip: string;
};

type Lane = {
  id: LaneId;
  name: string;
  limit: number | null;
  dot: string;
};

type Card = {
  id: string;
  ref: string;
  title: string;
  priority: Priority;
  owner: string;
  tag: string;
  done: number;
  total: number;
};

const PEOPLE: Person[] = [
  { id: "rk", initials: "RK", name: "Rina Kovacs", chip: "bg-indigo-500" },
  { id: "tm", initials: "TM", name: "Theo Mbeki", chip: "bg-emerald-500" },
  { id: "jo", initials: "JO", name: "Joan Okafor", chip: "bg-violet-500" },
  { id: "ls", initials: "LS", name: "Lucia Serrano", chip: "bg-amber-500" },
];

const PERSON_BY_ID: Record<string, Person> = PEOPLE.reduce<Record<string, Person>>(
  (acc, person) => {
    acc[person.id] = person;
    return acc;
  },
  {},
);

const LANES: Lane[] = [
  { id: "triage", name: "Triage", limit: null, dot: "bg-slate-400 dark:bg-slate-500" },
  { id: "doing", name: "Doing", limit: 3, dot: "bg-indigo-500" },
  { id: "review", name: "Review", limit: 2, dot: "bg-violet-500" },
  { id: "shipped", name: "Shipped", limit: null, dot: "bg-emerald-500" },
];

const LANE_BY_ID: Record<LaneId, Lane> = LANES.reduce<Record<LaneId, Lane>>(
  (acc, lane) => {
    acc[lane.id] = lane;
    return acc;
  },
  {} as Record<LaneId, Lane>,
);

const CARDS: Record<string, Card> = {
  "ds-412": {
    id: "ds-412",
    ref: "DS-412",
    title: "Focus ring is invisible on slate-900 surfaces",
    priority: "high",
    owner: "jo",
    tag: "a11y",
    done: 0,
    total: 3,
  },
  "ds-409": {
    id: "ds-409",
    ref: "DS-409",
    title: "Deprecate legacy Button size=\"xs\"",
    priority: "low",
    owner: "tm",
    tag: "cleanup",
    done: 1,
    total: 4,
  },
  "ds-407": {
    id: "ds-407",
    ref: "DS-407",
    title: "Icon exports missing sideEffects: false",
    priority: "med",
    owner: "ls",
    tag: "build",
    done: 0,
    total: 2,
  },
  "ds-398": {
    id: "ds-398",
    ref: "DS-398",
    title: "Combobox: async filtering and empty state",
    priority: "high",
    owner: "rk",
    tag: "component",
    done: 3,
    total: 6,
  },
  "ds-395": {
    id: "ds-395",
    ref: "DS-395",
    title: "Move spacing scale onto a 4pt grid",
    priority: "med",
    owner: "tm",
    tag: "tokens",
    done: 5,
    total: 9,
  },
  "ds-390": {
    id: "ds-390",
    ref: "DS-390",
    title: "Toast stacking order regresses under 380px",
    priority: "high",
    owner: "jo",
    tag: "bug",
    done: 4,
    total: 4,
  },
  "ds-386": {
    id: "ds-386",
    ref: "DS-386",
    title: "Docs: write the Combobox usage page",
    priority: "low",
    owner: "ls",
    tag: "docs",
    done: 2,
    total: 3,
  },
  "ds-381": {
    id: "ds-381",
    ref: "DS-381",
    title: "Table: sticky header + column resize",
    priority: "med",
    owner: "rk",
    tag: "component",
    done: 7,
    total: 7,
  },
  "ds-377": {
    id: "ds-377",
    ref: "DS-377",
    title: "Publish the v2.3 changelog",
    priority: "low",
    owner: "ls",
    tag: "release",
    done: 2,
    total: 2,
  },
};

const INITIAL_BOARD: Board = {
  triage: ["ds-412", "ds-409", "ds-407"],
  doing: ["ds-398", "ds-395"],
  review: ["ds-390", "ds-386"],
  shipped: ["ds-381", "ds-377"],
};

const PRIORITY_DOT: Record<Priority, string> = {
  high: "bg-rose-500",
  med: "bg-amber-500",
  low: "bg-sky-500",
};

const PRIORITY_LABEL: Record<Priority, string> = {
  high: "High priority",
  med: "Medium priority",
  low: "Low priority",
};

function cloneBoard(board: Board): Board {
  return {
    triage: [...board.triage],
    doing: [...board.doing],
    review: [...board.review],
    shipped: [...board.shipped],
  };
}

export default function KanbanCompact() {
  const reduce = useReducedMotion();

  const [board, setBoard] = useState<Board>(() => cloneBoard(INITIAL_BOARD));
  const [density, setDensity] = useState<Density>("compact");
  const [filter, setFilter] = useState<string>("all");
  const [focusId, setFocusId] = useState<string>("ds-412");
  const [grabbedId, setGrabbedId] = useState<string | null>(null);
  const [message, setMessage] = useState<string>("");

  const cardRefs = useRef<Record<string, HTMLButtonElement | null>>({});
  const wantFocus = useRef<string | null>(null);
  const snapshot = useRef<Board | null>(null);

  useEffect(() => {
    const id = wantFocus.current;
    if (!id) return;
    wantFocus.current = null;
    cardRefs.current[id]?.focus();
  });

  const isVisible = useCallback(
    (id: string) => filter === "all" || CARDS[id].owner === filter,
    [filter],
  );

  const visibleBoard = useMemo<Board>(
    () => ({
      triage: board.triage.filter(isVisible),
      doing: board.doing.filter(isVisible),
      review: board.review.filter(isVisible),
      shipped: board.shipped.filter(isVisible),
    }),
    [board, isVisible],
  );

  const flatVisible = useMemo(
    () => LANES.flatMap((lane) => visibleBoard[lane.id]),
    [visibleBoard],
  );

  const activeId = flatVisible.includes(focusId) ? focusId : flatVisible[0];

  const locate = useCallback(
    (id: string): { lane: LaneId; index: number } | null => {
      for (const lane of LANES) {
        const index = visibleBoard[lane.id].indexOf(id);
        if (index !== -1) return { lane: lane.id, index };
      }
      return null;
    },
    [visibleBoard],
  );

  const announce = useCallback((text: string) => setMessage(text), []);

  const moveFocus = useCallback(
    (id: string, axis: Axis) => {
      const at = locate(id);
      if (!at) return;
      const laneIndex = LANES.findIndex((lane) => lane.id === at.lane);

      if (axis === "up" || axis === "down") {
        const column = visibleBoard[at.lane];
        const next = column[at.index + (axis === "up" ? -1 : 1)];
        if (!next) return;
        setFocusId(next);
        wantFocus.current = next;
        return;
      }

      const step = axis === "left" ? -1 : 1;
      for (let i = laneIndex + step; i >= 0 && i < LANES.length; i += step) {
        const column = visibleBoard[LANES[i].id];
        if (column.length === 0) continue;
        const next = column[Math.min(at.index, column.length - 1)];
        setFocusId(next);
        wantFocus.current = next;
        return;
      }
    },
    [locate, visibleBoard],
  );

  const moveCard = useCallback(
    (id: string, axis: Axis) => {
      const at = locate(id);
      if (!at) return;

      if (axis === "up" || axis === "down") {
        const column = visibleBoard[at.lane];
        const partner = column[at.index + (axis === "up" ? -1 : 1)];
        if (!partner) return;
        setBoard((prev) => {
          const next = cloneBoard(prev);
          const full = next[at.lane];
          const a = full.indexOf(id);
          const b = full.indexOf(partner);
          full[a] = partner;
          full[b] = id;
          return next;
        });
        announce(
          `${CARDS[id].ref} moved ${axis} to position ${at.index + (axis === "up" ? 0 : 2)} of ${column.length} in ${LANE_BY_ID[at.lane].name}.`,
        );
        wantFocus.current = id;
        return;
      }

      const laneIndex = LANES.findIndex((lane) => lane.id === at.lane);
      const targetIndex = laneIndex + (axis === "left" ? -1 : 1);
      if (targetIndex < 0 || targetIndex >= LANES.length) return;
      const targetLane = LANES[targetIndex].id;

      setBoard((prev) => {
        const next = cloneBoard(prev);
        next[at.lane] = next[at.lane].filter((cardId) => cardId !== id);
        const targetVisible = next[targetLane].filter(isVisible);
        const anchor = targetVisible[Math.min(at.index, targetVisible.length)];
        const insertAt =
          anchor === undefined ? next[targetLane].length : next[targetLane].indexOf(anchor);
        next[targetLane].splice(insertAt, 0, id);
        return next;
      });

      announce(`${CARDS[id].ref} moved to ${LANE_BY_ID[targetLane].name}.`);
      wantFocus.current = id;
    },
    [announce, isVisible, locate, visibleBoard],
  );

  const grab = useCallback(
    (id: string) => {
      snapshot.current = cloneBoard(board);
      setGrabbedId(id);
      announce(
        `${CARDS[id].ref} picked up. Use the arrow keys to move it, Enter to drop, Escape to cancel.`,
      );
    },
    [announce, board],
  );

  const drop = useCallback(
    (id: string) => {
      const at = locate(id);
      snapshot.current = null;
      setGrabbedId(null);
      if (at) announce(`${CARDS[id].ref} dropped in ${LANE_BY_ID[at.lane].name}.`);
    },
    [announce, locate],
  );

  const cancel = useCallback(
    (id: string) => {
      if (snapshot.current) setBoard(snapshot.current);
      snapshot.current = null;
      setGrabbedId(null);
      wantFocus.current = id;
      announce(`Move cancelled. ${CARDS[id].ref} returned to its original position.`);
    },
    [announce],
  );

  const onCardKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLButtonElement>, id: string) => {
      const axis: Axis | null =
        event.key === "ArrowUp"
          ? "up"
          : event.key === "ArrowDown"
            ? "down"
            : event.key === "ArrowLeft"
              ? "left"
              : event.key === "ArrowRight"
                ? "right"
                : null;

      if (axis) {
        event.preventDefault();
        if (grabbedId === id) moveCard(id, axis);
        else moveFocus(id, axis);
        return;
      }

      if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
        event.preventDefault();
        if (grabbedId === id) drop(id);
        else grab(id);
        return;
      }

      if (event.key === "Escape" && grabbedId === id) {
        event.preventDefault();
        cancel(id);
      }
    },
    [cancel, drop, grab, grabbedId, moveCard, moveFocus],
  );

  const shift = useCallback(
    (id: string, axis: "left" | "right") => {
      moveCard(id, axis);
      setFocusId(id);
    },
    [moveCard],
  );

  const reset = useCallback(() => {
    setBoard(cloneBoard(INITIAL_BOARD));
    setGrabbedId(null);
    snapshot.current = null;
    announce("Board reset to the start of the sprint.");
  }, [announce]);

  const totalVisible = flatVisible.length;
  const shippedCount = visibleBoard.shipped.length;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 dark:bg-slate-950">
      <style>{`
        @keyframes kbcx-lift {
          0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.45); }
          50% { box-shadow: 0 0 0 5px rgba(99, 102, 241, 0); }
        }
        @keyframes kbcx-slide-in {
          from { opacity: 0; transform: translateY(6px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .kbcx-grabbed { animation: kbcx-lift 1.6s ease-out infinite; }
        .kbcx-lane { animation: kbcx-slide-in 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .kbcx-grabbed, .kbcx-lane { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <header className="flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-indigo-600 dark:text-indigo-400">
              Sprint 24 &middot; Design Systems
            </p>
            <h2 className="mt-1.5 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
              Mini board
            </h2>
            <p className="mt-1.5 max-w-lg text-sm text-slate-600 dark:text-slate-400">
              Nine open issues, four lanes, no ceremony. Pick a card up with{" "}
              <kbd className="rounded border border-slate-300 bg-white px-1 font-sans text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
                Enter
              </kbd>{" "}
              and steer it with the arrow keys.
            </p>
          </div>

          <div className="flex items-center gap-2">
            <div
              className="flex rounded-lg border border-slate-200 bg-white p-0.5 dark:border-slate-800 dark:bg-slate-900"
              role="group"
              aria-label="Card density"
            >
              {(["compact", "cozy"] as const).map((mode) => (
                <button
                  key={mode}
                  type="button"
                  onClick={() => setDensity(mode)}
                  aria-pressed={density === mode}
                  className={`rounded-md px-2.5 py-1 text-xs font-medium capitalize transition-colors 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 ${
                    density === mode
                      ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
                      : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
                  }`}
                >
                  {mode}
                </button>
              ))}
            </div>

            <button
              type="button"
              onClick={reset}
              className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 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-1 focus-visible:ring-offset-white dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
            >
              <svg
                viewBox="0 0 16 16"
                className="h-3.5 w-3.5"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.6"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
                <path d="M13.5 2v3h-3" />
              </svg>
              Reset
            </button>
          </div>
        </header>

        <div className="mt-6 flex flex-wrap items-center gap-2">
          <span className="text-[11px] font-medium uppercase tracking-wider text-slate-500 dark:text-slate-500">
            Assignee
          </span>
          <div className="flex flex-wrap gap-1.5" role="group" aria-label="Filter by assignee">
            <button
              type="button"
              onClick={() => setFilter("all")}
              aria-pressed={filter === "all"}
              className={`rounded-full border px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                filter === "all"
                  ? "border-indigo-500 bg-indigo-500 text-white"
                  : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400 dark:hover:border-slate-700"
              }`}
            >
              Everyone
            </button>
            {PEOPLE.map((person) => (
              <button
                key={person.id}
                type="button"
                onClick={() => setFilter(person.id)}
                aria-pressed={filter === person.id}
                className={`inline-flex items-center gap-1.5 rounded-full border py-1 pl-1 pr-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                  filter === person.id
                    ? "border-indigo-500 bg-indigo-500 text-white"
                    : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400 dark:hover:border-slate-700"
                }`}
              >
                <span
                  className={`grid h-4 w-4 place-items-center rounded-full text-[8px] font-bold text-white ${person.chip}`}
                  aria-hidden="true"
                >
                  {person.initials}
                </span>
                {person.name.split(" ")[0]}
              </button>
            ))}
          </div>
        </div>

        <div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
          {LANES.map((lane, laneIndex) => {
            const ids = visibleBoard[lane.id];
            const overLimit = lane.limit !== null && ids.length > lane.limit;

            return (
              <div
                key={lane.id}
                className="kbcx-lane flex flex-col rounded-xl border border-slate-200 bg-white/70 p-2 dark:border-slate-800 dark:bg-slate-900/50"
                style={{ animationDelay: `${laneIndex * 60}ms` }}
              >
                <div className="flex items-center justify-between px-1 pb-2 pt-0.5">
                  <div className="flex items-center gap-1.5">
                    <span className={`h-1.5 w-1.5 rounded-full ${lane.dot}`} aria-hidden="true" />
                    <h3
                      id={`kbcx-lane-${lane.id}`}
                      className="text-xs font-semibold text-slate-800 dark:text-slate-200"
                    >
                      {lane.name}
                    </h3>
                  </div>
                  <span
                    className={`rounded px-1.5 py-0.5 text-[10px] font-semibold tabular-nums ${
                      overLimit
                        ? "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400"
                        : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
                    }`}
                  >
                    {lane.limit !== null ? `${ids.length}/${lane.limit}` : ids.length}
                    {overLimit ? (
                      <span className="sr-only"> &mdash; over the work-in-progress limit</span>
                    ) : null}
                  </span>
                </div>

                <ul
                  aria-labelledby={`kbcx-lane-${lane.id}`}
                  className="flex min-h-[3.5rem] flex-col gap-1.5"
                >
                  {ids.map((id) => {
                    const card = CARDS[id];
                    const owner = PERSON_BY_ID[card.owner];
                    const grabbed = grabbedId === id;
                    const complete = card.done === card.total;

                    return (
                      <motion.li
                        key={id}
                        layout={reduce ? false : true}
                        transition={{ type: "spring", stiffness: 520, damping: 42 }}
                        className="group relative"
                      >
                        <button
                          type="button"
                          ref={(node) => {
                            cardRefs.current[id] = node;
                          }}
                          tabIndex={activeId === id ? 0 : -1}
                          aria-pressed={grabbed}
                          aria-describedby="kbcx-help"
                          onFocus={() => setFocusId(id)}
                          onClick={() => (grabbed ? drop(id) : grab(id))}
                          onKeyDown={(event) => onCardKeyDown(event, id)}
                          className={`w-full rounded-lg border bg-white text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-50 dark:bg-slate-900 dark:focus-visible:ring-offset-slate-950 ${
                            density === "cozy" ? "p-2.5" : "px-2 py-1.5"
                          } ${
                            grabbed
                              ? "kbcx-grabbed border-indigo-500 dark:border-indigo-400"
                              : "border-slate-200 hover:border-slate-300 dark:border-slate-800 dark:hover:border-slate-700"
                          }`}
                        >
                          <span className="flex items-center gap-1.5">
                            <span
                              className={`h-1.5 w-1.5 shrink-0 rounded-full ${PRIORITY_DOT[card.priority]}`}
                              aria-hidden="true"
                            />
                            <span className="text-[10px] font-semibold tracking-wide text-slate-400 tabular-nums dark:text-slate-500">
                              {card.ref}
                            </span>
                            <span className="sr-only">{PRIORITY_LABEL[card.priority]}.</span>
                            <span
                              className={`ml-auto grid h-4 w-4 place-items-center rounded-full text-[8px] font-bold text-white ${owner.chip}`}
                              aria-hidden="true"
                            >
                              {owner.initials}
                            </span>
                            <span className="sr-only">Assigned to {owner.name}.</span>
                          </span>

                          <span
                            className={`mt-1 block font-medium leading-snug text-slate-800 dark:text-slate-100 ${
                              density === "cozy" ? "text-xs" : "line-clamp-2 text-[11px]"
                            }`}
                          >
                            {card.title}
                          </span>

                          {density === "cozy" ? (
                            <span className="mt-2 flex items-center gap-1.5">
                              <span className="rounded border border-slate-200 px-1 py-px text-[9px] font-medium uppercase tracking-wide text-slate-500 dark:border-slate-700 dark:text-slate-400">
                                {card.tag}
                              </span>
                              <span
                                className={`inline-flex items-center gap-1 text-[10px] tabular-nums ${
                                  complete
                                    ? "text-emerald-600 dark:text-emerald-400"
                                    : "text-slate-500 dark:text-slate-400"
                                }`}
                              >
                                <svg
                                  viewBox="0 0 16 16"
                                  className="h-3 w-3"
                                  fill="none"
                                  stroke="currentColor"
                                  strokeWidth="1.8"
                                  strokeLinecap="round"
                                  strokeLinejoin="round"
                                  aria-hidden="true"
                                >
                                  <path d="M3 8.5 6 11.5 13 4.5" />
                                </svg>
                                {card.done}/{card.total}
                              </span>
                              <span className="sr-only">
                                {card.done} of {card.total} subtasks done.
                              </span>
                            </span>
                          ) : null}
                        </button>

                        <span className="pointer-events-none absolute right-1.5 top-1.5 flex gap-0.5 rounded bg-white pl-2 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-slate-900">
                          {(["left", "right"] as const).map((dir) => {
                            const laneIdx = LANES.findIndex((l) => l.id === lane.id);
                            const nextLane = LANES[laneIdx + (dir === "left" ? -1 : 1)];
                            if (!nextLane) return null;
                            return (
                              <button
                                key={dir}
                                type="button"
                                tabIndex={-1}
                                onClick={() => shift(id, dir)}
                                aria-label={`Move ${card.ref} to ${nextLane.name}`}
                                className="grid h-4 w-4 place-items-center rounded border border-slate-200 bg-white text-slate-500 transition-colors hover:border-indigo-400 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:border-indigo-400 dark:hover:text-indigo-400"
                              >
                                <svg
                                  viewBox="0 0 16 16"
                                  className="h-2.5 w-2.5"
                                  fill="none"
                                  stroke="currentColor"
                                  strokeWidth="2.2"
                                  strokeLinecap="round"
                                  strokeLinejoin="round"
                                  aria-hidden="true"
                                >
                                  <path d={dir === "left" ? "M10 3 5 8l5 5" : "M6 3l5 5-5 5"} />
                                </svg>
                              </button>
                            );
                          })}
                        </span>
                      </motion.li>
                    );
                  })}

                  {ids.length === 0 ? (
                    <li className="grid h-14 place-items-center rounded-lg border border-dashed border-slate-200 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-600">
                      Nothing here
                    </li>
                  ) : null}
                </ul>
              </div>
            );
          })}
        </div>

        <footer className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-4 dark:border-slate-800">
          <p id="kbcx-help" className="text-[11px] text-slate-500 dark:text-slate-500">
            Arrows move focus. Enter picks a card up, arrows then move it, Enter drops it, Escape
            cancels.
          </p>
          <p className="text-[11px] tabular-nums text-slate-500 dark:text-slate-500">
            {shippedCount} of {totalVisible} shipped
            {filter === "all" ? "" : ` · ${PERSON_BY_ID[filter].name}`}
          </p>
        </footer>

        <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 →