Table
BetaDatos tabulares para comparar y ordenar.
registry/ds/table.tsx| Código | Materia | Docente | Estado | |
|---|---|---|---|---|
| PRG101 | Programación | Lucía Paz | 0 | Completa |
| FIS101 | Física I | Martín Ferro | 3 | Últimos |
| MAT101 | Cálculo I | Julia Arce | 12 | Con cupo |
"use client";
import { useState } from "react";
import { Chip } from "@/registry/ds/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/registry/ds/table";
const rows = [
{ codigo: "MAT101", nombre: "Cálculo I", docente: "Julia Arce", cupo: 12, estado: "success" as const },
{ codigo: "FIS101", nombre: "Física I", docente: "Martín Ferro", cupo: 3, estado: "warning" as const },
{ codigo: "PRG101", nombre: "Programación", docente: "Lucía Paz", cupo: 0, estado: "danger" as const },
];
export default function TableBasic() {
const [sort, setSort] = useState<"asc" | "desc">("asc");
const sorted = [...rows].sort((a, b) => (sort === "asc" ? a.cupo - b.cupo : b.cupo - a.cupo));
return (
<div className="w-full max-w-2xl">
<Table>
<TableHead>
<TableRow>
<TableHeader>Código</TableHeader>
<TableHeader>Materia</TableHeader>
<TableHeader>Docente</TableHeader>
<TableHeader align="right" sort={sort} onSort={() => setSort(sort === "asc" ? "desc" : "asc")}>Cupo</TableHeader>
<TableHeader>Estado</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{sorted.map((r) => (
<TableRow key={r.codigo}>
<TableCell className="font-mono text-xs text-fg-muted">{r.codigo}</TableCell>
<TableCell className="font-medium">{r.nombre}</TableCell>
<TableCell className="text-fg-muted">{r.docente}</TableCell>
<TableCell numeric>{r.cupo}</TableCell>
<TableCell>
<Chip size="sm" status={r.estado}>{{ success: "Con cupo", warning: "Últimos", danger: "Completa" }[r.estado]}</Chip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}Cuándo usarlo
Cuando cada fila es un registro con los mismos atributos y la persona compara o busca entre ellos. Si cada ítem tiene forma distinta o pesa lo visual, es una grilla de Cards.
Selección múltiple
Un checkbox por fila y uno en el encabezado que marca todas (indeterminado cuando hay algunas). Las acciones en lote van arriba, en una barra que aparece con la primera selección, no en cada fila.
| Materia | Turno | Cupo | |
|---|---|---|---|
| Cálculo IIMAT201 | Mañana | 12 | |
| Física IFIS101 | Tarde | 3 | |
| ÁlgebraALG101 | Noche | 24 | |
| Inglés técnicoING201 | Noche | 31 |
"use client";
import { useState } from "react";
import { Button } from "@/registry/ds/button";
import { Chip } from "@/registry/ds/badge";
import { Checkbox } from "@/registry/ds/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/registry/ds/table";
const rows = [
{ codigo: "MAT201", nombre: "Cálculo II", turno: "Mañana", cupo: 12 },
{ codigo: "FIS101", nombre: "Física I", turno: "Tarde", cupo: 3 },
{ codigo: "ALG101", nombre: "Álgebra", turno: "Noche", cupo: 24 },
{ codigo: "ING201", nombre: "Inglés técnico", turno: "Noche", cupo: 31 },
];
/** Selección múltiple: checkbox por fila, "todas" con estado indeterminado y una barra de acciones en lote */
export default function TableSelection() {
const [selected, setSelected] = useState<Set<string>>(new Set(["FIS101"]));
const all = selected.size === rows.length;
const some = selected.size > 0 && !all;
const toggle = (codigo: string, checked: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(codigo);
else next.delete(codigo);
return next;
});
return (
<div className="flex w-full max-w-2xl flex-col gap-3">
<div className="flex min-h-9 items-center justify-between gap-3 text-sm" aria-live="polite">
<span className="text-fg-muted">{selected.size === 0 ? "Elegí materias para inscribirte" : `${selected.size} ${selected.size === 1 ? "seleccionada" : "seleccionadas"}`}</span>
{selected.size > 0 && (
<div className="flex gap-2">
<Button size="sm" variant="text" onClick={() => setSelected(new Set())}>
Limpiar
</Button>
<Button size="sm">Inscribirme a {selected.size}</Button>
</div>
)}
</div>
<Table size="sm">
<TableHead>
<TableRow>
<TableHeader className="w-10">
<Checkbox size="sm" aria-label="Seleccionar todas" checked={all} indeterminate={some} onCheckedChange={(c) => setSelected(c ? new Set(rows.map((r) => r.codigo)) : new Set())} />
</TableHeader>
<TableHeader>Materia</TableHeader>
<TableHeader className="max-sm:hidden">Turno</TableHeader>
<TableHeader align="right">Cupo</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{rows.map((r) => {
const checked = selected.has(r.codigo);
return (
<TableRow key={r.codigo} selected={checked}>
<TableCell>
<Checkbox size="sm" aria-label={`Seleccionar ${r.nombre}`} checked={checked} onCheckedChange={(c) => toggle(r.codigo, Boolean(c))} />
</TableCell>
<TableCell>
<span className="block font-medium text-fg">{r.nombre}</span>
<span className="block text-xs text-fg-muted">{r.codigo}</span>
</TableCell>
<TableCell className="text-fg-muted max-sm:hidden">{r.turno}</TableCell>
<TableCell numeric>
{r.cupo <= 5 ? (
<Chip size="sm" status="warning">
{r.cupo}
</Chip>
) : (
r.cupo
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}Cargando y vacía
Mientras llegan los datos, la misma grilla con Skeleton;
sin resultados, TableEmpty con el motivo y una salida. El contenedor lleva
aria-busy para que el lector de pantalla sepa que está cargando.
| Materia | Docente | Estado |
|---|
"use client";
import { useState } from "react";
import { Button } from "@/registry/ds/button";
import { Chip } from "@/registry/ds/badge";
import { Search } from "@/registry/ds/icons";
import { Skeleton } from "@/registry/ds/skeleton";
import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/registry/ds/table";
import { ToggleGroup, ToggleItem } from "@/registry/ds/toggle-group";
const rows = [
{ codigo: "MAT201", nombre: "Cálculo II", docente: "Julia Arce", estado: "success" as const },
{ codigo: "FIS101", nombre: "Física I", docente: "Martín Ferro", estado: "warning" as const },
{ codigo: "ALG101", nombre: "Álgebra", docente: "Pedro Núñez", estado: "danger" as const },
];
const label = { success: "Regular", warning: "En riesgo", danger: "Libre" };
/** Los tres estados de una tabla: cargando (skeleton con la misma grilla), vacía (TableEmpty) y con datos */
export default function TableStates() {
const [state, setState] = useState<"loading" | "empty" | "data">("loading");
return (
<div className="flex w-full max-w-2xl flex-col gap-4">
<ToggleGroup aria-label="Estado de la tabla" size="sm" value={[state]} onValueChange={(v) => v[0] && setState(v[0] as typeof state)}>
<ToggleItem value="loading">Cargando</ToggleItem>
<ToggleItem value="empty">Vacía</ToggleItem>
<ToggleItem value="data">Con datos</ToggleItem>
</ToggleGroup>
<div aria-busy={state === "loading"} aria-live="polite">
<Table size="sm">
<TableHead>
<TableRow>
<TableHeader>Materia</TableHeader>
<TableHeader>Docente</TableHeader>
<TableHeader>Estado</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{state === "loading" &&
[0, 1, 2].map((i) => (
<TableRow key={i} aria-hidden>
<TableCell>
<Skeleton width={i === 1 ? "55%" : "70%"} />
</TableCell>
<TableCell>
<Skeleton width="60%" />
</TableCell>
<TableCell>
<Skeleton width={64} height={20} variant="rect" className="rounded-full" />
</TableCell>
</TableRow>
))}
{state === "empty" && (
<TableEmpty colSpan={3}>
<div className="flex flex-col items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-full bg-surface text-icon-muted">
<Search className="size-5" />
</span>
<div>
<p className="font-medium text-fg">Ninguna materia coincide</p>
<p className="mt-0.5">Probá con otro nombre o quitá los filtros.</p>
</div>
<Button size="sm" variant="stroke" onClick={() => setState("data")}>
Quitar filtros
</Button>
</div>
</TableEmpty>
)}
{state === "data" &&
rows.map((r) => (
<TableRow key={r.codigo}>
<TableCell className="font-medium">{r.nombre}</TableCell>
<TableCell className="text-fg-muted">{r.docente}</TableCell>
<TableCell>
<Chip size="sm" status={r.estado}>
{label[r.estado]}
</Chip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{state === "loading" && <span className="sr-only">Cargando materias</span>}
</div>
</div>
);
}Muchas filas
Encabezado fijo dentro de un alto máximo, importes alineados a la derecha, acciones de fila en un Menu y Pagination compacta abajo.
| Comprobante | Concepto | Fecha | Importe | Acciones |
|---|---|---|---|---|
| 2026-001 | Matrícula | 10/03/2026 | $ 48.000 | |
| 2026-002 | Cuota Abr | 11/04/2026 | $ 20.500 | |
| 2026-003 | Cuota May | 12/05/2026 | $ 20.500 | |
| 2026-004 | Cuota Jun | 13/06/2026 | $ 20.500 | |
| 2026-005 | Matrícula | 14/07/2026 | $ 48.000 | |
| 2026-006 | Cuota Ago | 15/08/2026 | $ 20.500 | |
| 2026-007 | Cuota Sep | 16/09/2026 | $ 20.500 | |
| 2026-008 | Cuota Oct | 17/10/2026 | $ 20.500 |
"use client";
import { useState } from "react";
import { Button } from "@/registry/ds/button";
import { ArrowUpRight, Close, DotsVertical, FileText } from "@/registry/ds/icons";
import { Menu, MenuItem, MenuSeparator } from "@/registry/ds/menu";
import { Pagination } from "@/registry/ds/pagination";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/registry/ds/table";
const pagos = Array.from({ length: 23 }, (_, i) => {
const mes = ["Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct"][i % 8];
return { id: `2026-${String(i + 1).padStart(3, "0")}`, concepto: i % 4 === 0 ? "Matrícula" : `Cuota ${mes}`, fecha: `${10 + (i % 18)}/${String((i % 8) + 3).padStart(2, "0")}/2026`, importe: i % 4 === 0 ? 48000 : 20500 };
});
const porPagina = 8;
const pesos = (n: number) => `$ ${n.toLocaleString("es-AR")}`;
/** Muchas filas: encabezado fijo dentro de un alto máximo, columna numérica alineada, acciones en un Menu y paginación compacta */
export default function TableScroll() {
const [page, setPage] = useState(1);
const pages = Math.ceil(pagos.length / porPagina);
const visible = pagos.slice((page - 1) * porPagina, page * porPagina);
const total = visible.reduce((t, p) => t + p.importe, 0);
return (
<div className="flex w-full max-w-2xl flex-col gap-3">
<div className="max-h-72 overflow-y-auto rounded-xl [&>div]:rounded-none [&>div]:border-0">
<Table size="sm" className="[&_thead_th]:sticky [&_thead_th]:top-0 [&_thead_th]:z-10 [&_thead_th]:bg-bg-subtle">
<TableHead>
<TableRow>
<TableHeader>Comprobante</TableHeader>
<TableHeader>Concepto</TableHeader>
<TableHeader className="max-sm:hidden">Fecha</TableHeader>
<TableHeader align="right">Importe</TableHeader>
<TableHeader>
<span className="sr-only">Acciones</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{visible.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-mono text-xs text-fg-muted">{p.id}</TableCell>
<TableCell className="font-medium">{p.concepto}</TableCell>
<TableCell className="text-fg-muted max-sm:hidden">{p.fecha}</TableCell>
<TableCell numeric>{pesos(p.importe)}</TableCell>
<TableCell align="right">
<Menu align="end" trigger={<Button variant="text" size="sm" iconOnly={<DotsVertical />} aria-label={`Acciones de ${p.id}`} />}>
<MenuItem icon={<FileText />}>Descargar PDF</MenuItem>
<MenuItem icon={<ArrowUpRight />} href="#">
Ver detalle
</MenuItem>
<MenuSeparator />
<MenuItem icon={<Close />} destructive>
Anular
</MenuItem>
</Menu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-fg-muted">
<span>
{visible.length} de {pagos.length} · total de la página <span className="font-medium text-fg tabular-nums">{pesos(total)}</span>
</span>
<Pagination variant="compact" size="sm" page={page} count={pages} onPageChange={setPage} />
</div>
</div>
);
}Reglas
- Números a la derecha, alineados por dígito (
numeric); texto a la izquierda. - Encabezados cortos, sin punto. Unidades en el encabezado ("Cupo"), no en cada celda.
- Ordenar por una columna a la vez; mostrá el sentido en el encabezado.
- Máximo ~7 columnas visibles: el resto va detrás de un menú de columnas (Dropdown menu) o en el detalle de la fila.
- Acciones por fila en la última columna, como icon-buttons o un "…".
- Estados: Chip status en su propia columna.
- Muchas filas: Pagination debajo. Sin filas:
TableEmptycon un Empty state corto.
| Size | Fila | Encabezado |
|---|---|---|
sm | 40 | 36 |
md | 48 | 40 |
Padding X 16. Contenedor radius-xl 12. Encabezado Text xs / Medium,
mayúsculas con tracking-sm. Celda Text sm.
| Parte | Propiedad | Token |
|---|---|---|
| Contenedor | Fondo · borde | bg/elevated |
| Encabezado | Fondo | bg/subtle |
| Encabezado | Texto | text/muted |
| Celda | Texto | text/default |
| Divisor de filas | Borde | border/subtle |
| Fila · hover | Fondo | bg/subtle |
| Fila seleccionada | Fondo | primary/faint |
Instalación
npx shadcn@latest add https://polen.cdc.cool/r/table.jsonPiezas semánticas: Table, TableHead, TableBody, TableRow,
TableHeader, TableCell, TableEmpty. El orden, la paginación y la
selección los manejás vos y se los pasás.
Playground
Configurá el componente y copiá el snippet: se genera del mismo estado que renderiza el preview.
| Código | Materia | Docente | Estado | |
|---|---|---|---|---|
| PRG101 | Programación | Lucía Paz | 0 | Completa |
| FIS101 | Física I | Martín Ferro | 3 | Últimos |
| MAT101 | Cálculo I | Julia Arce | 12 | Con cupo |
<Table>
<TableHead>
<TableRow>
<TableHeader>Código</TableHeader>
<TableHeader>Materia</TableHeader>
<TableHeader align="right" sort={sort} onSort={toggleSort}>Cupo</TableHeader>
<TableHeader>Estado</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{rows.map((r) => (
<TableRow key={r.codigo}>
<TableCell>{r.codigo}</TableCell>
<TableCell>{r.nombre}</TableCell>
<TableCell numeric>{r.cupo}</TableCell>
<TableCell><Chip size="sm" status={r.estado}>{r.label}</Chip></TableCell>
</TableRow>
))}
</TableBody>
</Table>| Prop | Tipo | Default | Descripción |
|---|---|---|---|
Table · size | "sm" | "md" | "md" | Alto de filas 40 / 48. |
TableRow · selected | boolean | — | Fila marcada (aria-selected). |
TableHeader · align | "left" | "right" | "center" | "left" | Alineación de la columna. |
TableHeader · sort · onSort | "asc" | "desc" | "none" · () => void | — | Vuelve el encabezado un botón de orden con aria-sort. |
TableCell · numeric | boolean | — | Derecha, monoespaciada, dígitos tabulares. |
TableEmpty · colSpan | number | — | Fila que ocupa todas las columnas. |
<table>real con<th scope="col">: los lectores de pantalla anuncian columna y fila.- El orden se expone con
aria-sort; el encabezado ordenable es un botón. - Si la tabla scrollea horizontalmente, el contenedor es enfocable con teclado por el navegador; mantené la primera columna corta para orientarse.
- Agregá un
<caption>(oaria-label) que diga qué lista la tabla.