Data

Kanban

A board of columns whose cards move between and within columns by pointer drag or keyboard.

Built on
Pointer eventsPointer captureARIA live region@starting-style
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
5.3 kB
Dependencies
React
To do2
  1. Add SSO
  2. Ship v2.4
Doing1 / 1
  1. Fix login
Code
import { Kanban } from "@/components/equip/kanban";

export default function Demo() {
  return (
    <Kanban
      label="Sprint 14"
      style={{ width: 224 }}
      columns={[
        { id: "todo", title: "To do" },
        { id: "doing", title: "Doing", limit: 1 },
      ]}
      defaultCards={[
        { id: "c1", column: "todo", title: "Add SSO" },
        { id: "c2", column: "todo", title: "Ship v2.4" },
        { id: "c3", column: "doing", title: "Fix login" },
      ]}
    />
  );
}

Examples

Work in progress limits

A column over its limit says so in words as well as colour, and still accepts the card.

Backlog2
  1. Rotate staging keysOPS-118
  2. Drop Node 18OPS-121
In progress3 / 2 over limit
  1. Migrate invoices tableOPS-102
  2. Retry webhooksOPS-109
  3. Trace samplingOPS-111
Review1 / 2
  1. Rate limit searchOPS-097
Done1
  1. Upgrade PostgresOPS-088
Code
import { Kanban } from "@/components/equip/kanban";

export default function Example() {
  return (
    <Kanban
      label="Platform team board"
      style={{ width: "100%", maxWidth: 560 }}
      columns={[
        { id: "backlog", title: "Backlog" },
        { id: "doing", title: "In progress", limit: 2 },
        { id: "review", title: "Review", limit: 2 },
        { id: "done", title: "Done" },
      ]}
      defaultCards={[
        { id: "p1", column: "backlog", title: "Rotate staging keys", meta: "OPS-118" },
        { id: "p2", column: "backlog", title: "Drop Node 18", meta: "OPS-121" },
        { id: "p3", column: "doing", title: "Migrate invoices table", meta: "OPS-102" },
        { id: "p4", column: "doing", title: "Retry webhooks", meta: "OPS-109" },
        { id: "p5", column: "doing", title: "Trace sampling", meta: "OPS-111" },
        { id: "p6", column: "review", title: "Rate limit search", meta: "OPS-097" },
        { id: "p7", column: "done", title: "Upgrade Postgres", meta: "OPS-088" },
      ]}
    />
  );
}

Controlled with a move log

onMove fires once per drop, never while a card is held, so Escape cancels without your state ever changing.

Applied2
  1. Ana SilvaBackend engineer
  2. Wei ChenDesigner
Phone screen1
  1. Omar HaddadBackend engineer
Offer1
  1. Lena FischerSRE

Drag a card, or focus one and press Space.

Code
"use client";

import { useState } from "react";
import { Kanban, type KanbanCard } from "@/components/equip/kanban";

const start: KanbanCard[] = [
  { id: "h1", column: "applied", title: "Ana Silva", meta: "Backend engineer" },
  { id: "h2", column: "applied", title: "Wei Chen", meta: "Designer" },
  { id: "h3", column: "screen", title: "Omar Haddad", meta: "Backend engineer" },
  { id: "h4", column: "offer", title: "Lena Fischer", meta: "SRE" },
];

const stages = [
  { id: "applied", title: "Applied" },
  { id: "screen", title: "Phone screen" },
  { id: "offer", title: "Offer" },
];

export default function Example() {
  const [cards, setCards] = useState(start);
  const [log, setLog] = useState("Drag a card, or focus one and press Space.");

  function move(id: string, column: string, index: number) {
    setCards((list) => {
      const card = list.find((c) => c.id === id);
      if (!card) return list;
      const rest = list.filter((c) => c.id !== id);
      const inTarget = rest.filter((c) => c.column === column);
      const at = index < inTarget.length ? rest.indexOf(inTarget[index]) : rest.length;
      return [...rest.slice(0, at), { ...card, column }, ...rest.slice(at)];
    });
    const name = stages.find((s) => s.id === column)?.title ?? column;
    setLog(`${cards.find((c) => c.id === id)?.title} moved to ${name}, position ${index + 1}.`);
  }

  return (
    <div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 480 }}>
      <Kanban label="Hiring pipeline" columns={stages} cards={cards} onMove={move} />
      <p style={{ margin: 0, fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>{log}</p>
    </div>
  );
}

Custom card body

