Skip to content
    Style: Wireframe

    Components · Data

    Table

    A data table with a caption, right-aligned numbers and sortable columns (aria-sort). Scrolls in its own box.

    5.4% of the 2,073 sites in The UI Index have a table (112 sites).

    Used in UI Pro: Pricing, Comparison

    Deploys this week, Northwind site
    AuthorStatus
    mainAda LovelaceLive48
    pricing-copyGrace HopperPreview121
    fix-navAlan TuringFailed17
    docsAda LovelacePreview63

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    Install

    React
    npx shadcn@latest add https://www.themes.dev/ui/r/table.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/table.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI theme. The HTML needs only the compiled CSS and the script.

    table.tsx
    import { useState } from 'react';
    import { cn } from '@/lib/utils';
    import { SORT_PATH, sortRows, table, type Column, type Row } from './table.variants';
    
    export type { Column, Row };
    
    export interface TableProps {
      columns: Column[];
      rows: Row[];
      /** What the table shows. Also names the scroll box for screen readers. */
      caption: string;
      /** Unique on the page; ids the caption. */
      id: string;
      className?: string;
    }
    
    /** A data table with optional sortable columns (ascending, then descending; aria-sort on the column). */
    export function Table({ columns, rows, caption, id, className }: TableProps) {
      const [sort, setSort] = useState<{ key: string; dir: 'ascending' | 'descending' } | null>(null);
      const shown = sort ? sortRows(rows, sort.key, sort.dir) : rows;
      return (
        <div className={cn(table.wrap, className)} tabIndex={0} role="region" aria-labelledby={`${id}-caption`} data-table="">
          <table className={table.table}>
            <caption id={`${id}-caption`} className={table.caption}>{caption}</caption>
            <thead>
              <tr>
                {columns.map((c) => (
                  <th key={c.key} scope="col" aria-sort={c.sortable ? (sort?.key === c.key ? sort.dir : 'none') : undefined} className={cn(table.head, c.align === 'end' && table.end)}>
                    {c.sortable ? (
                      <button type="button" data-sort="" className={table.sort} onClick={(e) => {
                        // Handled here: the delegated script for Astro and HTML tables leaves this click alone.
                        e.preventDefault();
                        setSort({ key: c.key, dir: sort?.key === c.key && sort.dir === 'ascending' ? 'descending' : 'ascending' });
                      }}>
                        {c.label}
                        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={table.sortIcon}><path d={SORT_PATH} /></svg>
                      </button>
                    ) : (
                      c.label
                    )}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {shown.map((r, k) => (
                <tr key={k} className={table.row}>
                  {columns.map((c) => (
                    <td key={c.key} data-value={r[c.key]} className={cn(table.cell, c.align === 'end' && table.end)}>{r[c.key]}</td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      );
    }
    table.variants.ts
    /** A data table: a real <table> with a caption, header cells with scope, numbers right-aligned in tabular figures.
     *  Lines are for data, so rows keep their hairlines. It scrolls sideways in its own box (focusable, named by the
     *  caption, shaded at the edge that hides columns). Sortable columns have a button in the header and aria-sort. */
    export const table = {
      wrap: 'relative w-full overflow-x-auto rounded-lg border [background:linear-gradient(to_right,var(--background)_50%,transparent)_left/2rem_100%_no-repeat_local,linear-gradient(to_left,var(--background)_50%,transparent)_right/2rem_100%_no-repeat_local,radial-gradient(farthest-side_at_0_50%,var(--tabs-shade,rgb(0_0_0/0.2)),transparent)_left/0.75rem_100%_no-repeat_scroll,radial-gradient(farthest-side_at_100%_50%,var(--tabs-shade,rgb(0_0_0/0.2)),transparent)_right/0.75rem_100%_no-repeat_scroll,var(--background)]',
      table: 'w-full caption-bottom border-collapse text-sm',
      caption: 'border-t px-4 py-3 text-left text-sm text-muted-foreground',
      head: 'h-10 border-b px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground',
      cell: 'border-b px-4 py-3 align-middle whitespace-nowrap',
      row: 'transition-colors last:[&>td]:border-b-0 hover:bg-muted/60',
      end: 'text-right tabular-nums',
      sort: '-mx-2 inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md px-2 font-medium transition-colors select-none hover:bg-accent hover:text-foreground active:bg-pressed [&_svg]:size-3.5 [&_svg]:shrink-0',
      sortIcon: 'opacity-50 [[aria-sort=ascending]_&]:rotate-180 [[aria-sort=ascending]_&]:opacity-100 [[aria-sort=descending]_&]:opacity-100 transition-transform',
    };
    
    export type Column = { key: string; label: string; align?: 'start' | 'end'; sortable?: boolean };
    export type Row = Record<string, string | number>;
    export const SORT_PATH = 'M4 6l4 4 4-4';
    
    /** Rows sorted by a column: numbers as numbers, text with the locale's collation. */
    export function sortRows(rows: Row[], key: string, dir: 'ascending' | 'descending'): Row[] {
      const sorted = [...rows].sort((a, b) => {
        const x = a[key];
        const y = b[key];
        return typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y), undefined, { numeric: true });
      });
      return dir === 'descending' ? sorted.reverse() : sorted;
    }