Tablero kanban
Tres columnas con arrastre nativo y botones para mover tarjetas, con anuncio del movimiento para lectores de pantalla.
Por hacer2
En curso1
Hecho1
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/datos-kanbanEjemplo de uso de Tablero kanban
Después de copiar datos-kanban.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
columns? | DatosKanbanColumn[] | Opcional. |
onMove? | (cardId: string, from: string, to: string) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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".