renderCard replaces the content only; the card stays the drag handle and the keyboard focus target.

Triage2
  1. Export drops last rowBUG-412PR ยท 3 pt
  2. Dark mode chart legendBUG-415SL ยท 1 pt
Fixing1
  1. Login loop on SafariBUG-399DO ยท 5 pt
Code
"use client";

import { Kanban } from "@/components/equip/kanban";

const owners: Record<string, string> = { b1: "PR", b2: "SL", b3: "DO" };
const points: Record<string, number> = { b1: 3, b2: 1, b3: 5 };

export default function Example() {
  return (
    <Kanban
      label="Bugs"
      style={{ width: "100%", maxWidth: 400 }}
      columns={[
        { id: "triage", title: "Triage" },
        { id: "fixing", title: "Fixing" },
      ]}
      defaultCards={[
        { id: "b1", column: "triage", title: "Export drops last row", meta: "BUG-412" },
        { id: "b2", column: "triage", title: "Dark mode chart legend", meta: "BUG-415" },
        { id: "b3", column: "fixing", title: "Login loop on Safari", meta: "BUG-399" },
      ]}
      renderCard={(card) => (
        <>
          <span>{card.title}</span>
          <span style={{ display: "flex", justifyContent: "space-between", color: "var(--eq-ink-2)", fontSize: "var(--eq-text-xs)" }}>
            <span>{card.meta}</span>
            <span>
              {owners[card.id]} ยท {points[card.id]} pt
            </span>
          </span>
        </>
      )}
    />
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the board for assistive tech.
columns{ id: string; title: string; limit?: number }[]noneColumns in order. A limit flags the column when it holds more.
cards{ id: string; column: string; title: string; meta?: string }[]noneControlled cards. Array order is the order within each column.
defaultCardsKanbanCard[]noneStarting cards when the board keeps its own order.
onMove(cardId: string, toColumn: string, toIndex: number) => voidnoneCalled once per drop. toIndex counts cards in the target column after the move.
renderCard(card: KanbanCard) => ReactNodenoneReplaces the card body; the card stays the handle.
...restComponentProps<"div">noneEvery native div attribute, including ref.

Anatomy

root.eq-kanbanRole group; positions the drag ghost.
board.eq-kanban-boardScrolls sideways when columns do not fit.
column.eq-kanban-column
head.eq-kanban-head
column title.eq-kanban-column-title
count.eq-kanban-countTurns danger and says over limit above the limit.
count value.eq-kanban-count-value
list.eq-kanban-list
item.eq-kanban-itemSlides on the spring when cards make room; the dashed placeholder while dragging.
card.eq-kanban-cardRole button; the drag handle and focus target.
card title.eq-kanban-card-title
card meta.eq-kanban-card-meta
ghost.eq-kanban-ghostThe lifted copy that follows the pointer.
status.eq-kanban-statusPolite live region for every pick up, move and drop.

Keyboard

SpaceEnterPicks up the focused card, or drops a held card.
ArrowUpArrowDownMoves a held card within its column.
ArrowLeftArrowRightMoves a held card to the neighbouring column.
EscapeCancels a keyboard or pointer move and puts the card back.
TabWhile holding a card, cancels the move and moves focus on.

Notes

  • Every step is announced in a polite live region, such as Card Fix login picked up. Position 2 of 4 in Doing.
  • Moves stay a preview until the drop, so onMove fires once and Escape never has to undo your state.
  • Drag uses pointer events, not HTML drag and drop, so it works the same with mouse, pen and touch.
  • Cards set touch-action: none so a finger can drag them; scroll the page by touching outside the cards.
  • Each card is a tab stop; on a busy board, consider fewer cards per column or a filter.
  • The board does not scroll itself while you drag near its edge.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/kanban.tsx413 lines
"use client";

import { useId, useLayoutEffect, useRef, useState } from "react";
import type { ComponentProps, KeyboardEvent, PointerEvent, ReactNode } from "react";
import "./kanban.css";

export type KanbanColumn = { id: string; title: string; limit?: number };
export type KanbanCard = { id: string; column: string; title: string; meta?: string };

export type KanbanProps = Omit<ComponentProps<"div">, "children"> & {
  /** Names the board for assistive tech. */
  label: string;
  columns: KanbanColumn[];
  /** Controlled cards. Order in the array is order within each column. */
  cards?: KanbanCard[];
  /** Starting cards when the board manages its own order. */
  defaultCards?: KanbanCard[];
  /** Called once per drop. toIndex counts cards in the target column after the move. */
  onMove?: (cardId: string, toColumn: string, toIndex: number) => void;
  /** Replaces the card body. The card itself stays the drag handle and focus target. */
  renderCard?: (card: KanbanCard) => ReactNode;
};

type Held = {
  id: string;
  column: string;
  index: number;
  from: { column: string; index: number };
  mode: "keyboard" | "pointer";
};

type Press = {
  id: string;
  pointerId: number;
  x: number;
  y: number;
  dx: number;
  dy: number;
  width: number;
  height: number;
  started: boolean;
};

function inColumn(cards: KanbanCard[], column: string) {
  return cards.filter((c) => c.column === column);
}

function applyMove(cards: KanbanCard[], id: string, column: string, index: number) {
  const card = cards.find((c) => c.id === id);
  if (!card) return cards;
  const rest = cards.filter((c) => c.id !== id);
  const target = inColumn(rest, column);
  const before = target[index];
  const at = before
    ? rest.indexOf(before)
    : target.length > 0
      ? rest.indexOf(target[target.length - 1]) + 1
      : rest.length;
  return [...rest.slice(0, at), { ...card, column }, ...rest.slice(at)];
}

export function Kanban({
  label,
  columns,
  cards,
  defaultCards,
  onMove,
  renderCard,
  className,
  ref,
  ...rest
}: KanbanProps) {
  const uid = useId();
  const rootRef = useRef<HTMLDivElement | null>(null);
  const ghostRef = useRef<HTMLDivElement>(null);
  const heldRef = useRef<Held | null>(null);
  const press = useRef<Press | null>(null);
  const snap = useRef<Map<string, DOMRect> | null>(null);
  const refocus = useRef<string | null>(null);

  const [inner, setInner] = useState<KanbanCard[]>(defaultCards ?? []);
  const [held, setHeldState] = useState<Held | null>(null);
  const [ghost, setGhost] = useState<{ id: string; width: number; height: number; x: number; y: number } | null>(null);
  const [message, setMessage] = useState("");

  const current = cards ?? inner;
  const shown = held ? applyMove(current, held.id, held.column, held.index) : current;

  function setHeld(next: Held | null) {
    heldRef.current = next;
    setHeldState(next);
  }

  function setRoot(el: HTMLDivElement | null) {
    rootRef.current = el;
    if (typeof ref === "function") ref(el);
    else if (ref) ref.current = el;
  }

  function titleOf(id: string) {
    return current.find((c) => c.id === id)?.title ?? "";
  }

  function where(list: KanbanCard[], id: string) {
    const card = list.find((c) => c.id === id);
    if (!card) return "";
    const col = inColumn(list, card.column);
    const name = columns.find((c) => c.id === card.column)?.title ?? card.column;
    return `position ${col.indexOf(card) + 1} of ${col.length} in ${name}.`;
  }

  function snapshot(id?: string, rect?: DOMRect) {
    const map = new Map<string, DOMRect>();
    rootRef.current?.querySelectorAll<HTMLElement>("[data-card]").forEach((el) => {
      const key = el.dataset.card;
      if (key) map.set(key, el.getBoundingClientRect());
    });
    if (id && rect) map.set(id, rect);
    snap.current = map;
  }

  // Cards that change place slide from where they were (FLIP), and a held card keeps focus
  // when it is remounted in another column.
  useLayoutEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const before = snap.current;
    if (before) {
      snap.current = null;
      root.querySelectorAll<HTMLElement>("[data-card]").forEach((el) => {
        const prev = el.dataset.card ? before.get(el.dataset.card) : undefined;
        if (!prev) return;
        el.style.transition = "none";
        el.style.translate = "";
        const now = el.getBoundingClientRect();
        const dx = prev.left - now.left;
        const dy = prev.top - now.top;
        if (Math.abs(dx) >= 1 || Math.abs(dy) >= 1) {
          el.style.translate = `${dx}px ${dy}px`;
          void el.offsetWidth;
          el.style.translate = "";
        }
        el.style.transition = "";
      });
    }
    const focusId = held?.id ?? refocus.current;
    refocus.current = null;
    if (focusId) {
      const el = root.querySelector<HTMLElement>(`[data-card="${CSS.escape(focusId)}"] > .eq-kanban-card`);
      if (el && document.activeElement !== el) el.focus({ preventScroll: held?.mode !== "keyboard" });
    }
  });

  function pickUp(card: KanbanCard, mode: Held["mode"]) {
    const col = inColumn(current, card.column);
    const index = col.indexOf(card);
    setHeld({ id: card.id, column: card.column, index, from: { column: card.column, index }, mode });
    setMessage(`Card ${card.title} picked up. ${where(current, card.id).replace(/^p/, "P")}`);
  }

  function moveTo(column: string, index: number) {
    const h = heldRef.current;
    if (!h || (h.column === column && h.index === index)) return;
    snapshot();
    setHeld({ ...h, column, index });
    const next = applyMove(current, h.id, column, index);
    setMessage(`Card ${titleOf(h.id)} moved. ${where(next, h.id).replace(/^p/, "P")}`);
  }

  function drop(rect?: DOMRect) {
    const h = heldRef.current;
    if (!h) return;
    snapshot(h.id, rect);
    refocus.current = h.id;
    setHeld(null);
    setGhost(null);
    const next = applyMove(current, h.id, h.column, h.index);
    if (h.column !== h.from.column || h.index !== h.from.index) {
      if (cards === undefined) setInner(next);
      onMove?.(h.id, h.column, h.index);
    }
    setMessage(`Card ${titleOf(h.id)} dropped. ${where(next, h.id).replace(/^p/, "P")}`);
  }

  function cancel() {
    const h = heldRef.current;
    if (!h) return;
    snapshot(h.id, ghostRef.current?.getBoundingClientRect());
    refocus.current = h.id;
    setHeld(null);
    setGhost(null);
    setMessage(`Movement cancelled. Card ${titleOf(h.id)} returned to ${where(current, h.id)}`);
  }

  function onCardKeyDown(e: KeyboardEvent<HTMLDivElement>, card: KanbanCard) {
    const h = heldRef.current;
    if (!h) {
      if (e.key !== " " && e.key !== "Enter") return;
      pickUp(card, "keyboard");
    } else if (h.mode === "pointer") {
      if (e.key !== "Escape") return;
      const p = press.current;
      press.current = null;
      if (p && rootRef.current?.hasPointerCapture(p.pointerId)) rootRef.current.releasePointerCapture(p.pointerId);
      cancel();
    } else {
      const colIndex = columns.findIndex((c) => c.id === h.column);
      const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
      const others = current.filter((c) => c.id !== h.id);
      switch (e.key) {
        case "ArrowUp":
          moveTo(h.column, Math.max(0, h.index - 1));
          break;
        case "ArrowDown":
          moveTo(h.column, Math.min(inColumn(others, h.column).length, h.index + 1));
          break;
        case "ArrowLeft":
        case "ArrowRight": {
          const step = (e.key === "ArrowRight") !== rtl ? 1 : -1;
          const target = columns[colIndex + step];
          if (target) moveTo(target.id, Math.min(h.index, inColumn(others, target.id).length));
          break;
        }
        case " ":
        case "Enter":
          drop();
          break;
        case "Escape":
          cancel();
          break;
        case "Tab":
          cancel();
          return;
        default:
          return;
      }
    }
    e.preventDefault();
    e.stopPropagation();
  }

  function onCardPointerDown(e: PointerEvent<HTMLDivElement>, card: KanbanCard) {
    const root = rootRef.current;
    if (e.button !== 0 || heldRef.current || !root) return;
    const r = e.currentTarget.getBoundingClientRect();
    press.current = {
      id: card.id,
      pointerId: e.pointerId,
      x: e.clientX,
      y: e.clientY,
      dx: e.clientX - r.left,
      dy: e.clientY - r.top,
      width: r.width,
      height: r.height,
      started: false,
    };
    root.setPointerCapture(e.pointerId);
  }

  function ghostAt(p: Press, x: number, y: number) {
    const r = rootRef.current?.getBoundingClientRect();
    return { x: x - (r?.left ?? 0) - p.dx, y: y - (r?.top ?? 0) - p.dy };
  }

  function hitTest(x: number, y: number, id: string) {
    const root = rootRef.current;
    if (!root) return null;
    const cols = Array.from(root.querySelectorAll<HTMLElement>("[data-column]"));
    const gap = (col: HTMLElement) => {
      const r = col.getBoundingClientRect();
      return x < r.left ? r.left - x : x > r.right ? x - r.right : 0;
    };
    const best = cols.reduce<HTMLElement | undefined>((a, b) => (a && gap(a) <= gap(b) ? a : b), undefined);
    const list = best?.querySelector<HTMLElement>(".eq-kanban-list");
    const column = best?.dataset.column;
    if (!list || !column) return null;
    const top = list.getBoundingClientRect().top;
    let index = 0;
    for (const el of Array.from(list.querySelectorAll<HTMLElement>(":scope > [data-card]"))) {
      if (el.dataset.card !== id && el.offsetTop + el.offsetHeight / 2 < y - top) index++;
    }
    return { column, index };
  }

  function onPointerMove(e: PointerEvent<HTMLDivElement>) {
    const p = press.current;
    if (!p || e.pointerId !== p.pointerId) return;
    if (!p.started) {
      if (Math.hypot(e.clientX - p.x, e.clientY - p.y) < 4) return;
      const card = current.find((c) => c.id === p.id);
      if (!card) return;
      p.started = true;
      pickUp(card, "pointer");
      setGhost({ id: p.id, width: p.width, height: p.height, ...ghostAt(p, e.clientX, e.clientY) });
    } else if (ghostRef.current) {
      const { x, y } = ghostAt(p, e.clientX, e.clientY);
      ghostRef.current.style.translate = `${x}px ${y}px`;
    }
    const hit = hitTest(e.clientX, e.clientY, p.id);
    if (hit) moveTo(hit.column, hit.index);
  }

  function onPointerUp(e: PointerEvent<HTMLDivElement>) {
    const p = press.current;
    if (!p || e.pointerId !== p.pointerId) return;
    press.current = null;
    if (p.started) drop(ghostRef.current?.getBoundingClientRect());
  }

  function onPointerCancel(e: PointerEvent<HTMLDivElement>) {
    const p = press.current;
    if (!p || e.pointerId !== p.pointerId) return;
    press.current = null;
    if (p.started) cancel();
  }

  const hintId = `${uid}-hint`;
  const ghostCard = ghost ? current.find((c) => c.id === ghost.id) : undefined;

  return (
    <div
      {...rest}
      ref={setRoot}
      className={["eq-kanban", className].filter(Boolean).join(" ")}
      role="group"
      aria-label={label}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerCancel}
      onLostPointerCapture={onPointerCancel}
    >
      <div className="eq-kanban-board">
        {columns.map((col, ci) => {
          const list = inColumn(shown, col.id);
          const over = col.limit != null && list.length > col.limit;
          const titleId = `${uid}-col-${ci}`;
          return (
            <section
              key={col.id}
              className="eq-kanban-column"
              data-column={col.id}
              data-target={held?.mode === "pointer" && held.column === col.id ? "" : undefined}
              aria-labelledby={titleId}
            >
              <div className="eq-kanban-head">
                <span id={titleId} className="eq-kanban-column-title">
                  {col.title}
                </span>
                <span className="eq-kanban-count" data-over={over || undefined}>
                  <span key={list.length} className="eq-kanban-count-value">
                    {list.length}
                  </span>
                  {col.limit != null && ` / ${col.limit}`}
                  {over && " over limit"}
                </span>
              </div>
              <ol className="eq-kanban-list" aria-labelledby={titleId}>
                {list.map((card) => {
                  const isHeld = held?.id === card.id;
                  return (
                    <li
                      key={card.id}
                      className="eq-kanban-item"
                      data-card={card.id}
                      data-placeholder={(isHeld && held?.mode === "pointer") || undefined}
                    >
                      <div
                        className="eq-kanban-card"
                        role="button"
                        tabIndex={0}
                        aria-describedby={hintId}
                        data-lifted={(isHeld && held?.mode === "keyboard") || undefined}
                        onKeyDown={(e) => onCardKeyDown(e, card)}
                        onPointerDown={(e) => onCardPointerDown(e, card)}
                      >
                        {renderCard ? renderCard(card) : <CardBody card={card} />}
                      </div>
                    </li>
                  );
                })}
              </ol>
            </section>
          );
        })}
      </div>
      {ghost && ghostCard && (
        <div
          ref={ghostRef}
          className="eq-kanban-ghost"
          aria-hidden="true"
          style={{ width: ghost.width, height: ghost.height, translate: `${ghost.x}px ${ghost.y}px` }}
        >
          <div className="eq-kanban-card">{renderCard ? renderCard(ghostCard) : <CardBody card={ghostCard} />}</div>
        </div>
      )}
      <span id={hintId} hidden>
        Press Space or Enter to pick up. Use the arrow keys to move, Space or Enter to drop, Escape to cancel.
      </span>
      <span className="eq-kanban-status" role="status">
        {message}
      </span>
    </div>
  );
}

