Skip to content

Table

MD · 1950B gzip budget

A real, sortable table screen readers can navigate by row and column.

Category
Data display
Budget
md, several states or a live subscription
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale
Aisha
Bo

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/table.json

Copies the source into your project. Pulls in 3 shared registry items: cn, use-locale, locale.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { cn } from "../lib/cn";
import { useLocale } from "../lib/use-locale";

export interface Column<T> {
  key: string;
  header: string;
  render: (row: T) => React.ReactNode;
  sortable?: boolean;
}

export interface TableProps<T> {
  columns: Column<T>[];
  rows: T[];
  sortKey?: string;
  sortDirection?: "asc" | "desc";
  onSort?: (key: string) => void;
  className?: string;
}

/**
 * A real `<table>`, not a grid of `<div>`s: screen readers navigate tables by
 * row and column ("row 3, column Status") only when the markup is actually
 * tabular. Sortable headers use `aria-sort`, and arrow direction in
 * `wrapDirectionIcon` is left to the caller's own icon component so a single
 * table implementation works for both reading directions.
 */
export function Table<T>({ columns, rows, sortKey, sortDirection, onSort, className }: TableProps<T>) {
  const { locale } = useLocale();

  return (
    <div className={cn("overflow-x-auto text-start", className)}>
      <table className="w-full border-collapse text-sm">
        <thead>
          <tr className="border-b border-neutral-200 dark:border-neutral-800">
            {columns.map((column) => (
              <th
                key={column.key}
                scope="col"
                aria-sort={sortKey === column.key ? (sortDirection === "desc" ? "descending" : "ascending") : column.sortable ? "none" : undefined}
                className="p-2 text-start font-medium text-neutral-600 dark:text-neutral-400"
              >
                {column.sortable ? (
                  <button type="button" onClick={() => onSort?.(column.key)} className="flex items-center gap-1 hover:text-neutral-900 dark:hover:text-neutral-100">
                    {column.header}
                    {sortKey === column.key && <span aria-hidden="true">{sortDirection === "desc" ? "▾" : "▴"}</span>}
                  </button>
                ) : (
                  column.header
                )}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((row, index) => (
            <tr key={index} className="border-b border-neutral-100 dark:border-neutral-900">
              {columns.map((column) => (
                <td key={column.key} className="p-2" lang={locale}>
                  {column.render(row)}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation