Beta Preview

Table

A table displays data in rows and columns and enables a user to navigate its contents via directional navigation keys, and optionally supports row selection and sorting.

Theme
GamesFile folder6/7/2020
Program FilesFile folder4/7/2021
bootmgrSystem file11/20/2010
log.txtText Document1/18/2016
selectionMode 
Example
Table.tsx
Table.css
import {Table, TableHeader, Column, Row, TableBody, Cell} from './Table';

<Table
  aria-label="Files"
  selectionMode="multiple">
  <TableHeader>
    <Column isRowHeader>Name</Column>
    <Column>Type</Column>
    <Column>Date Modified</Column>
  </TableHeader>
  <TableBody>
    <Row>
      <Cell>Games</Cell>
      <Cell>File folder</Cell>
      <Cell>6/7/2020</Cell>
    </Row>
    <Row>
      <Cell>Program Files</Cell>
      <Cell>File folder</Cell>
      <Cell>4/7/2021</Cell>
    </Row>
    <Row>
      <Cell>bootmgr</Cell>
      <Cell>System file</Cell>
      <Cell>11/20/2010</Cell>
    </Row>
    <Row>
      <Cell>log.txt</Cell>
      <Cell>Text Document</Cell>
      <Cell>1/18/2016</Cell>
    </Row>
  </TableBody>
</Table>

Content

Table follows the Collection Components API, accepting both static and dynamic collections. In this example, both the columns and the rows are provided to the table via a render function, enabling the user to hide and show columns and add additional rows.

GamesFile folder6/7/2020
Program FilesFile folder4/7/2021
bootmgrSystem file11/20/2010
log.txtText Document1/18/2016
import {Table, TableHeader, Column, Row, TableBody, Cell} from './Table';
import {CheckboxGroup} from './CheckboxGroup';
import {Checkbox} from './Checkbox';
import {Button} from './Button';
import {useState} from 'react';

function FileTable() { let [showColumns, setShowColumns] = useState(['name', 'type', 'date']); let visibleColumns = columns.filter(column => showColumns.includes(column.id)); let [rows, setRows] = useState(initialRows); let addRow = () => { let date = new Date().toLocaleDateString(); setRows(rows => [ ...rows, {id: rows.length + 1, name: 'file.txt', date, type: 'Text Document'} ]); }; return ( <div style={{display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'start', width: '100%'}}> <CheckboxGroup aria-label="Show columns" value={showColumns} onChange={setShowColumns} orientation="horizontal"> <Checkbox value="type">Type</Checkbox> <Checkbox value="date">Date Modified</Checkbox> </CheckboxGroup> <Table aria-label="Files" style={{width: '100%'}}> <TableHeader columns={visibleColumns}> {column => ( <Column isRowHeader={column.isRowHeader}> {column.name}
</Column> )} </TableHeader> <TableBody items={rows} dependencies={[visibleColumns]}> {item => ( <Row columns={visibleColumns}> {column => <Cell>{item[column.id]}</Cell>} </Row> )} </TableBody> </Table> <Button onPress={addRow}>Add row</Button> </div> ); }

Asynchronous loading

Use renderEmptyState to display a spinner during initial load. To enable infinite scrolling, render a <TableLoadMoreItem> at the end of the list. Use whatever data fetching library you prefer – this example uses useAsyncList from react-stately.

import {Table, TableHeader, Column, Row, TableBody, Cell, TableLoadMoreItem} from './Table';
import {Collection} from 'react-aria-components';
import {ProgressCircle} from './ProgressCircle';
import {useAsyncList} from 'react-stately';

interface Character {
  name: string;
  height: number;
  mass: number;
  birth_year: number;
}