function CardBody({ card }: { card: KanbanCard }) {
  return (
    <>
      <span className="eq-kanban-card-title">{card.title}</span>
      {card.meta && <span className="eq-kanban-card-meta">{card.meta}</span>}
    </>
  );
}
components/equip/kanban.css195 lines
@layer equip {
  .eq-kanban {
    position: relative;
    /* The board scrolls sideways; the part itself never grows past its container. */
    min-width: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  .eq-kanban-board {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: minmax(6.5rem, 1fr);
    gap: var(--eq-space-2);
    overflow-x: auto;
    overscroll-behavior-x: contain;
  }

  .eq-kanban-column {
    display: flex;
    flex-direction: column;
    gap: var(--eq-space-2);
    min-width: 0;
    padding: var(--eq-space-2);
    border-radius: var(--eq-radius-m);
    background: var(--eq-tint);
    transition: background-color var(--eq-dur) var(--eq-ease);
  }

  .eq-kanban-column[data-target] {
    background: var(--eq-tint-strong);
  }

  .eq-kanban-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--eq-space-1);
    padding-inline: var(--eq-space-1);
    font-size: var(--eq-text-xs);
    line-height: 16px;
  }

  .eq-kanban-column-title {
    min-width: 0;
    overflow: hidden;
    font-weight: var(--eq-weight-strong);
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .eq-kanban-count {
    flex: none;
    color: var(--eq-ink-2);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    transition: color var(--eq-dur) var(--eq-ease);
  }

  .eq-kanban-count[data-over] {
    color: var(--eq-danger);
    font-weight: var(--eq-weight-strong);
  }

  /* Remounted on every change, so the new number drops into place. */
  .eq-kanban-count-value {
    display: inline-block;
    transition:
      translate var(--eq-dur-move) var(--eq-spring),
      opacity var(--eq-dur) var(--eq-ease);
  }

  @starting-style {
    .eq-kanban-count-value {
      opacity: 0;
      translate: 0 -4px;
    }
  }

  .eq-kanban-list {
    position: relative;
    display: flex;
    flex: 1;
    flex-direction: column;
    gap: var(--eq-space-2);
    min-height: var(--eq-h-m);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  /* The item carries the slide when cards make room; the card inside carries press and lift. */
  .eq-kanban-item {
    border-radius: var(--eq-radius-m);
    transition: translate var(--eq-dur-move) var(--eq-spring);
  }

  .eq-kanban-item[data-placeholder] {
    outline: 1px dashed var(--eq-line-strong);
    outline-offset: -1px;
    background: var(--eq-tint);
  }

  /* Transparent rather than hidden, so the card keeps focus and still hears Escape. */
  .eq-kanban-item[data-placeholder] > .eq-kanban-card {
    opacity: 0;
  }

  .eq-kanban-card {
    display: grid;
    gap: 2px;
    box-sizing: border-box;
    min-height: 24px;
    padding: var(--eq-space-2);
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    overflow-wrap: anywhere;
    cursor: grab;
    user-select: none;
    touch-action: none;
    transition:
      border-color var(--eq-dur) var(--eq-ease),
      box-shadow var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    .eq-kanban-card:hover {
      border-color: var(--eq-line-strong);
    }
  }

  .eq-kanban-card:active {
    scale: var(--eq-press);
  }

  .eq-kanban-card:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  /* Lifted: it rises by as much as a press sinks, on the spring. */
  .eq-kanban-card[data-lifted],
  .eq-kanban-ghost > .eq-kanban-card {
    border-color: var(--eq-line-strong);
    box-shadow: var(--eq-shadow);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    scale: calc(2 - var(--eq-press));
    transition:
      border-color var(--eq-dur) var(--eq-ease),
      box-shadow var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring);
  }

  .eq-kanban-ghost {
    position: absolute;
    top: 0;
    left: 0;
    z-index: 2;
    pointer-events: none;
  }

  .eq-kanban-ghost > .eq-kanban-card {
    height: 100%;
    cursor: grabbing;
  }

  @starting-style {
    .eq-kanban-ghost > .eq-kanban-card {
      box-shadow: none;
      scale: 1;
    }
  }

  .eq-kanban-card-title {
    min-width: 0;
  }

  .eq-kanban-card-meta {
    color: var(--eq-ink-2);
    font-size: var(--eq-text-xs);
  }

  .eq-kanban-status {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index