Ir al contenido
Evaluar mi proyecto
Componentes · Datos y visualizaciónDisponible

Tablero kanban

Tres columnas con arrastre nativo y botones para mover tarjetas, con anuncio del movimiento para lectores de pantalla.

Por hacer2
DiseñoDefinir alcance del módulo de reportes
IntegraciónConectar con facturación
En curso1
FrontendPantalla de inicio de sesión
Hecho1
DatosModelo de datos aprobado

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

datos-kanban.tsx102 líneas
Descargar
"use client";

import { useState, type CSSProperties, type DragEvent } from "react";

/** Tablero kanban con arrastre nativo y botones para mover entre columnas (accesibles con teclado), contador por columna y anuncio del movimiento. Estilo claro de herramienta de proyectos. React + TypeScript, sin dependencias. */
export type DatosKanbanCard = { id: string; title: string; tag: string };
export type DatosKanbanColumn = { id: string; title: string; cards: DatosKanbanCard[] };

export type DatosKanbanProps = {
  columns?: DatosKanbanColumn[];
  onMove?: (cardId: string, from: string, to: string) => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcdkb-root { box-sizing: border-box; width: 100%; max-width: 760px; color: #1f2430; font: 500 14px/1.35 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcdkb-root *, .gcdkb-root *::before, .gcdkb-root *::after { box-sizing: border-box; }
.gcdkb-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcdkb-root :focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcdkb-root * { transition-duration: .01ms !important; animation: none !important; } }
.gcdkb-board { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.gcdkb-col { display: flex; flex-direction: column; gap: 10px; min-height: 280px; padding: 12px; border-radius: 14px; background: #f1f3f8; transition: background .15s, box-shadow .15s; }
.gcdkb-col[data-over="true"] { background: #e6ecfb; box-shadow: inset 0 0 0 2px #7b9bf0; }
.gcdkb-head { display: flex; align-items: center; justify-content: space-between; padding: 0 4px; font-weight: 700; }
.gcdkb-count { min-width: 24px; padding: 2px 8px; border-radius: 99px; background: #fff; color: #5b6477; font-size: 12px; text-align: center; font-variant-numeric: tabular-nums; }
.gcdkb-card { display: grid; gap: 8px; padding: 12px; border: 1px solid #e3e6ee; border-radius: 10px; background: #fff; cursor: grab; box-shadow: 0 1px 2px rgba(20, 30, 50, .06); animation: gcdkb-in .3s cubic-bezier(.16, 1, .3, 1); }
.gcdkb-card[data-drag="true"] { opacity: .4; }
.gcdkb-card:active { cursor: grabbing; }
.gcdkb-tag { justify-self: start; padding: 2px 8px; border-radius: 6px; background: #e8efff; color: #2456c8; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.gcdkb-moves { display: flex; justify-content: flex-end; gap: 4px; }
.gcdkb-mv { display: grid; place-items: center; width: 30px; height: 28px; border: 1px solid #e3e6ee; border-radius: 6px; background: #fff; color: #5b6477; cursor: pointer; }
.gcdkb-mv:hover:not(:disabled) { background: #f1f3f8; color: #1f2430; }
.gcdkb-mv:disabled { opacity: .3; cursor: not-allowed; }
.gcdkb-empty { margin: auto; color: #8b92a5; font-size: 13px; }
.gcdkb-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
@keyframes gcdkb-in { from { opacity: 0; transform: translateY(6px); } }
@media (max-width: 620px) { .gcdkb-board { grid-template-columns: 1fr; } .gcdkb-col { min-height: 0; } }`;

const DEFAULT: DatosKanbanColumn[] = [
  { id: "todo", title: "Por hacer", cards: [{ id: "a", title: "Definir alcance del módulo de reportes", tag: "Diseño" }, { id: "b", title: "Conectar con facturación", tag: "Integración" }] },
  { id: "doing", title: "En curso", cards: [{ id: "c", title: "Pantalla de inicio de sesión", tag: "Frontend" }] },
  { id: "done", title: "Hecho", cards: [{ id: "d", title: "Modelo de datos aprobado", tag: "Datos" }] },
];

export function DatosKanban({ columns = DEFAULT, onMove, className = "", style }: DatosKanbanProps) {
  const [cols, setCols] = useState(columns);
  const [drag, setDrag] = useState<{ id: string; from: string } | null>(null);
  const [over, setOver] = useState<string | null>(null);
  const [live, setLive] = useState("");

  function move(id: string, from: string, to: string) {
    if (from === to) return;
    setCols((prev) => {
      const card = prev.find((col) => col.id === from)?.cards.find((c) => c.id === id);
      if (!card) return prev;
      return prev.map((col) => (col.id === from ? { ...col, cards: col.cards.filter((c) => c.id !== id) } : col.id === to ? { ...col, cards: [...col.cards, card] } : col));
    });
    const name = cols.find((col) => col.id === to)?.title;
    setLive(`Tarjeta movida a ${name}`);
    onMove?.(id, from, to);
  }
  function drop(event: DragEvent, to: string) {
    event.preventDefault();
    if (drag) move(drag.id, drag.from, to);
    setDrag(null);
    setOver(null);
  }

  return (
    <div className={`gcdkb-root ${className}`} style={style}>
      <style>{CSS}</style>
      <div className="gcdkb-board">
        {cols.map((col, ci) => (
          <section key={col.id} className="gcdkb-col" data-over={over === col.id} aria-label={col.title} onDragOver={(event) => { event.preventDefault(); setOver(col.id); }} onDragLeave={() => setOver((value) => (value === col.id ? null : value))} onDrop={(event) => drop(event, col.id)}>
            <div className="gcdkb-head">
              {col.title}
              <span className="gcdkb-count">{col.cards.length}</span>
            </div>
            {col.cards.map((card) => (
              <article key={card.id} className="gcdkb-card" draggable data-drag={drag?.id === card.id} onDragStart={(event) => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", card.id); setDrag({ id: card.id, from: col.id }); }} onDragEnd={() => { setDrag(null); setOver(null); }}>
                <span className="gcdkb-tag">{card.tag}</span>
                <span>{card.title}</span>
                <span className="gcdkb-moves">
                  <button className="gcdkb-mv" type="button" aria-label={`Mover “${card.title}” a ${cols[ci - 1]?.title ?? ""}`} disabled={ci === 0} onClick={() => move(card.id, col.id, cols[ci - 1].id)}>
                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m15 6-6 6 6 6" /></svg>
                  </button>
                  <button className="gcdkb-mv" type="button" aria-label={`Mover “${card.title}” a ${cols[ci + 1]?.title ?? ""}`} disabled={ci === cols.length - 1} onClick={() => move(card.id, col.id, cols[ci + 1].id)}>
                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
                  </button>
                </span>
              </article>
            ))}
            {col.cards.length === 0 ? <p className="gcdkb-empty">Suelta una tarjeta aquí</p> : null}
          </section>
        ))}
      </div>
      <span className="gcdkb-live" role="status" aria-live="polite">{live}</span>
    </div>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/datos-kanban

Ejemplo de uso de Tablero kanban

Después de copiar datos-kanban.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { DatosKanban } from "./datos-kanban";

export default function Ejemplo() {
  return <DatosKanban />;
}

Propiedades de DatosKanban

DatosKanban acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
columns?DatosKanbanColumn[]Opcional.
onMove?(cardId: string, from: string, to: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 102 líneas (7 KB) y define 1 animación con @keyframes. Esto es lo que hace Tablero kanban por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Declara roles ARIA para lectores de pantalla: status.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)