function AsyncSortTable() {
  let list = useAsyncList<Character>({
    async load({ signal, cursor }) {
      if (cursor) {
        cursor = cursor.replace(/^http:\/\//i, 'https://');
      }

      let res = await fetch(
        cursor || 'https://swapi.py4e.com/api/people/?search=',
        { signal }
      );
      let json = await res.json();

      return {
        items: json.results,
        cursor: json.next
      };
    }
  });

  return (
    <div
      style={{
        height: 150,
        overflow: 'auto',
        border: '0.5px solid var(--border-color)',
        borderRadius: 'var(--radius)'
      }}>
      <Table
        aria-label="Star Wars characters"
        style={{tableLayout: 'fixed', width: '100%', border: 0}}>
        <TableHeader
          style={{
            position: 'sticky',
            top: 0,
            background: 'var(--overlay-background)',
            zIndex: 1
          }}>
          <Column id="name" isRowHeader>Name</Column>
          <Column id="height">Height</Column>
          <Column id="mass">Mass</Column>
          <Column id="birth_year">Birth Year</Column>
        </TableHeader>
        <TableBody
          renderEmptyState={() => (
            <ProgressCircle isIndeterminate aria-label="Loading..." />
          )}>
          <Collection items={list.items}>
            {(item) => (
              <Row id={item.name}>
                <Cell>{item.name}</Cell>
                <Cell>{item.height}</Cell>
                <Cell>{item.mass}</Cell>
                <Cell>{item.birth_year}</Cell>
              </Row>
            )}
          </Collection>
          <TableLoadMoreItem
            onLoadMore={list.loadMore}
            isLoading={list.loadingState === 'loadingMore'} />
        </TableBody>
      </Table>
    </div>
  );
}

Use the href prop on a <Row> to create a link. See the client side routing guide to learn how to integrate with your framework. Link interactions vary depending on the selection behavior. See the selection guide for more details.

Adobehttps://adobe.com/January 28, 2023
Googlehttps://google.com/April 5, 2023
New York Timeshttps://nytimes.com/July 12, 2023
selectionBehavior 
import {Table, TableHeader, Column, Row, TableBody, Cell} from './Table';

<Table
  aria-label="Bookmarks"
  selectionMode="multiple">
  <TableHeader>
    <Column isRowHeader>Name</Column>
    <Column>URL</Column>
    <Column>Date added</Column>
  </TableHeader>
  <TableBody>
    <Row href="https://adobe.com/" target="_blank">
      <Cell>Adobe</Cell>
      <Cell>https://adobe.com/</Cell>
      <Cell>January 28, 2023</Cell>
    </Row>
    <Row href="https://google.com/" target="_blank">
      <Cell>Google</Cell>
      <Cell>https://google.com/</Cell>
      <Cell>April 5, 2023</Cell>
    </Row>
    <Row href="https://nytimes.com/" target="_blank">
      <Cell>New York Times</Cell>
      <Cell>https://nytimes.com/</Cell>
      <Cell>July 12, 2023</Cell>
    </Row>
  </TableBody>
</Table>

Empty state

No results found.
import {Table, TableHeader, Column, Row, TableBody, Cell} from './Table';

<Table aria-label="Search results">
  <TableHeader>
    <Column isRowHeader>Name</Column>
    <Column>Type</Column>
    <Column>Date Modified</Column>
  </TableHeader>
  <TableBody renderEmptyState={() => 'No results found.'}>
    {[]}
  </TableBody>
</Table>

Selection and actions

Use the selectionMode prop to enable single or multiple selection. The selected rows can be controlled via the selectedKeys prop, matching the id prop of the rows. The onAction event handles item actions. Rows can be disabled with the isDisabled prop. See the selection guide for more details.

CharizardFire, Flying67
BlastoiseWater56
VenusaurGrass, Poison83
PikachuElectric100

Current selection:

selectionMode 
selectionBehavior 
disabledBehavior 
disallowEmptySelection 
import type {Selection} from 'react-aria-components';
import {Table, TableHeader, Column, Row, TableBody, Cell} from './Table';
import {useState} from 'react';

function Example(props) {
  let [selected, setSelected] = useState<Selection>(new Set());

  return (
    <>
      <Table
        {...props}
        aria-label="Favorite pokemon"
        selectionMode="multiple"
        selectedKeys={selected}
        onSelectionChange={setSelected}
        onAction={key => alert(`Clicked ${key}`)}
      >
        <TableHeader>
          <Column isRowHeader>Name</Column>
          <Column>Type</Column>
          <Column>Level</Column>
        </TableHeader>
        <TableBody>
          <Row id="charizard">
            <Cell>Charizard</Cell>
            <Cell>Fire, Flying</Cell>
            <Cell>67</Cell>
          </Row>
          <Row id="blastoise">
            <Cell>Blastoise</Cell>
            <Cell>Water</Cell>
            <Cell>56</Cell>
          </Row>
          <Row id="venusaur" isDisabled>
            <Cell>Venusaur</Cell>
            <Cell>Grass, Poison</Cell>
            <Cell>83</Cell>
          </Row>
          <Row id="pikachu">
            <Cell>Pikachu</Cell>
            <Cell>Electric</Cell>
            <Cell>100</Cell>
          </Row>
        </TableBody>
      </Table>
      <p>Current selection: {selected === 'all' ? 'all' : [...selected].join(', ')}</p>
    </>
  );
}

Sorting

Set the allowsSorting prop on a <Column> to make it sortable. When the column header is pressed, onSortChange is called with a including the sorted column and direction (ascending or descending). Use this to sort the data accordingly, and pass the sortDescriptor prop to the <Table> to display the sorted column.

BlastoiseWater56
CharizardFire, Flying67
PikachuElectric100
VenusaurGrass, Poison83
import {type SortDescriptor} from 'react-aria-components';
import {Table, TableHeader, Column, TableBody, Row, Cell} from './Table';
import {useState} from 'react';

function SortableTable() { let [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'name', direction: 'ascending' }); let sortedRows = rows; if (sortDescriptor) { sortedRows = rows.toSorted((a, b) => { let first = a[sortDescriptor.column]; let second = b[sortDescriptor.column]; let cmp = first < second ? -1 : 1; if (sortDescriptor.direction === 'descending') { cmp = -cmp; } return cmp; }); }
return ( <Table aria-label="Favorite pokemon" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor} > <TableHeader> <Column id="name" isRowHeader allowsSorting>Name</Column> <Column id="type" allowsSorting>Type</Column> <Column id="level" allowsSorting>Level</Column> </TableHeader> <TableBody items={sortedRows}> {item => ( <Row> <Cell>{item.name}</Cell> <Cell>{item.type}</Cell> <Cell>{item.level}</Cell> </Row> )} </TableBody> </Table> ); }

