Skip to main content
Nexus Design System
GitHub

Table

Table shows rows of data in columns. Use it for records users scan and compare, such as invoices. Sorting, selection and pagination logic stay in your app.

A list of your recent invoices.
InvoiceStatusMethodAmount
INV001PaidCredit Card$250.00
INV002PendingPayPal$150.00
INV003UnpaidBank Transfer$350.00
INV004PaidCredit Card$450.00
INV005PaidPayPal$550.00
'use client';

import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/nxds/components/table/table';

const invoices = [
  {
    invoice: 'INV001',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$250.00',
  },
  { invoice: 'INV002', status: 'Pending', method: 'PayPal', amount: '$150.00' },
  {
    invoice: 'INV003',
    status: 'Unpaid',
    method: 'Bank Transfer',
    amount: '$350.00',
  },
  {
    invoice: 'INV004',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$450.00',
  },
  { invoice: 'INV005', status: 'Paid', method: 'PayPal', amount: '$550.00' },
];

export default function TableDefault() {
  return (
    <Table>
      <TableCaption>A list of your recent invoices.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead>Method</TableHead>
          <TableHead className="nx:text-right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((row) => (
          <TableRow key={row.invoice}>
            <TableCell className="nx:font-medium">{row.invoice}</TableCell>
            <TableCell>{row.status}</TableCell>
            <TableCell>{row.method}</TableCell>
            <TableCell className="nx:text-right">{row.amount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

Installation

npx nxds-cli@latest add table

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
  TableRowHeader,
  TableSelectionCell,
  TableSelectionHead,
} from '@/components/nxds/components/table/table';

Composition

Table
├── TableCaption
├── TableHeader
│   └── TableRow
│       ├── TableHead
│       └── TableSelectionHead
├── TableBody
│   └── TableRow
│       ├── TableCell
│       ├── TableSelectionCell
│       └── TableRowHeader
└── TableFooter
    └── TableRow
        └── TableCell

Examples

Add a TableFooter row for totals, on a muted fill.

InvoiceStatusAmount
INV001Paid$250.00
INV002Pending$150.00
INV003Unpaid$350.00
INV004Paid$450.00
INV005Paid$550.00
Total$1,750.00
'use client';

import {
  Table,
  TableBody,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/nxds/components/table/table';

const invoices = [
  {
    invoice: 'INV001',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$250.00',
  },
  { invoice: 'INV002', status: 'Pending', method: 'PayPal', amount: '$150.00' },
  {
    invoice: 'INV003',
    status: 'Unpaid',
    method: 'Bank Transfer',
    amount: '$350.00',
  },
  {
    invoice: 'INV004',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$450.00',
  },
  { invoice: 'INV005', status: 'Paid', method: 'PayPal', amount: '$550.00' },
];

export default function TableWithFooter() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead className="nx:text-right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((row) => (
          <TableRow key={row.invoice}>
            <TableCell className="nx:font-medium">{row.invoice}</TableCell>
            <TableCell>{row.status}</TableCell>
            <TableCell className="nx:text-right">{row.amount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow>
          <TableCell colSpan={2}>Total</TableCell>
          <TableCell className="nx:text-right">$1,750.00</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  );
}

Selectable rows

Put a Checkbox in each row with TableSelectionHead and TableSelectionCell.

Recent invoices with row selection.
InvoiceStatusAmount
INV001Paid$250.00
INV002Pending$150.00
INV003Unpaid$350.00
INV004Paid$450.00
INV005Paid$550.00
'use client';

import { useState } from 'react';

import { Checkbox } from '@/components/nxds/components/checkbox/checkbox';
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  type TableProps,
  TableRow,
  TableRowHeader,
  TableSelectionCell,
  TableSelectionHead,
} from '@/components/nxds/components/table/table';

const invoices = [
  {
    invoice: 'INV001',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$250.00',
  },
  { invoice: 'INV002', status: 'Pending', method: 'PayPal', amount: '$150.00' },
  {
    invoice: 'INV003',
    status: 'Unpaid',
    method: 'Bank Transfer',
    amount: '$350.00',
  },
  {
    invoice: 'INV004',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$450.00',
  },
  { invoice: 'INV005', status: 'Paid', method: 'PayPal', amount: '$550.00' },
];

const selectionInvoices = invoices.map((row) => ({ ...row, id: row.invoice }));

interface SelectionTableDemoProps extends TableProps {
  rows?: typeof selectionInvoices;
  disabled?: boolean;
}

function SelectionTableDemo({
  rows = selectionInvoices,
  disabled = false,
  ...props
}: SelectionTableDemoProps) {
  const [selected, setSelected] = useState(
    () =>
      new Set(
        disabled
          ? []
          : rows.filter((_, i) => i === 0 || i === 2).map((row) => row.id)
      )
  );
  const allSelected = rows.length > 0 && selected.size === rows.length;
  const checked = allSelected
    ? true
    : selected.size > 0
      ? 'indeterminate'
      : false;

  function toggleAll(value: boolean | 'indeterminate') {
    setSelected(
      value === true ? new Set(rows.map((row) => row.id)) : new Set()
    );
  }

  function toggleRow(id: string) {
    setSelected((current) => {
      const next = new Set(current);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }

  return (
    <Table {...props}>
      <TableCaption>Recent invoices with row selection.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableSelectionHead>
            <Checkbox
              checked={checked}
              onCheckedChange={toggleAll}
              disabled={disabled || rows.length === 0}
              aria-label="Select all rows"
            />
          </TableSelectionHead>
          <TableHead>Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead className="nx:text-end">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {rows.length === 0 ? (
          <TableRow>
            <TableCell colSpan={4}>No invoices yet.</TableCell>
          </TableRow>
        ) : (
          rows.map((row) => (
            <TableRow
              key={row.id}
              data-state={selected.has(row.id) ? 'selected' : undefined}
            >
              <TableSelectionCell>
                <Checkbox
                  checked={selected.has(row.id)}
                  onCheckedChange={() => toggleRow(row.id)}
                  disabled={disabled}
                  aria-label={`Select ${row.id}`}
                />
              </TableSelectionCell>
              <TableRowHeader>{row.id}</TableRowHeader>
              <TableCell>{row.status}</TableCell>
              <TableCell className="nx:text-end nx:tabular-nums">
                {row.amount}
              </TableCell>
            </TableRow>
          ))
        )}
      </TableBody>
    </Table>
  );
}

export default function TableSelectableRows() {
  return <SelectionTableDemo />;
}

This example also needs: Checkbox.

With actions

Add a trailing column of row actions with text buttons.

InvoiceStatusActions
INV001Paid
INV002Pending
INV003Unpaid
'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/nxds/components/table/table';

const invoices = [
  {
    invoice: 'INV001',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$250.00',
  },
  { invoice: 'INV002', status: 'Pending', method: 'PayPal', amount: '$150.00' },
  {
    invoice: 'INV003',
    status: 'Unpaid',
    method: 'Bank Transfer',
    amount: '$350.00',
  },
  {
    invoice: 'INV004',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$450.00',
  },
  { invoice: 'INV005', status: 'Paid', method: 'PayPal', amount: '$550.00' },
];

export default function TableWithActions() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead className="nx:text-right">Actions</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.slice(0, 3).map((row) => (
          <TableRow key={row.invoice}>
            <TableCell className="nx:font-medium">{row.invoice}</TableCell>
            <TableCell>{row.status}</TableCell>
            <TableCell>
              <div className="nx:flex nx:justify-end nx:gap-2">
                <Button variant="ghost" size="sm">
                  Edit
                </Button>
                <Button variant="destructive" size="sm">
                  Delete
                </Button>
              </div>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

This example also needs: Button.

Striped

Set striped to tint every other row, with hover and selection on top.

InvoiceStatusAmount
INV001Paid$250.00
INV002Pending$150.00
INV003Unpaid$350.00
INV004Paid$450.00
'use client';

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
  TableRowHeader,
} from '@/components/nxds/components/table/table';

const invoices = [
  {
    invoice: 'INV001',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$250.00',
  },
  { invoice: 'INV002', status: 'Pending', method: 'PayPal', amount: '$150.00' },
  {
    invoice: 'INV003',
    status: 'Unpaid',
    method: 'Bank Transfer',
    amount: '$350.00',
  },
  {
    invoice: 'INV004',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$450.00',
  },
  { invoice: 'INV005', status: 'Paid', method: 'PayPal', amount: '$550.00' },
];

export default function TableStriped() {
  return (
    <Table striped>
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead className="nx:text-right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.slice(0, 4).map((row, i) => (
          <TableRow
            key={row.invoice}
            data-state={i === 1 ? 'selected' : undefined}
          >
            <TableRowHeader>{row.invoice}</TableRowHeader>
            <TableCell>{row.status}</TableCell>
            <TableCell className="nx:text-right">{row.amount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

Sticky header

Set stickyHeader and a max height so the header stays visible while rows scroll.

InvoiceStatusMethodAmount
INV001PaidCredit Card$250.00
INV002PendingPayPal$150.00
INV003UnpaidBank Transfer$350.00
INV004PaidCredit Card$450.00
INV005PaidPayPal$550.00
INV001PaidCredit Card$250.00
INV002PendingPayPal$150.00
INV003UnpaidBank Transfer$350.00
INV004PaidCredit Card$450.00
INV005PaidPayPal$550.00
INV001PaidCredit Card$250.00
INV002PendingPayPal$150.00
INV003UnpaidBank Transfer$350.00
INV004PaidCredit Card$450.00
INV005PaidPayPal$550.00
'use client';

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/nxds/components/table/table';

const invoices = [
  {
    invoice: 'INV001',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$250.00',
  },
  { invoice: 'INV002', status: 'Pending', method: 'PayPal', amount: '$150.00' },
  {
    invoice: 'INV003',
    status: 'Unpaid',
    method: 'Bank Transfer',
    amount: '$350.00',
  },
  {
    invoice: 'INV004',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$450.00',
  },
  { invoice: 'INV005', status: 'Paid', method: 'PayPal', amount: '$550.00' },
];

export default function TableStickyHeader() {
  return (
    <Table stickyHeader containerClassName="nx:max-h-64">
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead>Method</TableHead>
          <TableHead className="nx:text-right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {[...invoices, ...invoices, ...invoices].map((row, i) => (
          <TableRow key={`${row.invoice}-${i}`}>
            <TableCell>{row.invoice}</TableCell>
            <TableCell>{row.status}</TableCell>
            <TableCell>{row.method}</TableCell>
            <TableCell className="nx:text-right">{row.amount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

Sortable header

Put a button in a TableHead and set aria-sort from your sort state.

Invoice
INV002$150.00
INV001$250.00
INV003$350.00
INV004$450.00
INV005$550.00
'use client';

import { useState } from 'react';

import { CaretDownIcon, CaretUpIcon } from '@phosphor-icons/react/ssr';

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
  TableRowHeader,
} from '@/components/nxds/components/table/table';

const invoices = [
  {
    invoice: 'INV001',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$250.00',
  },
  { invoice: 'INV002', status: 'Pending', method: 'PayPal', amount: '$150.00' },
  {
    invoice: 'INV003',
    status: 'Unpaid',
    method: 'Bank Transfer',
    amount: '$350.00',
  },
  {
    invoice: 'INV004',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$450.00',
  },
  { invoice: 'INV005', status: 'Paid', method: 'PayPal', amount: '$550.00' },
];

function SortableHeaderDemo() {
  const [sort, setSort] = useState<'asc' | 'desc'>('asc');
  const ariaSort = sort === 'asc' ? 'ascending' : 'descending';
  const Icon = sort === 'asc' ? CaretUpIcon : CaretDownIcon;
  const rows = [...invoices].sort((a, b) =>
    sort === 'asc'
      ? a.amount.localeCompare(b.amount)
      : b.amount.localeCompare(a.amount)
  );
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead aria-sort={ariaSort} className="nx:text-right">
            <button
              type="button"
              onClick={() => setSort((s) => (s === 'asc' ? 'desc' : 'asc'))}
              className="nx:ml-auto nx:inline-flex nx:items-center nx:gap-1 nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default"
            >
              Amount
              <Icon className="nx:size-4" aria-hidden />
            </button>
          </TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {rows.map((row) => (
          <TableRow key={row.invoice}>
            <TableRowHeader>{row.invoice}</TableRowHeader>
            <TableCell className="nx:text-right nx:tabular-nums">
              {row.amount}
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

export default function TableSortableHeader() {
  return <SortableHeaderDemo />;
}

This example also needs:

npm install @phosphor-icons/react@^2.1.10

Props

Table

Table props
PropTypeDefaultDescription
containerClassNamestringNo default documented

Classes for the scroll container, such as a max height. className goes on the <table>.

density"comfortable" | "compact""comfortable"

Row height.

  • comfortable: about 44px rows.
  • compact: about 36px rows, for dense data.
stickyHeaderbooleanfalse

Keeps the header row in view while the body scrolls. Set a max height with containerClassName, such as "nx:max-h-96".

stripedbooleanfalse

Tints every other body row. Hover and selection colors win over the stripe.

variant"default" | "grid" | "borderless""default"

Lines inside the table.

  • default: row lines and a header underline.
  • borderless: no lines.
  • grid: row and column lines.

TableBody

TableBody adds no props of its own; it accepts the props of the element or primitive it wraps.

TableCaption

TableCaption adds no props of its own; it accepts the props of the element or primitive it wraps.

TableCell

TableCell adds no props of its own; it accepts the props of the element or primitive it wraps.

TableFooter adds no props of its own; it accepts the props of the element or primitive it wraps.

TableHead

TableHead adds no props of its own; it accepts the props of the element or primitive it wraps.

TableHeader

TableHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

TableRow

TableRow adds no props of its own; it accepts the props of the element or primitive it wraps.

TableRowHeader

TableRowHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

TableSelectionCell

TableSelectionCell adds no props of its own; it accepts the props of the element or primitive it wraps.

TableSelectionHead

TableSelectionHead adds no props of its own; it accepts the props of the element or primitive it wraps.

Table shows rows of data in columns. Use it for records users scan and compare, such as invoices. Sorting, selection and pagination logic stay in your app.

<script setup lang="ts">
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/table';

const invoices = [
  {
    invoice: 'INV001',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$250.00',
  },
  {
    invoice: 'INV002',
    status: 'Pending',
    method: 'PayPal',
    amount: '$150.00',
  },
  {
    invoice: 'INV003',
    status: 'Unpaid',
    method: 'Bank Transfer',
    amount: '$350.00',
  },
  {
    invoice: 'INV004',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$450.00',
  },
  {
    invoice: 'INV005',
    status: 'Paid',
    method: 'PayPal',
    amount: '$550.00',
  },
];
</script>

<template>
  <Table>
    <TableCaption>A list of your recent invoices.</TableCaption>
    <TableHeader>
      <TableRow>
        <TableHead>Invoice</TableHead>
        <TableHead>Status</TableHead>
        <TableHead>Method</TableHead>
        <TableHead class="nx:text-right">Amount</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      <TableRow v-for="row in invoices" :key="row.invoice">
        <TableCell class="nx:font-medium">{{ row.invoice }}</TableCell>
        <TableCell>{{ row.status }}</TableCell>
        <TableCell>{{ row.method }}</TableCell>
        <TableCell class="nx:text-right">{{ row.amount }}</TableCell>
      </TableRow>
    </TableBody>
  </Table>
</template>

Installation

  1. Install the following dependencies:

    npm install class-variance-authority@^0.7.1
    
  2. Copy and paste the following code into your project.

    components/table/Table.vue
    <script setup lang="ts">
    import { type HTMLAttributes, provide } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { TableKey } from '.';
    
    defineOptions({ inheritAttrs: false });
    
    export interface TableProps {
      /**
       * Lines inside the table.
       *
       * - `default`: row lines and a header underline.
       * - `borderless`: no lines.
       * - `grid`: row and column lines.
       */
      variant?: 'default' | 'borderless' | 'grid';
      /**
       * Row height.
       *
       * - `comfortable`: about 44px rows.
       * - `compact`: about 36px rows, for dense data.
       */
      density?: 'comfortable' | 'compact';
      /**
       * Keeps the header row in view while the body scrolls. Set a max height with
       * `containerClassName`, such as `"nx:max-h-96"`.
       */
      stickyHeader?: boolean;
      /**
       * Tints every other body row. Hover and selection colors win over the stripe.
       */
      striped?: boolean;
      /**
       * Classes for the scroll container, such as a max height. `className` goes on
       * the `<table>`.
       */
      containerClassName?: string;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<TableProps>(), {
      variant: 'default',
      density: 'comfortable',
      stickyHeader: false,
      striped: false,
    });
    
    provide(TableKey, props);
    </script>
    
    <template>
      <div
        data-slot="table-container"
        tabindex="0"
        :class="
          cn(
            'nx:w-full nx:overflow-x-auto nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default nx:focus-visible:[outline-offset:-2px]',
            stickyHeader && 'nx:overflow-y-auto',
            containerClassName
          )
        "
      >
        <!-- A wide table overflows horizontally and holds no focusable children, so the container takes tabindex="0" to scroll into view (WCAG 2.1.1). -->
        <table
          v-bind="$attrs"
          data-slot="table"
          :data-variant="variant"
          :data-table-density="density"
          :data-sticky-header="stickyHeader || undefined"
          :data-striped="striped || undefined"
          :class="
            cn(
              'nx:w-full nx:caption-bottom nx:border-collapse nx:typography-body-default nx:[&[data-striped]_tbody_tr:nth-child(even):not(:hover):not([data-state=selected])]:bg-muted nx:[&[data-striped]_tfoot]:border-t-default nx:[&[data-striped]_tfoot]:border-border-default-alpha',
              props.class
            )
          "
        >
          <slot />
        </table>
      </div>
    </template>
    
    components/table/TableBody.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface TableBodyProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<TableBodyProps>();
    </script>
    
    <template>
      <tbody
        data-slot="table-body"
        :class="cn('nx:[&_tr:last-child]:border-0', props.class)"
      >
        <slot />
      </tbody>
    </template>
    
    components/table/TableCaption.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface TableCaptionProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<TableCaptionProps>();
    </script>
    
    <template>
      <caption
        data-slot="table-caption"
        :class="
          cn(
            'nx:mt-4 nx:typography-body-small nx:text-muted-foreground',
            props.class
          )
        "
      >
        <slot />
      </caption>
    </template>
    
    components/table/TableCell.vue
    <script setup lang="ts">
    import { type HTMLAttributes, inject } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { tableCellVariants, tableContextDefaults, TableKey } from '.';
    
    export interface TableCellProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<TableCellProps>();
    
    const table = inject(TableKey, tableContextDefaults);
    </script>
    
    <template>
      <td
        data-slot="table-cell"
        :class="
          cn(
            tableCellVariants({ variant: table.variant, density: table.density }),
            props.class
          )
        "
      >
        <slot />
      </td>
    </template>
    
    components/table/TableFooter.vue
    <script setup lang="ts">
    import { type HTMLAttributes, inject } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { tableContextDefaults, tableFooterVariants, TableKey } from '.';
    
    export interface TableFooterProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<TableFooterProps>();
    
    const table = inject(TableKey, tableContextDefaults);
    </script>
    
    <template>
      <tfoot
        data-slot="table-footer"
        :class="cn(tableFooterVariants({ variant: table.variant }), props.class)"
      >
        <slot />
      </tfoot>
    </template>
    
    components/table/TableHead.vue
    <script setup lang="ts">
    import { type HTMLAttributes, inject } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { tableContextDefaults, tableHeadVariants, TableKey } from '.';
    
    export interface TableHeadProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<TableHeadProps>();
    
    const table = inject(TableKey, tableContextDefaults);
    </script>
    
    <template>
      <th
        scope="col"
        data-slot="table-head"
        :class="
          cn(
            tableHeadVariants({ variant: table.variant, density: table.density }),
            table.stickyHeader && 'nx:sticky nx:top-0 nx:z-sticky nx:bg-container',
            props.class
          )
        "
      >
        <slot />
      </th>
    </template>
    
    components/table/TableHeader.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    export interface TableHeaderProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<TableHeaderProps>();
    </script>
    
    <template>
      <thead data-slot="table-header" :class="props.class">
        <slot />
      </thead>
    </template>
    
    components/table/TableRow.vue
    <script setup lang="ts">
    import { type HTMLAttributes, inject } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { tableContextDefaults, TableKey, tableRowVariants } from '.';
    
    export interface TableRowProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<TableRowProps>();
    
    const table = inject(TableKey, tableContextDefaults);
    </script>
    
    <template>
      <tr
        data-slot="table-row"
        :class="cn(tableRowVariants({ variant: table.variant }), props.class)"
      >
        <slot />
      </tr>
    </template>
    
    components/table/TableRowHeader.vue
    <script setup lang="ts">
    import { type HTMLAttributes, inject } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { tableCellVariants, tableContextDefaults, TableKey } from '.';
    
    export interface TableRowHeaderProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<TableRowHeaderProps>();
    
    const table = inject(TableKey, tableContextDefaults);
    </script>
    
    <template>
      <th
        scope="row"
        data-slot="table-row-header"
        :class="
          cn(
            tableCellVariants({ variant: table.variant, density: table.density }),
            'nx:typography-label-default',
            props.class
          )
        "
      >
        <slot />
      </th>
    </template>
    
    components/table/TableSelectionCell.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import TableCell from './TableCell.vue';
    
    import { tableSelectionCellClassName } from '.';
    
    export interface TableSelectionCellProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<TableSelectionCellProps>();
    </script>
    
    <template>
      <TableCell
        data-slot="table-selection-cell"
        :class="cn(tableSelectionCellClassName, props.class)"
      >
        <slot />
      </TableCell>
    </template>
    
    components/table/TableSelectionHead.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import TableHead from './TableHead.vue';
    
    import { tableSelectionCellClassName } from '.';
    
    export interface TableSelectionHeadProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<TableSelectionHeadProps>();
    </script>
    
    <template>
      <TableHead
        data-slot="table-selection-head"
        :class="cn(tableSelectionCellClassName, props.class)"
      >
        <slot />
      </TableHead>
    </template>
    
    components/table/index.ts
    import { cva } from 'class-variance-authority';
    import type { InjectionKey } from 'vue';
    
    export { default as Table, type TableProps } from './Table.vue';
    export { default as TableBody, type TableBodyProps } from './TableBody.vue';
    export {
      default as TableCaption,
      type TableCaptionProps,
    } from './TableCaption.vue';
    export { default as TableCell, type TableCellProps } from './TableCell.vue';
    export {
      default as TableFooter,
      type TableFooterProps,
    } from './TableFooter.vue';
    export { default as TableHead, type TableHeadProps } from './TableHead.vue';
    export {
      default as TableHeader,
      type TableHeaderProps,
    } from './TableHeader.vue';
    export { default as TableRow, type TableRowProps } from './TableRow.vue';
    export {
      default as TableRowHeader,
      type TableRowHeaderProps,
    } from './TableRowHeader.vue';
    export {
      default as TableSelectionCell,
      type TableSelectionCellProps,
    } from './TableSelectionCell.vue';
    export {
      default as TableSelectionHead,
      type TableSelectionHeadProps,
    } from './TableSelectionHead.vue';
    
    export type TableVariant = 'default' | 'borderless' | 'grid';
    export type TableDensity = 'comfortable' | 'compact';
    
    interface TableContext {
      variant: TableVariant;
      density: TableDensity;
      stickyHeader: boolean;
    }
    
    export const TableKey: InjectionKey<TableContext> = Symbol('table');
    
    export const tableContextDefaults: TableContext = {
      variant: 'default',
      density: 'comfortable',
      stickyHeader: false,
    };
    
    export const tableFooterVariants = cva(
      'nx:bg-muted nx:typography-label-default nx:[&>tr]:last:border-b-0',
      {
        variants: {
          variant: {
            default: 'nx:border-t-default nx:border-border-default-alpha',
            borderless: '',
            grid: 'nx:border-t-default nx:border-border-default-alpha',
          } satisfies Record<TableVariant, string>,
        },
        defaultVariants: { variant: 'default' },
      }
    );
    
    export const tableRowVariants = cva(
      'nx:transition-colors nx:hover:bg-background-hover nx:data-[state=selected]:bg-control-background nx:data-[state=selected]:hover:bg-control-background-hover',
      {
        variants: {
          variant: {
            default: 'nx:border-b-default nx:border-border-default-alpha',
            borderless: '',
            grid: 'nx:border-b-default nx:border-border-default-alpha',
          } satisfies Record<TableVariant, string>,
        },
        defaultVariants: { variant: 'default' },
      }
    );
    
    export const tableHeadVariants = cva(
      'nx:px-2 nx:text-start nx:align-middle nx:typography-label-default nx:whitespace-nowrap nx:text-muted-foreground nx:[&[aria-sort=ascending]]:text-foreground nx:[&[aria-sort=descending]]:text-foreground',
      {
        variants: {
          variant: {
            default: '',
            borderless: '',
            grid: 'nx:border-e-default nx:border-border-default-alpha nx:last:border-e-0',
          } satisfies Record<TableVariant, string>,
          density: {
            comfortable: 'nx:py-3',
            compact: 'nx:py-2.5',
          } satisfies Record<TableDensity, string>,
        },
        defaultVariants: {
          variant: 'default',
          density: 'comfortable',
        },
      }
    );
    
    export const tableCellVariants = cva(
      'nx:px-2 nx:align-middle nx:whitespace-nowrap',
      {
        variants: {
          variant: {
            default: '',
            borderless: '',
            grid: 'nx:border-e-default nx:border-border-default-alpha nx:last:border-e-0',
          } satisfies Record<TableVariant, string>,
          density: {
            comfortable: 'nx:py-3',
            compact: 'nx:py-2',
          } satisfies Record<TableDensity, string>,
        },
        defaultVariants: { variant: 'default', density: 'comfortable' },
      }
    );
    
    export const tableSelectionCellClassName =
      'nx:w-0 nx:pe-0 nx:*:[[role=checkbox]]:translate-y-0.5';
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, TableRowHeader, TableSelectionCell, TableSelectionHead } from '@/components/table';

Examples

Add a TableFooter row for totals, on a muted fill.

<script setup lang="ts">
import {
  Table,
  TableBody,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/table';

const invoices = [
  { invoice: 'INV001', status: 'Paid', amount: '$250.00' },
  { invoice: 'INV002', status: 'Pending', amount: '$150.00' },
  { invoice: 'INV003', status: 'Unpaid', amount: '$350.00' },
  { invoice: 'INV004', status: 'Paid', amount: '$450.00' },
  { invoice: 'INV005', status: 'Paid', amount: '$550.00' },
];
</script>

<template>
  <Table>
    <TableHeader>
      <TableRow>
        <TableHead>Invoice</TableHead>
        <TableHead>Status</TableHead>
        <TableHead class="nx:text-right">Amount</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      <TableRow v-for="row in invoices" :key="row.invoice">
        <TableCell class="nx:font-medium">{{ row.invoice }}</TableCell>
        <TableCell>{{ row.status }}</TableCell>
        <TableCell class="nx:text-right">{{ row.amount }}</TableCell>
      </TableRow>
    </TableBody>
    <TableFooter>
      <TableRow>
        <TableCell :colspan="2">Total</TableCell>
        <TableCell class="nx:text-right">$1,750.00</TableCell>
      </TableRow>
    </TableFooter>
  </Table>
</template>

Selectable rows

Put a Checkbox in each row with TableSelectionHead and TableSelectionCell.

<script setup lang="ts">
import { ref } from 'vue';

import { Checkbox } from '@/components/checkbox';
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
  TableRowHeader,
  TableSelectionCell,
  TableSelectionHead,
} from '@/components/table';

const rows = [
  { id: 'INV001', status: 'Paid', amount: '$250.00' },
  { id: 'INV002', status: 'Pending', amount: '$150.00' },
  { id: 'INV003', status: 'Unpaid', amount: '$350.00' },
  { id: 'INV004', status: 'Paid', amount: '$450.00' },
  { id: 'INV005', status: 'Paid', amount: '$550.00' },
];
const selected = ref(['INV001', 'INV003']);
</script>

<template>
  <Table>
    <TableCaption>Recent invoices with row selection.</TableCaption>
    <TableHeader>
      <TableRow>
        <TableSelectionHead>
          <Checkbox
            :model-value="
              selected.length === rows.length
                ? true
                : selected.length > 0
                  ? 'indeterminate'
                  : false
            "
            aria-label="Select all rows"
            @update:model-value="
              selected = $event === true ? rows.map((row) => row.id) : []
            "
          />
        </TableSelectionHead>
        <TableHead>Invoice</TableHead>
        <TableHead>Status</TableHead>
        <TableHead class="nx:text-end">Amount</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      <TableRow
        v-for="row in rows"
        :key="row.id"
        :data-state="selected.includes(row.id) ? 'selected' : undefined"
      >
        <TableSelectionCell>
          <Checkbox
            :model-value="selected.includes(row.id)"
            :aria-label="'Select ' + row.id"
            @update:model-value="
              selected = $event
                ? [...selected, row.id]
                : selected.filter((id) => id !== row.id)
            "
          />
        </TableSelectionCell>
        <TableRowHeader>{{ row.id }}</TableRowHeader>
        <TableCell>{{ row.status }}</TableCell>
        <TableCell class="nx:text-end nx:tabular-nums">{{
          row.amount
        }}</TableCell>
      </TableRow>
    </TableBody>
  </Table>
</template>

This example also needs: Checkbox.

With actions

Add a trailing column of row actions with text buttons.

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/table';

const invoices = [
  { invoice: 'INV001', status: 'Paid' },
  { invoice: 'INV002', status: 'Pending' },
  { invoice: 'INV003', status: 'Unpaid' },
];
</script>

<template>
  <Table>
    <TableHeader>
      <TableRow>
        <TableHead>Invoice</TableHead>
        <TableHead>Status</TableHead>
        <TableHead class="nx:text-right">Actions</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      <TableRow v-for="row in invoices" :key="row.invoice">
        <TableCell class="nx:font-medium">{{ row.invoice }}</TableCell>
        <TableCell>{{ row.status }}</TableCell>
        <TableCell>
          <div class="nx:flex nx:justify-end nx:gap-2">
            <Button variant="ghost" size="sm">Edit</Button>
            <Button variant="destructive" size="sm">Delete</Button>
          </div>
        </TableCell>
      </TableRow>
    </TableBody>
  </Table>
</template>

This example also needs: Button.

Striped

Set striped to tint every other row, with hover and selection on top.

<script setup lang="ts">
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
  TableRowHeader,
} from '@/components/table';

const invoices = [
  { invoice: 'INV001', status: 'Paid', amount: '$250.00' },
  { invoice: 'INV002', status: 'Pending', amount: '$150.00' },
  { invoice: 'INV003', status: 'Unpaid', amount: '$350.00' },
  { invoice: 'INV004', status: 'Paid', amount: '$450.00' },
];
</script>

<template>
  <Table striped>
    <TableHeader>
      <TableRow>
        <TableHead>Invoice</TableHead>
        <TableHead>Status</TableHead>
        <TableHead class="nx:text-right">Amount</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      <TableRow
        v-for="(row, i) in invoices"
        :key="row.invoice"
        :data-state="i === 1 ? 'selected' : undefined"
      >
        <TableRowHeader>{{ row.invoice }}</TableRowHeader>
        <TableCell>{{ row.status }}</TableCell>
        <TableCell class="nx:text-right">{{ row.amount }}</TableCell>
      </TableRow>
    </TableBody>
  </Table>
</template>

Sticky header

Set sticky-header and a max height so the header stays visible while rows scroll.

<script setup lang="ts">
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/table';

const invoices = [
  {
    invoice: 'INV001',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$250.00',
  },
  {
    invoice: 'INV002',
    status: 'Pending',
    method: 'PayPal',
    amount: '$150.00',
  },
  {
    invoice: 'INV003',
    status: 'Unpaid',
    method: 'Bank Transfer',
    amount: '$350.00',
  },
  {
    invoice: 'INV004',
    status: 'Paid',
    method: 'Credit Card',
    amount: '$450.00',
  },
  {
    invoice: 'INV005',
    status: 'Paid',
    method: 'PayPal',
    amount: '$550.00',
  },
];
</script>

<template>
  <Table sticky-header container-class-name="nx:max-h-64">
    <TableHeader>
      <TableRow>
        <TableHead>Invoice</TableHead>
        <TableHead>Status</TableHead>
        <TableHead>Method</TableHead>
        <TableHead class="nx:text-right">Amount</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      <TableRow
        v-for="(row, i) in [...invoices, ...invoices, ...invoices]"
        :key="row.invoice + '-' + i"
      >
        <TableCell>{{ row.invoice }}</TableCell>
        <TableCell>{{ row.status }}</TableCell>
        <TableCell>{{ row.method }}</TableCell>
        <TableCell class="nx:text-right">{{ row.amount }}</TableCell>
      </TableRow>
    </TableBody>
  </Table>
</template>

Sortable header

Put a button in a TableHead and set aria-sort from your sort state.

<script setup lang="ts">
import { PhCaretDown, PhCaretUp } from '@phosphor-icons/vue';
import { ref } from 'vue';

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
  TableRowHeader,
} from '@/components/table';

const invoices = [
  { invoice: 'INV001', amount: '$250.00' },
  { invoice: 'INV002', amount: '$150.00' },
  { invoice: 'INV003', amount: '$350.00' },
  { invoice: 'INV004', amount: '$450.00' },
  { invoice: 'INV005', amount: '$550.00' },
];
const sort = ref<'asc' | 'desc'>('asc');
</script>

<template>
  <Table>
    <TableHeader>
      <TableRow>
        <TableHead>Invoice</TableHead>
        <TableHead
          :aria-sort="sort === 'asc' ? 'ascending' : 'descending'"
          class="nx:text-right"
        >
          <button
            type="button"
            class="nx:ml-auto nx:inline-flex nx:items-center nx:gap-1 nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default"
            @click="sort = sort === 'asc' ? 'desc' : 'asc'"
          >
            Amount<component
              :is="sort === 'asc' ? PhCaretUp : PhCaretDown"
              class="nx:size-4"
              aria-hidden="true"
            />
          </button>
        </TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      <TableRow
        v-for="row in [...invoices].sort((a, b) =>
          sort === 'asc'
            ? a.amount.localeCompare(b.amount)
            : b.amount.localeCompare(a.amount)
        )"
        :key="row.invoice"
      >
        <TableRowHeader>{{ row.invoice }}</TableRowHeader>
        <TableCell class="nx:text-right nx:tabular-nums">{{
          row.amount
        }}</TableCell>
      </TableRow>
    </TableBody>
  </Table>
</template>

This example also needs:

npm install @phosphor-icons/vue@^2.2.1

Props

Table

Table props
PropTypeDefaultDescription
containerClassNamestringNo default documented

Classes for the scroll container, such as a max height. className goes on the <table>.

density"comfortable" | "compact""comfortable"

Row height.

  • comfortable: about 44px rows.
  • compact: about 36px rows, for dense data.
stickyHeaderbooleanfalse

Keeps the header row in view while the body scrolls. Set a max height with containerClassName, such as "nx:max-h-96".

stripedbooleanfalse

Tints every other body row. Hover and selection colors win over the stripe.

variant"default" | "borderless" | "grid""default"

Lines inside the table.

  • default: row lines and a header underline.
  • borderless: no lines.
  • grid: row and column lines.

TableBody

TableBody adds no props of its own; it accepts the props of the element or primitive it wraps.

TableCaption

TableCaption adds no props of its own; it accepts the props of the element or primitive it wraps.

TableCell

TableCell adds no props of its own; it accepts the props of the element or primitive it wraps.

TableFooter adds no props of its own; it accepts the props of the element or primitive it wraps.

TableHead

TableHead adds no props of its own; it accepts the props of the element or primitive it wraps.

TableHeader

TableHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

TableRow

TableRow adds no props of its own; it accepts the props of the element or primitive it wraps.

TableRowHeader

TableRowHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

TableSelectionCell

TableSelectionCell adds no props of its own; it accepts the props of the element or primitive it wraps.

TableSelectionHead

TableSelectionHead adds no props of its own; it accepts the props of the element or primitive it wraps.