Column resizing

Wrap the <Table> with a <ResizableTableContainer>, and add a <ColumnResizer> to each column to make it resizable. Use the defaultWidth, width, minWidth, and maxWidth props on a <Column> to control resizing behavior. These accept pixels, percentages, or fractional values (the fr unit). The default column width is 1fr.

2022 Roadmap Proposal Revision 012822 Copy (2)214 KBNovember 27, 2022 at 4:56PM
Budget14 MBJanuary 27, 2021 at 1:56AM
Welcome Email Template20 KBJuly 24, 2022 at 2:48 PM
Job Posting_8301139 KBMay 30, 2025
import {Table, TableHeader, Column, Row, TableBody, Cell} from './Table';
import {ResizableTableContainer} from 'react-aria-components';

<ResizableTableContainer>
<Table aria-label="Table with resizable columns"> <TableHeader> <Column id="file" isRowHeader allowsResizing maxWidth={500}>File Name</Column> <Column id="size" allowsResizing defaultWidth={80}>Size</Column> <Column id="date" minWidth={100}>Date Modified</Column> </TableHeader> <TableBody items={rows}> {item => ( <Row> <Cell>{item.name}</Cell> <Cell>{item.size}</Cell> <Cell>{item.date}</Cell> </Row> )} </TableBody> </Table> </ResizableTableContainer>

Resize events

The ResizableTableContainer's onResize event is called when a column resizer is moved by the user. The onResizeEnd event is called when the user finishes resizing. These receive a Map containing the widths of all columns in the Table. This example persists the column widths in localStorage.

2022 Roadmap Proposal Revision 012822 Copy (2)214 KBNovember 27, 2022 at 4:56PM
Budget14 MBJanuary 27, 2021 at 1:56AM
Welcome Email Template20 KBJuly 24, 2022 at 2:48 PM
Job Posting_8301139 KBMay 30, 2025
import {Table, TableHeader, Column, Row, TableBody, Cell} from './Table';
import {ResizableTableContainer} from 'react-aria-components';
import {useSyncExternalStore} from 'react';

const initialWidths = new Map([ ['file', '1fr'], ['size', 80], ['date', 100] ]); export default function ResizableTable() { let columnWidths = useSyncExternalStore(subscribe, getColumnWidths, getInitialWidths);
return ( <ResizableTableContainer onResize={setColumnWidths} > <Table aria-label="Table with resizable columns"> <TableHeader columns={columns} dependencies={[columnWidths]}> {column => ( <Column isRowHeader={column.id === 'file'} allowsResizing width={columnWidths.get(column.id)} > {column.name} </Column> )} </TableHeader> <TableBody items={rows}> {item => ( <Row> <Cell>{item.name}</Cell> <Cell>{item.size}</Cell> <Cell>{item.date}</Cell> </Row> )} </TableBody> </Table> </ResizableTableContainer> ); } let parsedWidths; function getColumnWidths() { // Parse column widths from localStorage. if (!parsedWidths) { let data = localStorage.getItem('table-widths'); if (data) { parsedWidths = new Map(JSON.parse(data)); } } return parsedWidths || initialWidths; } function setColumnWidths(widths) { // Store new widths in localStorage, and trigger subscriptions. localStorage.setItem('table-widths', JSON.stringify(Array.from(widths))); window.dispatchEvent(new Event('storage')); } function getInitialWidths() { return initialWidths; } function subscribe(fn) { let onStorage = () => { // Invalidate cache. parsedWidths = null; fn(); }; window.addEventListener('storage', onStorage); return () => window.removeEventListener('storage', onStorage); }

Drag and drop

Table supports drag and drop interactions when the dragAndDropHooks prop is provided using the hook. Users can drop data on the table as a whole, on individual rows, insert new rows between existing ones, or reorder rows. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the drag and drop guide to learn more.

GamesFile folder6/7/2020
Program FilesFile folder4/7/2021
bootmgrSystem file11/20/2010
log.txtText Document1/18/2016
import {useListData} from 'react-stately';
import {Table, TableHeader, Column, Row} from './Table';
import {TableBody, Cell} from 'react-aria-components';
import {useDragAndDrop} from 'react-aria-components';

function ReorderableTable() {
  let list = useListData({
    initialItems: [
      {id: 1, name: 'Games', date: '6/7/2020', type: 'File folder'},
      {id: 2, name: 'Program Files', date: '4/7/2021', type: 'File folder'},
      {id: 3, name: 'bootmgr', date: '11/20/2010', type: 'System file'},
      {id: 4, name: 'log.txt', date: '1/18/2016', type: 'Text Document'}
    ]
  });

  let {dragAndDropHooks} = useDragAndDrop({
    getItems: (keys) => [...keys].map(key => ({
      'text/plain': list.getItem(key).name
    })),
    onReorder(e) {
      if (e.target.dropPosition === 'before') {
        list.moveBefore(e.target.key, e.keys);
      } else if (e.target.dropPosition === 'after') {
        list.moveAfter(e.target.key, e.keys);
      }
    }
  });

  return (
    <Table
      aria-label="Files"
      selectionMode="multiple"
      dragAndDropHooks={dragAndDropHooks}
    >
      <TableHeader>
        <Column isRowHeader>Name</Column>
        <Column>Type</Column>
        <Column>Date Modified</Column>
      </TableHeader>
      <TableBody items={list.items}>
        {item => (
          <Row>
            <Cell>{item.name}</Cell>
            <Cell>{item.type}</Cell>
            <Cell>{item.date}</Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}

API

ColumnSize214 KB120 KB88 KB24 KBProposalBudgetWelcomeOnboardingFile nameCellSelect allcheckboxTable bodyTable headerRowSelectioncheckboxDragbuttonColumnresizer