beta

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.

installyarn add react-aria-components
version1.0.0-beta.2
usageimport {Table} from 'react-aria-components'

Example#


import {Cell, Column, Row, Table, TableBody, TableHeader} from 'react-aria-components';

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

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

<Table
  aria-label="Files"
  selectionMode="multiple"
>
  <TableHeader>
    <Column>
      <MyCheckbox />
    </Column>
    <Column
      isRowHeader
    >
      Name
    </Column>
    <Column>
      Type
    </Column>
    <Column>
      Date Modified
    </Column>
  </TableHeader>
  <TableBody>
    <Row>
      <Cell>
        <MyCheckbox />
      </Cell>
      <Cell>
        Games
      </Cell>
      <Cell>
        File folder
      </Cell>
      <Cell>
        6/7/2020
      </Cell>
    </Row>
    <Row>
      <Cell>
        <MyCheckbox />
      </Cell>
      <Cell>
        Program Files
      </Cell>
      <Cell>
        File folder
      </Cell>
      <Cell>
        4/7/2021
      </Cell>
    </Row>
    <Row>
      <Cell>
        <MyCheckbox />
      </Cell>
      <Cell>
        bootmgr
      </Cell>
      <Cell>
        System file
      </Cell>
      <Cell>
        11/20/2010
      </Cell>
    </Row>
    <Row>
      <Cell>
        <MyCheckbox />
      </Cell>
      <Cell>
        log.txt
      </Cell>
      <Cell>
        Text Document
      </Cell>
      <Cell>
        1/18/2016
      </Cell>
    </Row>
  </TableBody>
</Table>
Show CSS
.react-aria-Table {
  --highlight-background: slateblue;
  --highlight-foreground: white;
  --border-color: var(--spectrum-global-color-gray-400);
  --background-color: var(--page-background);
  --text-color: var(--spectrum-alias-text-color);
  --text-color-disabled: var(--spectrum-alias-text-color-disabled);

  padding: 0.286rem;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--background-color);
  outline: none;
  border-spacing: 0;
  min-height: 100px;
  align-self: start;
  max-width: 100%;
  word-break: break-word;

  &[data-focus-visible] {
    border-color: var(--highlight-background);
    box-shadow: 0 0 0 1px var(--highlight-background);
  }

  .react-aria-TableHeader {
    &:after {
      content: '';
      display: table-row;
      height: 2px;
    }

    & tr:last-child .react-aria-Column {
      border-bottom: 1px solid var(--border-color);
      cursor: default;
    }
  }

  .react-aria-Column {
    &[colspan] {
      text-align: center;
    }

    .sort-indicator {
      padding: 0 2px;
    }

    &:not([data-sort-direction]) .sort-indicator {
      visibility: hidden;
    }
  }

  .react-aria-TableBody {
    &[data-empty] {
      text-align: center;
      font-style: italic;
    }
  }

  .react-aria-Row {
    border-radius: 6px;
    outline: none;
    cursor: default;
    color: var(--text-color);
    font-size: 1.072rem;
    position: relative;
    transform: scale(1);

    &[data-focus-visible] {
      outline: 2px solid var(--highlight-background);
      outline-offset: -2px;
    }

    &[data-pressed] .react-aria-Cell {
      background: var(--spectrum-global-color-gray-200);
    }

    &[data-selected] {
      .react-aria-Cell {
        background: var(--highlight-background);
        color: var(--highlight-foreground);
      }

      &[data-focus-visible],
      .react-aria-Cell[data-focus-visible] {
        outline-color: var(--highlight-foreground);
        outline-offset: -4px;
      }

      .react-aria-Button {
        color: var(--highlight-foreground);
        --focus-ring-color: var(--highlight-foreground);
        --hover-highlight: rgb(255 255 255 / 0.1);
        --active-highlight: rgb(255 255 255 / 0.2);
      }
    }

    &[data-disabled] {
      color: var(--text-color-disabled);
    }
  }

  .react-aria-Cell,
  .react-aria-Column {
    padding: 4px 8px;
    text-align: left;
    outline: none;

    &[data-focus-visible] {
      outline: 2px solid var(--highlight-background);
      outline-offset: -2px;
    }
  }

  .react-aria-Cell {
    transform: translateZ(0);

    &:first-child {
      border-radius: 6px 0 0 6px;
    }

    &:last-child {
      border-radius: 0 6px 6px 0;
    }
  }

  /* join selected items if :has selector is supported */
  @supports selector(:has(.foo)) {
    .react-aria-Row[data-selected]:has(+ [data-selected]) .react-aria-Cell,
    .react-aria-Row[data-selected]:has(+ .react-aria-DropIndicator + [data-selected]) .react-aria-Cell {
      border-end-start-radius: 0;
      border-end-end-radius: 0;
    }

    .react-aria-Row[data-selected] + [data-selected] .react-aria-Cell,
    .react-aria-Row[data-selected] + .react-aria-DropIndicator + [data-selected] .react-aria-Cell {
      border-start-start-radius: 0;
      border-start-end-radius: 0;
    }
  }
}

.react-aria-Checkbox {
  --deselected-color: gray;
  --deselected-color-pressed: dimgray;
  --selected-color: slateblue;
  --selected-color-pressed: lch(from slateblue calc(l - 10%) c h);
  --checkmark-color: white;
  --focus-ring-color: slateblue;

  width: 1rem;
  height: 1rem;
  border: 2px solid var(--deselected-color);
  border-radius: 4px;
  transition: all 200ms;
  display: flex;
  align-items: center;
  justify-content: center;

  & svg {
    width: 0.857rem;
    height: 0.857rem;
    fill: none;
    stroke: var(--checkmark-color);
    stroke-width: 3px;
    stroke-dasharray: 22px;
    stroke-dashoffset: 66;
    transition: all 200ms;
  }

  &[data-focus-visible] {
    box-shadow: 0 0 0 2px var(--spectrum-alias-background-color-default), 0 0 0 4px var(--focus-ring-color);
  }

  &[data-pressed] {
    border-color: var(--deselected-color-pressed);
  }

  &[data-selected],
  &[data-indeterminate] {
    border-color: var(--selected-color);
    background: var(--selected-color);

    &[data-pressed] {
      border-color: var(--selected-color-pressed);
      background: var(--selected-color-pressed);
    }

    &[data-focus-visible] {
      box-shadow: 0 0 0 2px var(--background-color), 0 0 0 4px var(--selected-color);
    }

    & svg {
      stroke-dashoffset: 44;
    }
  }

  &[data-indeterminate] {
    & svg {
      stroke: none;
      fill: var(--checkmark-color);
    }
  }

  &[data-disabled] {
    opacity: 0.4;
  }
}

:where(.react-aria-Row) .react-aria-Checkbox {
  --selected-color: white;
  --selected-color-pressed: #ddd;
  --checkmark-color: slateblue;
  --background-color: var(--highlight-background);
}

.react-aria-Button {
  background: transparent;
  border: none;
  border-radius: 4px;
  appearance: none;
  vertical-align: middle;
  font-size: 1.2rem;
  text-align: center;
  line-height: 1.2em;
  margin: 0;
  outline: none;
  padding: 4px 6px;
  transition: background 200ms;
  --focus-ring-color: slateblue;
  --hover-highlight: var(--spectrum-alias-highlight-hover);
  --active-highlight: var(--spectrum-alias-highlight-active);

  &[data-hovered] {
    background: var(--hover-highlight);
  }

  &[data-pressed] {
    background: var(--active-highlight);
  }

  &[data-focus-visible] {
    box-shadow: 0 0 0 2px var(--focus-ring-color);
  }
}

@media (forced-colors: active) {
  .react-aria-Table {
    forced-color-adjust: none;

    --highlight-background: Highlight;
    --highlight-foreground: HighlightText;
    --border-color: ButtonBorder;
    --background-color: ButtonFace;
    --text-color: ButtonText;
    --text-color-disabled: GrayText;
  }

  .react-aria-Checkbox {
    forced-color-adjust: none;

    --deselected-color: ButtonBorder;
    --deselected-color-pressed: ButtonBorder;
    --selected-color: Highlight;
    --selected-color-pressed: Highlight;
    --checkmark-color: HighlightText;
    --spectrum-alias-background-color-default: Canvas;
    --focus-ring-color: Highlight;

    &[data-disabled] {
      opacity: 1;
      --deselected-color: GrayText;
      --selected-color: GrayText;
    }
  }

  .react-aria-Row .react-aria-Checkbox {
    --selected-color: ButtonFace;
    --selected-color-pressed: ButtonFace;
    --checkmark-color: ButtonText;
  }

  .react-aria-Button {
    forced-color-adjust: none;
    --focus-ring-color: Highlight;
  }
}
.react-aria-Table {
  --highlight-background: slateblue;
  --highlight-foreground: white;
  --border-color: var(--spectrum-global-color-gray-400);
  --background-color: var(--page-background);
  --text-color: var(--spectrum-alias-text-color);
  --text-color-disabled: var(--spectrum-alias-text-color-disabled);

  padding: 0.286rem;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--background-color);
  outline: none;
  border-spacing: 0;
  min-height: 100px;
  align-self: start;
  max-width: 100%;
  word-break: break-word;

  &[data-focus-visible] {
    border-color: var(--highlight-background);
    box-shadow: 0 0 0 1px var(--highlight-background);
  }

  .react-aria-TableHeader {
    &:after {
      content: '';
      display: table-row;
      height: 2px;
    }

    & tr:last-child .react-aria-Column {
      border-bottom: 1px solid var(--border-color);
      cursor: default;
    }
  }

  .react-aria-Column {
    &[colspan] {
      text-align: center;
    }

    .sort-indicator {
      padding: 0 2px;
    }

    &:not([data-sort-direction]) .sort-indicator {
      visibility: hidden;
    }
  }

  .react-aria-TableBody {
    &[data-empty] {
      text-align: center;
      font-style: italic;
    }
  }

  .react-aria-Row {
    border-radius: 6px;
    outline: none;
    cursor: default;
    color: var(--text-color);
    font-size: 1.072rem;
    position: relative;
    transform: scale(1);

    &[data-focus-visible] {
      outline: 2px solid var(--highlight-background);
      outline-offset: -2px;
    }

    &[data-pressed] .react-aria-Cell {
      background: var(--spectrum-global-color-gray-200);
    }

    &[data-selected] {
      .react-aria-Cell {
        background: var(--highlight-background);
        color: var(--highlight-foreground);
      }

      &[data-focus-visible],
      .react-aria-Cell[data-focus-visible] {
        outline-color: var(--highlight-foreground);
        outline-offset: -4px;
      }

      .react-aria-Button {
        color: var(--highlight-foreground);
        --focus-ring-color: var(--highlight-foreground);
        --hover-highlight: rgb(255 255 255 / 0.1);
        --active-highlight: rgb(255 255 255 / 0.2);
      }
    }

    &[data-disabled] {
      color: var(--text-color-disabled);
    }
  }

  .react-aria-Cell,
  .react-aria-Column {
    padding: 4px 8px;
    text-align: left;
    outline: none;

    &[data-focus-visible] {
      outline: 2px solid var(--highlight-background);
      outline-offset: -2px;
    }
  }

  .react-aria-Cell {
    transform: translateZ(0);

    &:first-child {
      border-radius: 6px 0 0 6px;
    }

    &:last-child {
      border-radius: 0 6px 6px 0;
    }
  }

  /* join selected items if :has selector is supported */
  @supports selector(:has(.foo)) {
    .react-aria-Row[data-selected]:has(+ [data-selected]) .react-aria-Cell,
    .react-aria-Row[data-selected]:has(+ .react-aria-DropIndicator + [data-selected]) .react-aria-Cell {
      border-end-start-radius: 0;
      border-end-end-radius: 0;
    }

    .react-aria-Row[data-selected] + [data-selected] .react-aria-Cell,
    .react-aria-Row[data-selected] + .react-aria-DropIndicator + [data-selected] .react-aria-Cell {
      border-start-start-radius: 0;
      border-start-end-radius: 0;
    }
  }
}

.react-aria-Checkbox {
  --deselected-color: gray;
  --deselected-color-pressed: dimgray;
  --selected-color: slateblue;
  --selected-color-pressed: lch(from slateblue calc(l - 10%) c h);
  --checkmark-color: white;
  --focus-ring-color: slateblue;

  width: 1rem;
  height: 1rem;
  border: 2px solid var(--deselected-color);
  border-radius: 4px;
  transition: all 200ms;
  display: flex;
  align-items: center;
  justify-content: center;

  & svg {
    width: 0.857rem;
    height: 0.857rem;
    fill: none;
    stroke: var(--checkmark-color);
    stroke-width: 3px;
    stroke-dasharray: 22px;
    stroke-dashoffset: 66;
    transition: all 200ms;
  }

  &[data-focus-visible] {
    box-shadow: 0 0 0 2px var(--spectrum-alias-background-color-default), 0 0 0 4px var(--focus-ring-color);
  }

  &[data-pressed] {
    border-color: var(--deselected-color-pressed);
  }

  &[data-selected],
  &[data-indeterminate] {
    border-color: var(--selected-color);
    background: var(--selected-color);

    &[data-pressed] {
      border-color: var(--selected-color-pressed);
      background: var(--selected-color-pressed);
    }

    &[data-focus-visible] {
      box-shadow: 0 0 0 2px var(--background-color), 0 0 0 4px var(--selected-color);
    }

    & svg {
      stroke-dashoffset: 44;
    }
  }

  &[data-indeterminate] {
    & svg {
      stroke: none;
      fill: var(--checkmark-color);
    }
  }

  &[data-disabled] {
    opacity: 0.4;
  }
}

:where(.react-aria-Row) .react-aria-Checkbox {
  --selected-color: white;
  --selected-color-pressed: #ddd;
  --checkmark-color: slateblue;
  --background-color: var(--highlight-background);
}

.react-aria-Button {
  background: transparent;
  border: none;
  border-radius: 4px;
  appearance: none;
  vertical-align: middle;
  font-size: 1.2rem;
  text-align: center;
  line-height: 1.2em;
  margin: 0;
  outline: none;
  padding: 4px 6px;
  transition: background 200ms;
  --focus-ring-color: slateblue;
  --hover-highlight: var(--spectrum-alias-highlight-hover);
  --active-highlight: var(--spectrum-alias-highlight-active);

  &[data-hovered] {
    background: var(--hover-highlight);
  }

  &[data-pressed] {
    background: var(--active-highlight);
  }

  &[data-focus-visible] {
    box-shadow: 0 0 0 2px var(--focus-ring-color);
  }
}

@media (forced-colors: active) {
  .react-aria-Table {
    forced-color-adjust: none;

    --highlight-background: Highlight;
    --highlight-foreground: HighlightText;
    --border-color: ButtonBorder;
    --background-color: ButtonFace;
    --text-color: ButtonText;
    --text-color-disabled: GrayText;
  }

  .react-aria-Checkbox {
    forced-color-adjust: none;

    --deselected-color: ButtonBorder;
    --deselected-color-pressed: ButtonBorder;
    --selected-color: Highlight;
    --selected-color-pressed: Highlight;
    --checkmark-color: HighlightText;
    --spectrum-alias-background-color-default: Canvas;
    --focus-ring-color: Highlight;

    &[data-disabled] {
      opacity: 1;
      --deselected-color: GrayText;
      --selected-color: GrayText;
    }
  }

  .react-aria-Row .react-aria-Checkbox {
    --selected-color: ButtonFace;
    --selected-color-pressed: ButtonFace;
    --checkmark-color: ButtonText;
  }

  .react-aria-Button {
    forced-color-adjust: none;
    --focus-ring-color: Highlight;
  }
}
.react-aria-Table {
  --highlight-background: slateblue;
  --highlight-foreground: white;
  --border-color: var(--spectrum-global-color-gray-400);
  --background-color: var(--page-background);
  --text-color: var(--spectrum-alias-text-color);
  --text-color-disabled: var(--spectrum-alias-text-color-disabled);

  padding: 0.286rem;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--background-color);
  outline: none;
  border-spacing: 0;
  min-height: 100px;
  align-self: start;
  max-width: 100%;
  word-break: break-word;

  &[data-focus-visible] {
    border-color: var(--highlight-background);
    box-shadow: 0 0 0 1px var(--highlight-background);
  }

  .react-aria-TableHeader {
    &:after {
      content: '';
      display: table-row;
      height: 2px;
    }

    & tr:last-child .react-aria-Column {
      border-bottom: 1px solid var(--border-color);
      cursor: default;
    }
  }

  .react-aria-Column {
    &[colspan] {
      text-align: center;
    }

    .sort-indicator {
      padding: 0 2px;
    }

    &:not([data-sort-direction]) .sort-indicator {
      visibility: hidden;
    }
  }

  .react-aria-TableBody {
    &[data-empty] {
      text-align: center;
      font-style: italic;
    }
  }

  .react-aria-Row {
    border-radius: 6px;
    outline: none;
    cursor: default;
    color: var(--text-color);
    font-size: 1.072rem;
    position: relative;
    transform: scale(1);

    &[data-focus-visible] {
      outline: 2px solid var(--highlight-background);
      outline-offset: -2px;
    }

    &[data-pressed] .react-aria-Cell {
      background: var(--spectrum-global-color-gray-200);
    }

    &[data-selected] {
      .react-aria-Cell {
        background: var(--highlight-background);
        color: var(--highlight-foreground);
      }

      &[data-focus-visible],
      .react-aria-Cell[data-focus-visible] {
        outline-color: var(--highlight-foreground);
        outline-offset: -4px;
      }

      .react-aria-Button {
        color: var(--highlight-foreground);
        --focus-ring-color: var(--highlight-foreground);
        --hover-highlight: rgb(255 255 255 / 0.1);
        --active-highlight: rgb(255 255 255 / 0.2);
      }
    }

    &[data-disabled] {
      color: var(--text-color-disabled);
    }
  }

  .react-aria-Cell,
  .react-aria-Column {
    padding: 4px 8px;
    text-align: left;
    outline: none;

    &[data-focus-visible] {
      outline: 2px solid var(--highlight-background);
      outline-offset: -2px;
    }
  }

  .react-aria-Cell {
    transform: translateZ(0);

    &:first-child {
      border-radius: 6px 0 0 6px;
    }

    &:last-child {
      border-radius: 0 6px 6px 0;
    }
  }

  /* join selected items if :has selector is supported */
  @supports selector(:has(.foo)) {
    .react-aria-Row[data-selected]:has(+ [data-selected]) .react-aria-Cell,
    .react-aria-Row[data-selected]:has(+ .react-aria-DropIndicator + [data-selected]) .react-aria-Cell {
      border-end-start-radius: 0;
      border-end-end-radius: 0;
    }

    .react-aria-Row[data-selected] + [data-selected] .react-aria-Cell,
    .react-aria-Row[data-selected] + .react-aria-DropIndicator + [data-selected] .react-aria-Cell {
      border-start-start-radius: 0;
      border-start-end-radius: 0;
    }
  }
}

.react-aria-Checkbox {
  --deselected-color: gray;
  --deselected-color-pressed: dimgray;
  --selected-color: slateblue;
  --selected-color-pressed: lch(from slateblue calc(l - 10%) c h);
  --checkmark-color: white;
  --focus-ring-color: slateblue;

  width: 1rem;
  height: 1rem;
  border: 2px solid var(--deselected-color);
  border-radius: 4px;
  transition: all 200ms;
  display: flex;
  align-items: center;
  justify-content: center;

  & svg {
    width: 0.857rem;
    height: 0.857rem;
    fill: none;
    stroke: var(--checkmark-color);
    stroke-width: 3px;
    stroke-dasharray: 22px;
    stroke-dashoffset: 66;
    transition: all 200ms;
  }

  &[data-focus-visible] {
    box-shadow: 0 0 0 2px var(--spectrum-alias-background-color-default), 0 0 0 4px var(--focus-ring-color);
  }

  &[data-pressed] {
    border-color: var(--deselected-color-pressed);
  }

  &[data-selected],
  &[data-indeterminate] {
    border-color: var(--selected-color);
    background: var(--selected-color);

    &[data-pressed] {
      border-color: var(--selected-color-pressed);
      background: var(--selected-color-pressed);
    }

    &[data-focus-visible] {
      box-shadow: 0 0 0 2px var(--background-color), 0 0 0 4px var(--selected-color);
    }

    & svg {
      stroke-dashoffset: 44;
    }
  }

  &[data-indeterminate] {
    & svg {
      stroke: none;
      fill: var(--checkmark-color);
    }
  }

  &[data-disabled] {
    opacity: 0.4;
  }
}

:where(.react-aria-Row) .react-aria-Checkbox {
  --selected-color: white;
  --selected-color-pressed: #ddd;
  --checkmark-color: slateblue;
  --background-color: var(--highlight-background);
}

.react-aria-Button {
  background: transparent;
  border: none;
  border-radius: 4px;
  appearance: none;
  vertical-align: middle;
  font-size: 1.2rem;
  text-align: center;
  line-height: 1.2em;
  margin: 0;
  outline: none;
  padding: 4px 6px;
  transition: background 200ms;
  --focus-ring-color: slateblue;
  --hover-highlight: var(--spectrum-alias-highlight-hover);
  --active-highlight: var(--spectrum-alias-highlight-active);

  &[data-hovered] {
    background: var(--hover-highlight);
  }

  &[data-pressed] {
    background: var(--active-highlight);
  }

  &[data-focus-visible] {
    box-shadow: 0 0 0 2px var(--focus-ring-color);
  }
}

@media (forced-colors: active) {
  .react-aria-Table {
    forced-color-adjust: none;

    --highlight-background: Highlight;
    --highlight-foreground: HighlightText;
    --border-color: ButtonBorder;
    --background-color: ButtonFace;
    --text-color: ButtonText;
    --text-color-disabled: GrayText;
  }

  .react-aria-Checkbox {
    forced-color-adjust: none;

    --deselected-color: ButtonBorder;
    --deselected-color-pressed: ButtonBorder;
    --selected-color: Highlight;
    --selected-color-pressed: Highlight;
    --checkmark-color: HighlightText;
    --spectrum-alias-background-color-default: Canvas;
    --focus-ring-color: Highlight;

    &[data-disabled] {
      opacity: 1;
      --deselected-color: GrayText;
      --selected-color: GrayText;
    }
  }

  .react-aria-Row .react-aria-Checkbox {
    --selected-color: ButtonFace;
    --selected-color-pressed: ButtonFace;
    --checkmark-color: ButtonText;
  }

  .react-aria-Button {
    forced-color-adjust: none;
    --focus-ring-color: Highlight;
  }
}

Features#


A table can be built using the <table>, <tr>, <td>, and other table specific HTML elements, but is very limited in functionality especially when it comes to user interactions. HTML tables are meant for static content, rather than tables with rich interactions like focusable elements within cells, keyboard navigation, row selection, sorting, etc. Table helps achieve accessible and interactive table components that can be styled as needed.

  • Row selection – Single or multiple selection, with optional checkboxes, disabled rows, and both toggle and replace selection behaviors.
  • Columns – Support for column sorting, row header columns, and nested column groups. Columns may optionally allow user resizing via mouse, touch, and keyboard interactions.
  • Interactive children – Table cells may include interactive elements such as buttons, menus, etc.
  • Actions – Rows and cells support optional actions such as navigation via click, tap, double click, or Enter key.
  • Async loading – Support for loading and sorting items asynchronously.
  • Keyboard navigation – Table rows, cells, and focusable children can be navigated using the arrow keys, along with page up/down, home/end, etc. Typeahead, auto scrolling, and selection modifier keys are supported as well.
  • Drag and drop – Tables support drag and drop to reorder, insert, or update rows via mouse, touch, keyboard, and screen reader interactions.
  • Touch friendly – Selection and actions adapt their behavior depending on the device. For example, selection is activated via long press on touch when row actions are present.
  • Accessible – Follows the ARIA grid pattern, with additional selection announcements via an ARIA live region. Extensively tested across many devices and assistive technologies to ensure announcements and behaviors are consistent.

Anatomy#


ColumnSize214 KB120 KB88 KB24 KBProposalBudgetWelcomeOnboardingFile nameCellSelect allcheckboxTable bodyTable headerRowSelectioncheckboxDragbuttonColumnresizer

A table consists of a container element, with columns and rows of cells containing data inside. The cells within a table may contain focusable elements or plain text content. Columns may be nested to create column groups.

If the table supports row selection, each row can optionally include a selection checkbox. Additionally, a "select all" checkbox may be displayed in a column header if the table supports multiple row selection. A drag button may also be included within a cell if the row is draggable.

If a table supports column resizing, then it should also be wrapped in a <ResizableTableContainer>, and a <ColumnResizer> should be included in each resizable column.

import {Button, Cell, Checkbox, Column, ColumnResizer, ResizableTableContainer, Row, Table, TableBody, TableHeader} from 'react-aria-components';

<ResizableTableContainer>
  <Table>
    <TableHeader>
      <Column />
      <Column>
        <Checkbox slot="selection" />
      </Column>
      <Column>
        <ColumnResizer />
      </Column>
      <Column>
        <Column />
        <Column />
      </Column>
    </TableHeader>
    <TableBody>
      <Row>
        <Cell>
          <Button slot="drag" />
        </Cell>
        <Cell>
          <Checkbox slot="selection" />
        </Cell>
        <Cell />
        <Cell />
        <Cell />
      </Row>
    </TableBody>
  </Table>
</ResizableTableContainer>
import {
  Button,
  Cell,
  Checkbox,
  Column,
  ColumnResizer,
  ResizableTableContainer,
  Row,
  Table,
  TableBody,
  TableHeader
} from 'react-aria-components';

<ResizableTableContainer>
  <Table>
    <TableHeader>
      <Column />
      <Column>
        <Checkbox slot="selection" />
      </Column>
      <Column>
        <ColumnResizer />
      </Column>
      <Column>
        <Column />
        <Column />
      </Column>
    </TableHeader>
    <TableBody>
      <Row>
        <Cell>
          <Button slot="drag" />
        </Cell>
        <Cell>
          <Checkbox slot="selection" />
        </Cell>
        <Cell />
        <Cell />
        <Cell />
      </Row>
    </TableBody>
  </Table>
</ResizableTableContainer>
import {
  Button,
  Cell,
  Checkbox,
  Column,
  ColumnResizer,
  ResizableTableContainer,
  Row,
  Table,
  TableBody,
  TableHeader
} from 'react-aria-components';

<ResizableTableContainer>
  <Table>
    <TableHeader>
      <Column />
      <Column>
        <Checkbox slot="selection" />
      </Column>
      <Column>
        <ColumnResizer />
      </Column>
      <Column>
        <Column />
        <Column />
      </Column>
    </TableHeader>
    <TableBody>
      <Row>
        <Cell>
          <Button slot="drag" />
        </Cell>
        <Cell>
          <Checkbox slot="selection" />
        </Cell>
        <Cell />
        <Cell />
        <Cell />
      </Row>
    </TableBody>
  </Table>
</ResizableTableContainer>

Concepts#

Table makes use of the following concepts:

Collections
Defining collections of items, async loading, and updating items over time.
Selection
Interactions and data structures to represent selection.
Drag and drop
Concepts and interactions for an accessible drag and drop experience.

Composed components#

A Table uses the following components, which may also be used standalone or reused in other components.

Checkbox
A checkbox allows a user to select an individual option.
Button
A button allows a user to perform an action.

Examples#


Stock Table
A table with sticky headers, sorting, multiple selection, and column resizing.

Reusable wrappers#


If you will use a Table in multiple places in your app, you can wrap all of the pieces into a reusable component. This way, the DOM structure, styling code, and other logic are defined in a single place and reused everywhere to ensure consistency.

The following example includes a custom Column component with a sort indicator. It displays an upwards facing arrow when the column is sorted in the ascending direction, and a downward facing arrow otherwise.

import type {ColumnProps} from 'react-aria-components';

function MyColumn<T extends object>(props: ColumnProps<T>) {
  return (
    <Column {...props}>
      {({allowsSorting, sortDirection}) => <>
        {props.children}
        {allowsSorting && (
          <span aria-hidden="true" className="sort-indicator">
            {sortDirection === 'ascending' ? '▲' : '▼'}
          </span>
        )}
      </>}
    </Column>
  );
}
import type {ColumnProps} from 'react-aria-components';

function MyColumn<T extends object>(props: ColumnProps<T>) {
  return (
    <Column {...props}>
      {({ allowsSorting, sortDirection }) => (
        <>
          {props.children}
          {allowsSorting && (
            <span
              aria-hidden="true"
              className="sort-indicator"
            >
              {sortDirection === 'ascending' ? '▲' : '▼'}
            </span>
          )}
        </>
      )}
    </Column>
  );
}
import type {ColumnProps} from 'react-aria-components';

function MyColumn<
  T extends object
>(
  props: ColumnProps<T>
) {
  return (
    <Column {...props}>
      {(
        {
          allowsSorting,
          sortDirection
        }
      ) => (
        <>
          {props
            .children}
          {allowsSorting &&
            (
              <span
                aria-hidden="true"
                className="sort-indicator"
              >
                {sortDirection ===
                    'ascending'
                  ? '▲'
                  : '▼'}
              </span>
            )}
        </>
      )}
    </Column>
  );
}

The TableHeader and Row components can also be wrapped to automatically include checkboxes for selection, and a drag handle when drag and drop is enabled, allowing consumers to avoid repeating them in each row. In this example, the select all checkbox is displayed when multiple selection is enabled and the selection behavior is "toggle". These options can be retrieved from the table using the useTableOptions hook. We also use the Collection component to generate children from either static or dynamic collections the same way as the default TableHeader and Row components.

import type {RowProps, TableHeaderProps} from 'react-aria-components';
import {Checkbox, Collection, useTableOptions} from 'react-aria-components';

function MyTableHeader<T extends object>(
  { columns, children }: TableHeaderProps<T>
) {
  let { selectionBehavior, selectionMode, allowsDragging } = useTableOptions();

  return (
    <TableHeader>
      {/* Add extra columns for drag and drop and selection. */}
      {allowsDragging && <Column />}
      {selectionBehavior === 'toggle' && (
        <Column>{selectionMode === 'multiple' && <MyCheckbox />}</Column>
      )}
      <Collection items={columns}>
        {children}
      </Collection>
    </TableHeader>
  );
}

function MyRow<T extends object>(
  { id, columns, children, ...otherProps }: RowProps<T>
) {
  let { selectionBehavior, allowsDragging } = useTableOptions();

  return (
    <Row id={id} {...otherProps}>
      {allowsDragging && (
        <Cell>
          <Button slot="drag"></Button>
        </Cell>
      )}
      {selectionBehavior === 'toggle' && (
        <Cell>
          <MyCheckbox />
        </Cell>
      )}
      <Collection items={columns}>
        {children}
      </Collection>
    </Row>
  );
}

function MyCheckbox() {
  return (
    <Checkbox slot="selection">
      {({ isIndeterminate }) => (
        <svg viewBox="0 0 18 18" aria-hidden="true">
          {isIndeterminate
            ? <rect x={1} y={7.5} width={15} height={3} />
            : <polyline points="1 9 7 14 15 4" />}
        </svg>
      )}
    </Checkbox>
  );
}
import type {
  RowProps,
  TableHeaderProps
} from 'react-aria-components';
import {
  Checkbox,
  Collection,
  useTableOptions
} from 'react-aria-components';

function MyTableHeader<T extends object>(
  { columns, children }: TableHeaderProps<T>
) {
  let { selectionBehavior, selectionMode, allowsDragging } =
    useTableOptions();

  return (
    <TableHeader>
      {/* Add extra columns for drag and drop and selection. */}
      {allowsDragging && <Column />}
      {selectionBehavior === 'toggle' && (
        <Column>
          {selectionMode === 'multiple' && <MyCheckbox />}
        </Column>
      )}
      <Collection items={columns}>
        {children}
      </Collection>
    </TableHeader>
  );
}

function MyRow<T extends object>(
  { id, columns, children, ...otherProps }: RowProps<T>
) {
  let { selectionBehavior, allowsDragging } =
    useTableOptions();

  return (
    <Row id={id} {...otherProps}>
      {allowsDragging && (
        <Cell>
          <Button slot="drag"></Button>
        </Cell>
      )}
      {selectionBehavior === 'toggle' && (
        <Cell>
          <MyCheckbox />
        </Cell>
      )}
      <Collection items={columns}>
        {children}
      </Collection>
    </Row>
  );
}

function MyCheckbox() {
  return (
    <Checkbox slot="selection">
      {({ isIndeterminate }) => (
        <svg viewBox="0 0 18 18" aria-hidden="true">
          {isIndeterminate
            ? <rect x={1} y={7.5} width={15} height={3} />
            : <polyline points="1 9 7 14 15 4" />}
        </svg>
      )}
    </Checkbox>
  );
}
import type {
  RowProps,
  TableHeaderProps
} from 'react-aria-components';
import {
  Checkbox,
  Collection,
  useTableOptions
} from 'react-aria-components';

function MyTableHeader<
  T extends object
>(
  { columns, children }:
    TableHeaderProps<T>
) {
  let {
    selectionBehavior,
    selectionMode,
    allowsDragging
  } = useTableOptions();

  return (
    <TableHeader>
      {/* Add extra columns for drag and drop and selection. */}
      {allowsDragging &&
        <Column />}
      {selectionBehavior ===
          'toggle' && (
        <Column>
          {selectionMode ===
              'multiple' &&
            (
              <MyCheckbox />
            )}
        </Column>
      )}
      <Collection
        items={columns}
      >
        {children}
      </Collection>
    </TableHeader>
  );
}

function MyRow<
  T extends object
>(
  {
    id,
    columns,
    children,
    ...otherProps
  }: RowProps<T>
) {
  let {
    selectionBehavior,
    allowsDragging
  } = useTableOptions();

  return (
    <Row
      id={id}
      {...otherProps}
    >
      {allowsDragging &&
        (
          <Cell>
            <Button slot="drag"></Button>
          </Cell>
        )}
      {selectionBehavior ===
          'toggle' && (
        <Cell>
          <MyCheckbox />
        </Cell>
      )}
      <Collection
        items={columns}
      >
        {children}
      </Collection>
    </Row>
  );
}

function MyCheckbox() {
  return (
    <Checkbox slot="selection">
      {(
        {
          isIndeterminate
        }
      ) => (
        <svg
          viewBox="0 0 18 18"
          aria-hidden="true"
        >
          {isIndeterminate
            ? (
              <rect
                x={1}
                y={7.5}
                width={15}
                height={3}
              />
            )
            : (
              <polyline points="1 9 7 14 15 4" />
            )}
        </svg>
      )}
    </Checkbox>
  );
}

Now we can render a table with a default selection column built in.

<Table aria-label="Files" selectionMode="multiple">
  <MyTableHeader>
    <MyColumn isRowHeader>Name</MyColumn>
    <MyColumn>Type</MyColumn>
    <MyColumn>Date Modified</MyColumn>
  </MyTableHeader>
  <TableBody>
    <MyRow>
      <Cell>Games</Cell>
      <Cell>File folder</Cell>
      <Cell>6/7/2020</Cell>
    </MyRow>
    <MyRow>
      <Cell>Program Files</Cell>
      <Cell>File folder</Cell>
      <Cell>4/7/2021</Cell>
    </MyRow>
    <MyRow>
      <Cell>bootmgr</Cell>
      <Cell>System file</Cell>
      <Cell>11/20/2010</Cell>
    </MyRow>
  </TableBody>
</Table>
<Table aria-label="Files" selectionMode="multiple">
  <MyTableHeader>
    <MyColumn isRowHeader>Name</MyColumn>
    <MyColumn>Type</MyColumn>
    <MyColumn>Date Modified</MyColumn>
  </MyTableHeader>
  <TableBody>
    <MyRow>
      <Cell>Games</Cell>
      <Cell>File folder</Cell>
      <Cell>6/7/2020</Cell>
    </MyRow>
    <MyRow>
      <Cell>Program Files</Cell>
      <Cell>File folder</Cell>
      <Cell>4/7/2021</Cell>
    </MyRow>
    <MyRow>
      <Cell>bootmgr</Cell>
      <Cell>System file</Cell>
      <Cell>11/20/2010</Cell>
    </MyRow>
  </TableBody>
</Table>
<Table
  aria-label="Files"
  selectionMode="multiple"
>
  <MyTableHeader>
    <MyColumn
      isRowHeader
    >
      Name
    </MyColumn>
    <MyColumn>
      Type
    </MyColumn>
    <MyColumn>
      Date Modified
    </MyColumn>
  </MyTableHeader>
  <TableBody>
    <MyRow>
      <Cell>
        Games
      </Cell>
      <Cell>
        File folder
      </Cell>
      <Cell>
        6/7/2020
      </Cell>
    </MyRow>
    <MyRow>
      <Cell>
        Program Files
      </Cell>
      <Cell>
        File folder
      </Cell>
      <Cell>
        4/7/2021
      </Cell>
    </MyRow>
    <MyRow>
      <Cell>
        bootmgr
      </Cell>
      <Cell>
        System file
      </Cell>
      <Cell>
        11/20/2010
      </Cell>
    </MyRow>
  </TableBody>
</Table>

Content#


So far, our examples have shown static collections, where the data is hard coded. Dynamic collections, as shown below, can be used when the table data comes from an external data source such as an API, or updates over time. In the example below, both the columns and the rows are provided to the table via a render function. You can also make the columns static and only the rows dynamic.

import type {TableProps} from 'react-aria-components';

function FileTable(props: TableProps) {
  let columns = [
    {name: 'Name', key: 'name', isRowHeader: true},
    {name: 'Type', key: 'type'},
    {name: 'Date Modified', key: 'date'}
  ];

  let rows = [
    {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'}
  ];

  return (
    <Table aria-label="Files" {...props}>
      <MyTableHeader columns={columns}>
        {column => (
          <Column isRowHeader={column.isRowHeader}>
            {column.name}
          </Column>
        )}
      </MyTableHeader>
      <TableBody items={rows}>
        {item => (
          <MyRow columns={columns}>
            {column => <Cell>{item[column.key]}</Cell>}
          </MyRow>
        )}
      </TableBody>
    </Table>
  );
}
import type {TableProps} from 'react-aria-components';

function FileTable(props: TableProps) {
  let columns = [
    { name: 'Name', key: 'name', isRowHeader: true },
    { name: 'Type', key: 'type' },
    { name: 'Date Modified', key: 'date' }
  ];

  let rows = [
    {
      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'
    }
  ];

  return (
    <Table aria-label="Files" {...props}>
      <MyTableHeader columns={columns}>
        {(column) => (
          <Column isRowHeader={column.isRowHeader}>
            {column.name}
          </Column>
        )}
      </MyTableHeader>
      <TableBody items={rows}>
        {(item) => (
          <MyRow columns={columns}>
            {(column) => <Cell>{item[column.key]}</Cell>}
          </MyRow>
        )}
      </TableBody>
    </Table>
  );
}
import type {TableProps} from 'react-aria-components';

function FileTable(
  props: TableProps
) {
  let columns = [
    {
      name: 'Name',
      key: 'name',
      isRowHeader: true
    },
    {
      name: 'Type',
      key: 'type'
    },
    {
      name:
        'Date Modified',
      key: 'date'
    }
  ];

  let rows = [
    {
      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'
    }
  ];

  return (
    <Table
      aria-label="Files"
      {...props}
    >
      <MyTableHeader
        columns={columns}
      >
        {(column) => (
          <Column
            isRowHeader={column
              .isRowHeader}
          >
            {column.name}
          </Column>
        )}
      </MyTableHeader>
      <TableBody
        items={rows}
      >
        {(item) => (
          <MyRow
            columns={columns}
          >
            {(column) => (
              <Cell>
                {item[
                  column
                    .key
                ]}
              </Cell>
            )}
          </MyRow>
        )}
      </TableBody>
    </Table>
  );
}

Selection#


Single selection#

By default, Table doesn't allow row selection but this can be enabled using the selectionMode prop. Use defaultSelectedKeys to provide a default set of selected rows. Note that the value of the selected keys must match the key prop of the row.

The example below enables single selection mode, and uses defaultSelectedKeys to select the row with id equal to 2. A user can click on a different row to change the selection, or click on the same row again to deselect it entirely.

// Using the example above
<FileTable selectionMode="single" defaultSelectedKeys={[2]} />
// Using the example above
<FileTable
  selectionMode="single"
  defaultSelectedKeys={[2]}
/>
// Using the example above
<FileTable
  selectionMode="single"
  defaultSelectedKeys={[
    2
  ]}
/>

Multiple selection#

Multiple selection can be enabled by setting selectionMode to multiple.

// Using the example above
<FileTable selectionMode="multiple" defaultSelectedKeys={[2, 4]} />
// Using the example above
<FileTable
  selectionMode="multiple"
  defaultSelectedKeys={[2, 4]}
/>
// Using the example above
<FileTable
  selectionMode="multiple"
  defaultSelectedKeys={[
    2,
    4
  ]}
/>

Disallow empty selection#

Table also supports a disallowEmptySelection prop which forces the user to have at least one row in the Table selected at all times. In this mode, if a single row is selected and the user presses it, it will not be deselected.

// Using the example above
<FileTable
  selectionMode="single"
  defaultSelectedKeys={[2]}
  disallowEmptySelection
/>
// Using the example above
<FileTable
  selectionMode="single"
  defaultSelectedKeys={[2]}
  disallowEmptySelection
/>
// Using the example above
<FileTable
  selectionMode="single"
  defaultSelectedKeys={[
    2
  ]}
  disallowEmptySelection
/>

Controlled selection#

To programmatically control row selection, use the selectedKeys prop paired with the onSelectionChange callback. The key prop from the selected rows will be passed into the callback when the row is pressed, allowing you to update state accordingly.

import type {Selection} from 'react-aria-components';

interface Pokemon {
  id: number,
  name: string,
  type: string,
  level: string
}

interface PokemonTableProps extends TableProps {
  items?: Pokemon[],
  renderEmptyState?: () => string
}

function PokemonTable(props: PokemonTableProps) {
  let items = props.items || [
    {id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67'},
    {id: 2, name: 'Blastoise', type: 'Water', level: '56'},
    {id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83'},
    {id: 4, name: 'Pikachu', type: 'Electric', level: '100'}
  ];

  let [selectedKeys, setSelectedKeys] = React.useState<Selection>(new Set());
  return (
    <Table
      aria-label="Pokemon table"
      {...props}
      selectedKeys={selectedKeys}
      onSelectionChange={setSelectedKeys}    >
      <MyTableHeader>
        <Column isRowHeader>Name</Column>
        <Column>Type</Column>
        <Column>Level</Column>
      </MyTableHeader>
      <TableBody items={items} renderEmptyState={props.renderEmptyState}>
        {item => (
          <MyRow>
            <Cell>{item.name}</Cell>
            <Cell>{item.type}</Cell>
            <Cell>{item.level}</Cell>
          </MyRow>
        )}
      </TableBody>
    </Table>
  );
}

<PokemonTable selectionMode="multiple" />
import type {Selection} from 'react-aria-components';

interface Pokemon {
  id: number;
  name: string;
  type: string;
  level: string;
}

interface PokemonTableProps extends TableProps {
  items?: Pokemon[];
  renderEmptyState?: () => string;
}

function PokemonTable(props: PokemonTableProps) {
  let items = props.items || [
    {
      id: 1,
      name: 'Charizard',
      type: 'Fire, Flying',
      level: '67'
    },
    {
      id: 2,
      name: 'Blastoise',
      type: 'Water',
      level: '56'
    },
    {
      id: 3,
      name: 'Venusaur',
      type: 'Grass, Poison',
      level: '83'
    },
    {
      id: 4,
      name: 'Pikachu',
      type: 'Electric',
      level: '100'
    }
  ];

  let [selectedKeys, setSelectedKeys] = React.useState<
    Selection
  >(new Set());
  return (
    <Table
      aria-label="Pokemon table"
      {...props}
      selectedKeys={selectedKeys}
      onSelectionChange={setSelectedKeys}    >
      <MyTableHeader>
        <Column isRowHeader>Name</Column>
        <Column>Type</Column>
        <Column>Level</Column>
      </MyTableHeader>
      <TableBody
        items={items}
        renderEmptyState={props.renderEmptyState}
      >
        {(item) => (
          <MyRow>
            <Cell>{item.name}</Cell>
            <Cell>{item.type}</Cell>
            <Cell>{item.level}</Cell>
          </MyRow>
        )}
      </TableBody>
    </Table>
  );
}

<PokemonTable selectionMode="multiple" />
import type {Selection} from 'react-aria-components';

interface Pokemon {
  id: number;
  name: string;
  type: string;
  level: string;
}

interface PokemonTableProps
  extends TableProps {
  items?: Pokemon[];
  renderEmptyState?:
    () => string;
}

function PokemonTable(
  props:
    PokemonTableProps
) {
  let items =
    props.items || [
      {
        id: 1,
        name:
          'Charizard',
        type:
          'Fire, Flying',
        level: '67'
      },
      {
        id: 2,
        name:
          'Blastoise',
        type: 'Water',
        level: '56'
      },
      {
        id: 3,
        name: 'Venusaur',
        type:
          'Grass, Poison',
        level: '83'
      },
      {
        id: 4,
        name: 'Pikachu',
        type: 'Electric',
        level: '100'
      }
    ];

  let [
    selectedKeys,
    setSelectedKeys
  ] = React.useState<
    Selection
  >(new Set());
  return (
    <Table
      aria-label="Pokemon table"
      {...props}
      selectedKeys={selectedKeys}
      onSelectionChange={setSelectedKeys}    >
      <MyTableHeader>
        <Column
          isRowHeader
        >
          Name
        </Column>
        <Column>
          Type
        </Column>
        <Column>
          Level
        </Column>
      </MyTableHeader>
      <TableBody
        items={items}
        renderEmptyState={props
          .renderEmptyState}
      >
        {(item) => (
          <MyRow>
            <Cell>
              {item.name}
            </Cell>
            <Cell>
              {item.type}
            </Cell>
            <Cell>
              {item
                .level}
            </Cell>
          </MyRow>
        )}
      </TableBody>
    </Table>
  );
}

<PokemonTable selectionMode="multiple" />

Selection behavior#

By default, Table uses the "toggle" selection behavior, which behaves like a checkbox group: clicking, tapping, or pressing the Space or Enter keys toggles selection for the focused row. Using the arrow keys moves focus but does not change selection. The "toggle" selection mode is often paired with a column of checkboxes in each row as an explicit affordance for selection.

When the selectionBehavior prop is set to "replace", clicking a row with the mouse replaces the selection with only that row. Using the arrow keys moves both focus and selection. To select multiple rows, modifier keys such as Ctrl, Cmd, and Shift can be used. To move focus without moving selection, the Ctrl key on Windows or the Option key on macOS can be held while pressing the arrow keys. Holding this modifier while pressing the Space key toggles selection for the focused row, which allows multiple selection of non-contiguous items. On touch screen devices, selection always behaves as toggle since modifier keys may not be available. This behavior emulates native platforms such as macOS and Windows, and is often used when checkboxes in each row are not desired.

<PokemonTable selectionMode="multiple" selectionBehavior="replace" />
<PokemonTable
  selectionMode="multiple"
  selectionBehavior="replace"
/>
<PokemonTable
  selectionMode="multiple"
  selectionBehavior="replace"
/>

Row actions#


Table supports row actions via the onRowAction prop, which is useful for functionality such as navigation. In the default "toggle" selection behavior, when nothing is selected, clicking or tapping the row triggers the row action. When at least one item is selected, the table is in selection mode, and clicking or tapping a row toggles the selection. Actions may also be triggered via the Enter key, and selection using the Space key.

This behavior is slightly different in the "replace" selection behavior, where single clicking selects the row and actions are performed via double click. On touch devices, the action becomes the primary tap interaction, and a long press enters into selection mode, which temporarily swaps the selection behavior to "toggle" to perform selection (you may wish to display checkboxes when this happens). Deselecting all items exits selection mode and reverts the selection behavior back to "replace". Keyboard behaviors are unaffected.

<div style={{display: 'flex', flexWrap: 'wrap', gap: '24px'}}>
  <PokemonTable
    aria-label="Pokemon table with row actions and toggle selection behavior"
    onRowAction={key => alert(`Opening item ${key}...`)}    selectionMode="multiple" />
  <PokemonTable
    aria-label="Pokemon table with row actions and replace selection behavior"
    onRowAction={key => alert(`Opening item ${key}...`)}
    selectionBehavior="replace"    selectionMode="multiple" />
</div>
<div
  style={{
    display: 'flex',
    flexWrap: 'wrap',
    gap: '24px'
  }}
>
  <PokemonTable
    aria-label="Pokemon table with row actions and toggle selection behavior"
    onRowAction={(key) => alert(`Opening item ${key}...`)}    selectionMode="multiple"
  />
  <PokemonTable
    aria-label="Pokemon table with row actions and replace selection behavior"
    onRowAction={(key) => alert(`Opening item ${key}...`)}
    selectionBehavior="replace"    selectionMode="multiple"
  />
</div>
<div
  style={{
    display: 'flex',
    flexWrap: 'wrap',
    gap: '24px'
  }}
>
  <PokemonTable
    aria-label="Pokemon table with row actions and toggle selection behavior"
    onRowAction={(key) =>
      alert(
        `Opening item ${key}...`
      )}    selectionMode="multiple"
  />
  <PokemonTable
    aria-label="Pokemon table with row actions and replace selection behavior"
    onRowAction={(key) =>
      alert(
        `Opening item ${key}...`
      )}
    selectionBehavior="replace"    selectionMode="multiple"
  />
</div>

Table rows may also be links to another page or website. This can be achieved by passing the href prop to the <Row> component. Links behave the same way as described above for row actions depending on the selectionMode and selectionBehavior.

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

Client side routing#

The <Row> component works with frameworks and client side routers like Next.js and React Router. As with other React Aria components that support links, this works via the RouterProvider component at the root of your app. See the client side routing guide to learn how to set this up.

Disabled rows#


You can disable specific rows by providing an array of keys to Table via the disabledKeys prop. This will prevent rows from being selectable as shown in the example below. Note that you are responsible for the styling of disabled rows, however, the selection checkbox will be automatically disabled.

// Using the same table as above
<PokemonTable selectionMode="multiple" disabledKeys={[3]} />
// Using the same table as above
<PokemonTable selectionMode="multiple" disabledKeys={[3]} />
// Using the same table as above
<PokemonTable
  selectionMode="multiple"
  disabledKeys={[3]}
/>

By default, only row selection is disabled. When disabledBehavior is set to all, all interactions such as focus, dragging, and actions are also disabled.

<PokemonTable
  selectionMode="multiple"
  disabledKeys={[3]}
  disabledBehavior="all"
/>
<PokemonTable
  selectionMode="multiple"
  disabledKeys={[3]}
  disabledBehavior="all"
/>
<PokemonTable
  selectionMode="multiple"
  disabledKeys={[3]}
  disabledBehavior="all"
/>

Sorting#


Table supports sorting its data when a column header is pressed. To designate that a Column should support sorting, provide it with the allowsSorting prop. The Table accepts a sortDescriptor prop that defines the current column key to sort by and the sort direction (ascending/descending). When the user presses a sortable column header, the column's key and sort direction is passed into the onSortChange callback, allowing you to update the sortDescriptor appropriately.

This example performs client side sorting by passing a sort function to the useAsyncList hook. See the docs for more information on how to perform server side sorting.

import {useAsyncList} from '@react-stately/data';

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

function AsyncSortTable() {
  let list = useAsyncList<Character>({
    async load({ signal }) {
      let res = await fetch(`https://swapi.py4e.com/api/people/?search`, {
        signal
      });
      let json = await res.json();
      return {
        items: json.results
      };
    },
    async sort({ items, sortDescriptor }) {
      return {
        items: items.sort((a, b) => {
          let first = a[sortDescriptor.column];
          let second = b[sortDescriptor.column];
          let cmp = (parseInt(first) || first) < (parseInt(second) || second)
            ? -1
            : 1;
          if (sortDescriptor.direction === 'descending') {
            cmp *= -1;
          }
          return cmp;
        })
      };
    }
  });

  return (
    <Table
      aria-label="Example table with client side sorting"
      sortDescriptor={list.sortDescriptor}
      onSortChange={list.sort}    >
      <TableHeader>
        <MyColumn id="name" isRowHeader allowsSorting>Name</MyColumn>
        <MyColumn id="height" allowsSorting>Height</MyColumn>
        <MyColumn id="mass" allowsSorting>Mass</MyColumn>
        <MyColumn id="birth_year" allowsSorting>Birth Year</MyColumn>
      </TableHeader>
      <TableBody 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>
        )}
      </TableBody>
    </Table>
  );
}
import {useAsyncList} from '@react-stately/data';

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

function AsyncSortTable() {
  let list = useAsyncList<Character>({
    async load({ signal }) {
      let res = await fetch(
        `https://swapi.py4e.com/api/people/?search`,
        { signal }
      );
      let json = await res.json();
      return {
        items: json.results
      };
    },
    async sort({ items, sortDescriptor }) {
      return {
        items: items.sort((a, b) => {
          let first = a[sortDescriptor.column];
          let second = b[sortDescriptor.column];
          let cmp =
            (parseInt(first) || first) <
                (parseInt(second) || second)
              ? -1
              : 1;
          if (sortDescriptor.direction === 'descending') {
            cmp *= -1;
          }
          return cmp;
        })
      };
    }
  });

  return (
    <Table
      aria-label="Example table with client side sorting"
      sortDescriptor={list.sortDescriptor}
      onSortChange={list.sort}    >
      <TableHeader>
        <MyColumn id="name" isRowHeader allowsSorting>
          Name
        </MyColumn>
        <MyColumn id="height" allowsSorting>
          Height
        </MyColumn>
        <MyColumn id="mass" allowsSorting>Mass</MyColumn>
        <MyColumn id="birth_year" allowsSorting>
          Birth Year
        </MyColumn>
      </TableHeader>
      <TableBody 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>
        )}
      </TableBody>
    </Table>
  );
}
import {useAsyncList} from '@react-stately/data';

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

function AsyncSortTable() {
  let list =
    useAsyncList<
      Character
    >({
      async load(
        { signal }
      ) {
        let res =
          await fetch(
            `https://swapi.py4e.com/api/people/?search`,
            { signal }
          );
        let json =
          await res
            .json();
        return {
          items:
            json.results
        };
      },
      async sort(
        {
          items,
          sortDescriptor
        }
      ) {
        return {
          items: items
            .sort(
              (a, b) => {
                let first =
                  a[
                    sortDescriptor
                      .column
                  ];
                let second =
                  b[
                    sortDescriptor
                      .column
                  ];
                let cmp =
                  (parseInt(
                      first
                    ) ||
                      first) <
                      (parseInt(
                        second
                      ) ||
                        second)
                    ? -1
                    : 1;
                if (
                  sortDescriptor
                    .direction ===
                    'descending'
                ) {
                  cmp *=
                    -1;
                }
                return cmp;
              }
            )
        };
      }
    });

  return (
    <Table
      aria-label="Example table with client side sorting"
      sortDescriptor={list
        .sortDescriptor}
      onSortChange={list
        .sort}    >
      <TableHeader>
        <MyColumn
          id="name"
          isRowHeader
          allowsSorting
        >
          Name
        </MyColumn>
        <MyColumn
          id="height"
          allowsSorting
        >
          Height
        </MyColumn>
        <MyColumn
          id="mass"
          allowsSorting
        >
          Mass
        </MyColumn>
        <MyColumn
          id="birth_year"
          allowsSorting
        >
          Birth Year
        </MyColumn>
      </TableHeader>
      <TableBody
        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>
        )}
      </TableBody>
    </Table>
  );
}

Nested columns#


Columns can be nested to create column groups. This will result in more than one header row to be created, with the colspan attribute of each column header cell set to the appropriate value so that the columns line up. Data for the leaf columns appears in each row of the table body.

Static#

This example also shows the use of the isRowHeader prop for Column, which controls which columns are included in the accessibility name for each row. By default, only the first column is included, but in some cases more than one column may be used to represent the row. In this example, the first and last name columns are combined to form the ARIA label for the row. Only leaf columns may be marked as row headers.

<Table aria-label="Example table with nested columns">
  <TableHeader>
    <Column title="Name">
      <Column isRowHeader>First Name</Column>
      <Column isRowHeader>Last Name</Column>
    </Column>
    <Column title="Information">
      <Column>Age</Column>
      <Column>Birthday</Column>
    </Column>
  </TableHeader>
  <TableBody>
    <Row>
      <Cell>Sam</Cell>
      <Cell>Smith</Cell>
      <Cell>36</Cell>
      <Cell>May 3</Cell>
    </Row>
    <Row>
      <Cell>Julia</Cell>
      <Cell>Jones</Cell>
      <Cell>24</Cell>
      <Cell>February 10</Cell>
    </Row>
    <Row>
      <Cell>Peter</Cell>
      <Cell>Parker</Cell>
      <Cell>28</Cell>
      <Cell>September 7</Cell>
    </Row>
    <Row>
      <Cell>Bruce</Cell>
      <Cell>Wayne</Cell>
      <Cell>32</Cell>
      <Cell>December 18</Cell>
    </Row>
  </TableBody>
</Table>
<Table aria-label="Example table with nested columns">
  <TableHeader>
    <Column title="Name">
      <Column isRowHeader>First Name</Column>
      <Column isRowHeader>Last Name</Column>
    </Column>
    <Column title="Information">
      <Column>Age</Column>
      <Column>Birthday</Column>
    </Column>
  </TableHeader>
  <TableBody>
    <Row>
      <Cell>Sam</Cell>
      <Cell>Smith</Cell>
      <Cell>36</Cell>
      <Cell>May 3</Cell>
    </Row>
    <Row>
      <Cell>Julia</Cell>
      <Cell>Jones</Cell>
      <Cell>24</Cell>
      <Cell>February 10</Cell>
    </Row>
    <Row>
      <Cell>Peter</Cell>
      <Cell>Parker</Cell>
      <Cell>28</Cell>
      <Cell>September 7</Cell>
    </Row>
    <Row>
      <Cell>Bruce</Cell>
      <Cell>Wayne</Cell>
      <Cell>32</Cell>
      <Cell>December 18</Cell>
    </Row>
  </TableBody>
</Table>
<Table aria-label="Example table with nested columns">
  <TableHeader>
    <Column title="Name">
      <Column
        isRowHeader
      >
        First Name
      </Column>
      <Column
        isRowHeader
      >
        Last Name
      </Column>
    </Column>
    <Column title="Information">
      <Column>
        Age
      </Column>
      <Column>
        Birthday
      </Column>
    </Column>
  </TableHeader>
  <TableBody>
    <Row>
      <Cell>Sam</Cell>
      <Cell>
        Smith
      </Cell>
      <Cell>36</Cell>
      <Cell>
        May 3
      </Cell>
    </Row>
    <Row>
      <Cell>
        Julia
      </Cell>
      <Cell>
        Jones
      </Cell>
      <Cell>24</Cell>
      <Cell>
        February 10
      </Cell>
    </Row>
    <Row>
      <Cell>
        Peter
      </Cell>
      <Cell>
        Parker
      </Cell>
      <Cell>28</Cell>
      <Cell>
        September 7
      </Cell>
    </Row>
    <Row>
      <Cell>
        Bruce
      </Cell>
      <Cell>
        Wayne
      </Cell>
      <Cell>32</Cell>
      <Cell>
        December 18
      </Cell>
    </Row>
  </TableBody>
</Table>

Dynamic#

Nested columns can also be defined dynamically using the function syntax and the childColumns prop. The following example is the same as the example above, but defined dynamically.

interface ColumnDefinition {
  name: string,
  key: string,
  children?: ColumnDefinition[],
  isRowHeader?: boolean
}

let columns: ColumnDefinition[] = [
  {name: 'Name', key: 'name', children: [
    {name: 'First Name', key: 'first', isRowHeader: true},
    {name: 'Last Name', key: 'last', isRowHeader: true}
  ]},
  {name: 'Information', key: 'info', children: [
    {name: 'Age', key: 'age'},
    {name: 'Birthday', key: 'birthday'}
  ]}
];

let rows = [
  {id: 1, first: 'Sam', last: 'Smith', age: 36, birthday: 'May 3'},
  {id: 2, first: 'Julia', last: 'Jones', age: 24, birthday: 'February 10'},
  {id: 3, first: 'Peter', last: 'Parker', age: 28, birthday: 'September 7'},
  {id: 4, first: 'Bruce', last: 'Wayne', age: 32, birthday: 'December 18'}
];

<Table aria-label="Example table with dynamic nested columns">
  <TableHeader columns={columns}>
    {column => (
      <Column isRowHeader={column.isRowHeader} childColumns={column.children}>
        {column.name}
      </Column>
    )}
  </TableHeader>
  <TableBody items={rows}>
    {item => (
      <Row>
        <Cell>{item.first}</Cell>
        <Cell>{item.last}</Cell>
        <Cell>{item.age}</Cell>
        <Cell>{item.birthday}</Cell>
      </Row>
    )}
  </TableBody>
</Table>
interface ColumnDefinition {
  name: string;
  key: string;
  children?: ColumnDefinition[];
  isRowHeader?: boolean;
}

let columns: ColumnDefinition[] = [
  {
    name: 'Name',
    key: 'name',
    children: [
      {
        name: 'First Name',
        key: 'first',
        isRowHeader: true
      },
      { name: 'Last Name', key: 'last', isRowHeader: true }
    ]
  },
  {
    name: 'Information',
    key: 'info',
    children: [
      { name: 'Age', key: 'age' },
      { name: 'Birthday', key: 'birthday' }
    ]
  }
];

let rows = [
  {
    id: 1,
    first: 'Sam',
    last: 'Smith',
    age: 36,
    birthday: 'May 3'
  },
  {
    id: 2,
    first: 'Julia',
    last: 'Jones',
    age: 24,
    birthday: 'February 10'
  },
  {
    id: 3,
    first: 'Peter',
    last: 'Parker',
    age: 28,
    birthday: 'September 7'
  },
  {
    id: 4,
    first: 'Bruce',
    last: 'Wayne',
    age: 32,
    birthday: 'December 18'
  }
];

<Table aria-label="Example table with dynamic nested columns">
  <TableHeader columns={columns}>
    {(column) => (
      <Column
        isRowHeader={column.isRowHeader}
        childColumns={column.children}
      >
        {column.name}
      </Column>
    )}
  </TableHeader>
  <TableBody items={rows}>
    {(item) => (
      <Row>
        <Cell>{item.first}</Cell>
        <Cell>{item.last}</Cell>
        <Cell>{item.age}</Cell>
        <Cell>{item.birthday}</Cell>
      </Row>
    )}
  </TableBody>
</Table>
interface ColumnDefinition {
  name: string;
  key: string;
  children?:
    ColumnDefinition[];
  isRowHeader?: boolean;
}

let columns:
  ColumnDefinition[] = [
    {
      name: 'Name',
      key: 'name',
      children: [
        {
          name:
            'First Name',
          key: 'first',
          isRowHeader:
            true
        },
        {
          name:
            'Last Name',
          key: 'last',
          isRowHeader:
            true
        }
      ]
    },
    {
      name:
        'Information',
      key: 'info',
      children: [
        {
          name: 'Age',
          key: 'age'
        },
        {
          name:
            'Birthday',
          key: 'birthday'
        }
      ]
    }
  ];

let rows = [
  {
    id: 1,
    first: 'Sam',
    last: 'Smith',
    age: 36,
    birthday: 'May 3'
  },
  {
    id: 2,
    first: 'Julia',
    last: 'Jones',
    age: 24,
    birthday:
      'February 10'
  },
  {
    id: 3,
    first: 'Peter',
    last: 'Parker',
    age: 28,
    birthday:
      'September 7'
  },
  {
    id: 4,
    first: 'Bruce',
    last: 'Wayne',
    age: 32,
    birthday:
      'December 18'
  }
];

<Table aria-label="Example table with dynamic nested columns">
  <TableHeader
    columns={columns}
  >
    {(column) => (
      <Column
        isRowHeader={column
          .isRowHeader}
        childColumns={column
          .children}
      >
        {column.name}
      </Column>
    )}
  </TableHeader>
  <TableBody
    items={rows}
  >
    {(item) => (
      <Row>
        <Cell>
          {item.first}
        </Cell>
        <Cell>
          {item.last}
        </Cell>
        <Cell>
          {item.age}
        </Cell>
        <Cell>
          {item
            .birthday}
        </Cell>
      </Row>
    )}
  </TableBody>
</Table>

Empty state#


Use the renderEmptyState prop to customize what the TableBody will display if there are no items.

<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>
<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>
<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>

Column Resizing#


Table supports resizable columns, allowing users to dynamically adjust the width of a column. This is enabled by wrapping the <Table> with a <ResizableTableContainer> element, which serves as a scrollable container for the Table. Then, to make a column resizable, render a <ColumnResizer> element inside a <Column>. This allows a user to drag a resize handle to change the width of a column. Keyboard users can also resize a column by pressing Enter to enter resizing mode and then using the arrow keys to resize. Screen reader users can resize columns by operating the resizer like a slider.

Width values#

By default, a Table relies on the browser's default table layout algorithm to determine column widths. However, when a Table is wrapped in a <ResizableTableContainer>, column widths are calculated in JavaScript by React Aria. When no additional props are provided, Table divides the available space evenly among the columns. The Column component also supports four different width props that allow you to control column sizing behavior: defaultWidth, width, minWidth, and maxWidth.

An initial, uncontrolled width can be provided to a Column using the defaultWidth prop. This allows the column width to freely shrink and grow in relation to other column widths. Alternatively, a controlled value can be provided by the width prop. These props accept fixed pixel values, percentages of the total table width, or fractional values (the fr unit), which represent a fraction of the available space. Columns without a defined width are equivalent to 1fr.

The minWidth and maxWidth props define constraints on the size of a column, which may be defined either as fixed pixel values or as percentages of the total table width. These are respected when calculating the size of a column, and also provide limits for column resizing.

The example below illustrates how each of the column width props affects their respective column's resize behavior. Note that the column names are wrapped in a <span tabIndex={-1}> so that they can be focused with the keyboard in addition to the <ColumnResizer>.

import {ResizableTableContainer, ColumnResizer} from 'react-aria-components';

<ResizableTableContainer>
  <Table aria-label="Table with resizable columns">
    <TableHeader>
      <Column id="file" isRowHeader maxWidth={500}>
        <div className="flex-wrapper">
          <span tabIndex={-1} className="column-name">File Name</span>
          <ColumnResizer />
        </div>
      </Column>
      <Column id="size" width={80}>Size</Column>
      <Column id="date" minWidth={100}>
        <div className="flex-wrapper">
          <span tabIndex={-1} className="column-name">Date Modified</span>
          <ColumnResizer />
        </div>
      </Column>    </TableHeader>
    <TableBody>
      <Row>
        <Cell>2022-Roadmap-Proposal-Revision-012822-Copy(2)</Cell>
        <Cell>214 KB</Cell>
        <Cell>November 27, 2022 at 4:56PM</Cell>
      </Row>
      <Row>
        <Cell>62259692_p0_master1200</Cell>
        <Cell>120 KB</Cell>
        <Cell>January 27, 2021 at 1:56AM</Cell>
      </Row>
    </TableBody>
  </Table>
</ResizableTableContainer>
import {
  ColumnResizer,
  ResizableTableContainer
} from 'react-aria-components';

<ResizableTableContainer>
  <Table aria-label="Table with resizable columns">
    <TableHeader>
      <Column id="file" isRowHeader maxWidth={500}>
        <div className="flex-wrapper">
          <span tabIndex={-1} className="column-name">
            File Name
          </span>
          <ColumnResizer />
        </div>
      </Column>
      <Column id="size" width={80}>Size</Column>
      <Column id="date" minWidth={100}>
        <div className="flex-wrapper">
          <span tabIndex={-1} className="column-name">
            Date Modified
          </span>
          <ColumnResizer />
        </div>
      </Column>    </TableHeader>
    <TableBody>
      <Row>
        <Cell>
          2022-Roadmap-Proposal-Revision-012822-Copy(2)
        </Cell>
        <Cell>214 KB</Cell>
        <Cell>November 27, 2022 at 4:56PM</Cell>
      </Row>
      <Row>
        <Cell>62259692_p0_master1200</Cell>
        <Cell>120 KB</Cell>
        <Cell>January 27, 2021 at 1:56AM</Cell>
      </Row>
    </TableBody>
  </Table>
</ResizableTableContainer>
import {
  ColumnResizer,
  ResizableTableContainer
} from 'react-aria-components';

<ResizableTableContainer>
  <Table aria-label="Table with resizable columns">
    <TableHeader>
      <Column
        id="file"
        isRowHeader
        maxWidth={500}
      >
        <div className="flex-wrapper">
          <span
            tabIndex={-1}
            className="column-name"
          >
            File Name
          </span>
          <ColumnResizer />
        </div>
      </Column>
      <Column
        id="size"
        width={80}
      >
        Size
      </Column>
      <Column
        id="date"
        minWidth={100}
      >
        <div className="flex-wrapper">
          <span
            tabIndex={-1}
            className="column-name"
          >
            Date
            Modified
          </span>
          <ColumnResizer />
        </div>
      </Column>    </TableHeader>
    <TableBody>
      <Row>
        <Cell>
          2022-Roadmap-Proposal-Revision-012822-Copy(2)
        </Cell>
        <Cell>
          214 KB
        </Cell>
        <Cell>
          November 27,
          2022 at
          4:56PM
        </Cell>
      </Row>
      <Row>
        <Cell>
          62259692_p0_master1200
        </Cell>
        <Cell>
          120 KB
        </Cell>
        <Cell>
          January 27,
          2021 at
          1:56AM
        </Cell>
      </Row>
    </TableBody>
  </Table>
</ResizableTableContainer>
Show CSS
.react-aria-ResizableTableContainer {
  --border-color: var(--spectrum-global-color-gray-400);
  --background-color: var(--page-background);

  max-width: 400px;
  overflow: auto;
  position: relative;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--background-color);

  .react-aria-Table {
    border: none;
  }

  .flex-wrapper {
    display: flex;
    align-items: center;
  }

  .column-name,
  .react-aria-Button {
    flex: 1;
    font: inherit;
    text-align: start;
    color: inherit;
    overflow: hidden;
    text-overflow: ellipsis;

    &:focus-visible {
      outline: 2px solid slateblue;
    }
  }

  .react-aria-ColumnResizer {
    width: 15px;
    background-color: grey;
    height: 25px;
    flex: 0 0 auto;
    touch-action: none;
    box-sizing: border-box;
    border: 5px;
    border-style: none solid;
    border-color: transparent;
    background-clip: content-box;

    &[data-resizable-direction=both] {
      cursor: ew-resize;
    }

    &[data-resizable-direction=left] {
      cursor: e-resize;
    }

    &[data-resizable-direction=right] {
      cursor: w-resize;
    }

    &[data-focus-visible] {
      background-color: slateblue;
    }

    &[data-resizing] {
      border-color: slateblue;
      background-color: transparent;
    }
  }

  .react-aria-Column,
  .react-aria-Cell {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
}

@media (forced-colors: active) {
  .react-aria-ResizableTableContainer {
    --border-color: ButtonBorder;
    --background-color: ButtonFace;
  }
}
.react-aria-ResizableTableContainer {
  --border-color: var(--spectrum-global-color-gray-400);
  --background-color: var(--page-background);

  max-width: 400px;
  overflow: auto;
  position: relative;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--background-color);

  .react-aria-Table {
    border: none;
  }

  .flex-wrapper {
    display: flex;
    align-items: center;
  }

  .column-name,
  .react-aria-Button {
    flex: 1;
    font: inherit;
    text-align: start;
    color: inherit;
    overflow: hidden;
    text-overflow: ellipsis;

    &:focus-visible {
      outline: 2px solid slateblue;
    }
  }

  .react-aria-ColumnResizer {
    width: 15px;
    background-color: grey;
    height: 25px;
    flex: 0 0 auto;
    touch-action: none;
    box-sizing: border-box;
    border: 5px;
    border-style: none solid;
    border-color: transparent;
    background-clip: content-box;

    &[data-resizable-direction=both] {
      cursor: ew-resize;
    }

    &[data-resizable-direction=left] {
      cursor: e-resize;
    }

    &[data-resizable-direction=right] {
      cursor: w-resize;
    }

    &[data-focus-visible] {
      background-color: slateblue;
    }

    &[data-resizing] {
      border-color: slateblue;
      background-color: transparent;
    }
  }

  .react-aria-Column,
  .react-aria-Cell {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
}

@media (forced-colors: active) {
  .react-aria-ResizableTableContainer {
    --border-color: ButtonBorder;
    --background-color: ButtonFace;
  }
}
.react-aria-ResizableTableContainer {
  --border-color: var(--spectrum-global-color-gray-400);
  --background-color: var(--page-background);

  max-width: 400px;
  overflow: auto;
  position: relative;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--background-color);

  .react-aria-Table {
    border: none;
  }

  .flex-wrapper {
    display: flex;
    align-items: center;
  }

  .column-name,
  .react-aria-Button {
    flex: 1;
    font: inherit;
    text-align: start;
    color: inherit;
    overflow: hidden;
    text-overflow: ellipsis;

    &:focus-visible {
      outline: 2px solid slateblue;
    }
  }

  .react-aria-ColumnResizer {
    width: 15px;
    background-color: grey;
    height: 25px;
    flex: 0 0 auto;
    touch-action: none;
    box-sizing: border-box;
    border: 5px;
    border-style: none solid;
    border-color: transparent;
    background-clip: content-box;

    &[data-resizable-direction=both] {
      cursor: ew-resize;
    }

    &[data-resizable-direction=left] {
      cursor: e-resize;
    }

    &[data-resizable-direction=right] {
      cursor: w-resize;
    }

    &[data-focus-visible] {
      background-color: slateblue;
    }

    &[data-resizing] {
      border-color: slateblue;
      background-color: transparent;
    }
  }

  .react-aria-Column,
  .react-aria-Cell {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
}

@media (forced-colors: active) {
  .react-aria-ResizableTableContainer {
    --border-color: ButtonBorder;
    --background-color: ButtonFace;
  }
}

Resize events#

Table accepts an onResize prop which is triggered whenever a column resizer is moved by the user. This can be used in combination with the width prop to update a Column's width in a controlled fashion. Table also accepts an onResizeEnd prop, which is triggered when the user finishes a column resize operation. Both events receive a Map object containing the widths of every column in the Table.

The example below uses onResize to update each of the Table's controlled column widths. It also saves the finalized column widths to localStorage in onResizeEnd, allowing the Table's state to be preserved between page loads and refreshes.

let initialColumns = [
  { name: 'File Name', id: 'file', width: '1fr' },
  { name: 'Size', id: 'size', width: 80 },
  { name: 'Date', id: 'date', width: 100 }
];

function ResizableTable() {
  let [columns, setColumns] = React.useState(() => {
    let localStorageWidths = localStorage.getItem('table-widths');
    if (localStorageWidths) {
      let widths = JSON.parse(localStorageWidths);
      return initialColumns.map((col) => ({ ...col, width: widths[col.id] }));
    } else {
      return initialColumns;
    }
  });

  let onResize = (widths) => {
    setColumns((columns) =>
      columns.map((col) => ({ ...col, width: widths.get(col.id) }))
    );
  };

  let onResizeEnd = (widths) => {
    localStorage.setItem(
      'table-widths',
      JSON.stringify(Object.fromEntries(widths))
    );
  };
  return (
    <ResizableTableContainer
      onResize={onResize}
      onResizeEnd={onResizeEnd}    >
      <Table aria-label="Table with controlled, resizable columns saved in local storage">
        <TableHeader columns={columns}>
          {(column) => (
            <Column isRowHeader={column.id === 'file'} width={column.width}>
              <div className="flex-wrapper">
                <span tabIndex={-1} className="column-name">{column.name}</span>
                <ColumnResizer />
              </div>
            </Column>
          )}
        </TableHeader>
        <TableBody>
          <Row>
            <Cell>2022-Roadmap-Proposal-Revision-012822-Copy(2)</Cell>
            <Cell>214 KB</Cell>
            <Cell>November 27, 2022 at 4:56PM</Cell>
          </Row>
          <Row>
            <Cell>62259692_p0_master1200</Cell>
            <Cell>120 KB</Cell>
            <Cell>January 27, 2021 at 1:56AM</Cell>
          </Row>
        </TableBody>
      </Table>
    </ResizableTableContainer>
  );
}

<ResizableTable />
let initialColumns = [
  { name: 'File Name', id: 'file', width: '1fr' },
  { name: 'Size', id: 'size', width: 80 },
  { name: 'Date', id: 'date', width: 100 }
];

function ResizableTable() {
  let [columns, setColumns] = React.useState(() => {
    let localStorageWidths = localStorage.getItem(
      'table-widths'
    );
    if (localStorageWidths) {
      let widths = JSON.parse(localStorageWidths);
      return initialColumns.map((col) => ({
        ...col,
        width: widths[col.id]
      }));
    } else {
      return initialColumns;
    }
  });

  let onResize = (widths) => {
    setColumns((columns) =>
      columns.map((col) => ({
        ...col,
        width: widths.get(col.id)
      }))
    );
  };

  let onResizeEnd = (widths) => {
    localStorage.setItem(
      'table-widths',
      JSON.stringify(Object.fromEntries(widths))
    );
  };
  return (
    <ResizableTableContainer
      onResize={onResize}
      onResizeEnd={onResizeEnd}    >
      <Table aria-label="Table with controlled, resizable columns saved in local storage">
        <TableHeader columns={columns}>
          {(column) => (
            <Column
              isRowHeader={column.id === 'file'}
              width={column.width}
            >
              <div className="flex-wrapper">
                <span tabIndex={-1} className="column-name">
                  {column.name}
                </span>
                <ColumnResizer />
              </div>
            </Column>
          )}
        </TableHeader>
        <TableBody>
          <Row>
            <Cell>
              2022-Roadmap-Proposal-Revision-012822-Copy(2)
            </Cell>
            <Cell>214 KB</Cell>
            <Cell>November 27, 2022 at 4:56PM</Cell>
          </Row>
          <Row>
            <Cell>62259692_p0_master1200</Cell>
            <Cell>120 KB</Cell>
            <Cell>January 27, 2021 at 1:56AM</Cell>
          </Row>
        </TableBody>
      </Table>
    </ResizableTableContainer>
  );
}

<ResizableTable />
let initialColumns = [
  {
    name: 'File Name',
    id: 'file',
    width: '1fr'
  },
  {
    name: 'Size',
    id: 'size',
    width: 80
  },
  {
    name: 'Date',
    id: 'date',
    width: 100
  }
];

function ResizableTable() {
  let [
    columns,
    setColumns
  ] = React.useState(
    () => {
      let localStorageWidths =
        localStorage
          .getItem(
            'table-widths'
          );
      if (
        localStorageWidths
      ) {
        let widths = JSON
          .parse(
            localStorageWidths
          );
        return initialColumns
          .map(
            (col) => ({
              ...col,
              width:
                widths[
                  col.id
                ]
            })
          );
      } else {
        return initialColumns;
      }
    }
  );

  let onResize = (
    widths
  ) => {
    setColumns(
      (columns) =>
        columns.map(
          (col) => ({
            ...col,
            width: widths
              .get(
                col.id
              )
          })
        )
    );
  };

  let onResizeEnd = (
    widths
  ) => {
    localStorage.setItem(
      'table-widths',
      JSON.stringify(
        Object
          .fromEntries(
            widths
          )
      )
    );
  };
  return (
    <ResizableTableContainer
      onResize={onResize}
      onResizeEnd={onResizeEnd}    >
      <Table aria-label="Table with controlled, resizable columns saved in local storage">
        <TableHeader
          columns={columns}
        >
          {(column) => (
            <Column
              isRowHeader={column
                .id ===
                'file'}
              width={column
                .width}
            >
              <div className="flex-wrapper">
                <span
                  tabIndex={-1}
                  className="column-name"
                >
                  {column
                    .name}
                </span>
                <ColumnResizer />
              </div>
            </Column>
          )}
        </TableHeader>
        <TableBody>
          <Row>
            <Cell>
              2022-Roadmap-Proposal-Revision-012822-Copy(2)
            </Cell>
            <Cell>
              214 KB
            </Cell>
            <Cell>
              November
              27, 2022 at
              4:56PM
            </Cell>
          </Row>
          <Row>
            <Cell>
              62259692_p0_master1200
            </Cell>
            <Cell>
              120 KB
            </Cell>
            <Cell>
              January 27,
              2021 at
              1:56AM
            </Cell>
          </Row>
        </TableBody>
      </Table>
    </ResizableTableContainer>
  );
}

<ResizableTable />

Column header menu#

The Column component exposes a startResize function as part of its render props which allows initiating column resizing programmatically. In addition, sorting can also be performed using the sort function. This enables you to create a dropdown menu that the user can use to sort or resize a column, along with any other custom actions you may have. Using a menu to initiate column resizing provides a larger hit area for touch screen users.

This example shows how to create a reusable component that wraps <Column> to include a menu with sorting and resizing functionality.

import {Button, Item, Menu, MenuTrigger, Popover} from 'react-aria-components';

interface ResizableTableColumnProps<T>
  extends Omit<ColumnProps<T>, 'children'> {
  children: React.ReactNode;
}

function ResizableTableColumn<T extends object>(
  props: ResizableTableColumnProps<T>
) {
  return (
    <Column {...props}>
      {({ startResize, sort, allowsSorting, sortDirection }) => (
        <div className="flex-wrapper">
          <MenuTrigger>
            <Button>{props.children}</Button>
            <Popover>
              <Menu
                onAction={(action) => {
                  if (action === 'sortAscending') {
                    sort('ascending');
                  } else if (action === 'sortDescending') {
                    sort('descending');
                  } else if (action === 'resize') {
                    startResize();
                  }
                }}
              >
                <Item id="sortAscending">Sort Ascending</Item>
                <Item id="sortDescending">Sort Descending</Item>
                <Item id="resize">Resize</Item>
              </Menu>
            </Popover>
          </MenuTrigger>
          {allowsSorting && (
            <span aria-hidden="true" className="sort-indicator">
              {sortDirection === 'ascending' ? '▲' : '▼'}
            </span>
          )}
          <ColumnResizer />
        </div>
      )}
    </Column>
  );
}
import {
  Button,
  Item,
  Menu,
  MenuTrigger,
  Popover
} from 'react-aria-components';

interface ResizableTableColumnProps<T>
  extends Omit<ColumnProps<T>, 'children'> {
  children: React.ReactNode;
}

function ResizableTableColumn<T extends object>(
  props: ResizableTableColumnProps<T>
) {
  return (
    <Column {...props}>
      {(
        { startResize, sort, allowsSorting, sortDirection }
      ) => (
        <div className="flex-wrapper">
          <MenuTrigger>
            <Button>{props.children}</Button>
            <Popover>
              <Menu
                onAction={(action) => {
                  if (action === 'sortAscending') {
                    sort('ascending');
                  } else if (action === 'sortDescending') {
                    sort('descending');
                  } else if (action === 'resize') {
                    startResize();
                  }
                }}
              >
                <Item id="sortAscending">
                  Sort Ascending
                </Item>
                <Item id="sortDescending">
                  Sort Descending
                </Item>
                <Item id="resize">Resize</Item>
              </Menu>
            </Popover>
          </MenuTrigger>
          {allowsSorting && (
            <span
              aria-hidden="true"
              className="sort-indicator"
            >
              {sortDirection === 'ascending' ? '▲' : '▼'}
            </span>
          )}
          <ColumnResizer />
        </div>
      )}
    </Column>
  );
}
import {
  Button,
  Item,
  Menu,
  MenuTrigger,
  Popover
} from 'react-aria-components';

interface ResizableTableColumnProps<
  T
> extends
  Omit<
    ColumnProps<T>,
    'children'
  > {
  children:
    React.ReactNode;
}

function ResizableTableColumn<
  T extends object
>(
  props:
    ResizableTableColumnProps<
      T
    >
) {
  return (
    <Column {...props}>
      {(
        {
          startResize,
          sort,
          allowsSorting,
          sortDirection
        }
      ) => (
        <div className="flex-wrapper">
          <MenuTrigger>
            <Button>
              {props
                .children}
            </Button>
            <Popover>
              <Menu
                onAction={(
                  action
                ) => {
                  if (
                    action ===
                      'sortAscending'
                  ) {
                    sort(
                      'ascending'
                    );
                  } else if (
                    action ===
                      'sortDescending'
                  ) {
                    sort(
                      'descending'
                    );
                  } else if (
                    action ===
                      'resize'
                  ) {
                    startResize();
                  }
                }}
              >
                <Item id="sortAscending">
                  Sort
                  Ascending
                </Item>
                <Item id="sortDescending">
                  Sort
                  Descending
                </Item>
                <Item id="resize">
                  Resize
                </Item>
              </Menu>
            </Popover>
          </MenuTrigger>
          {allowsSorting &&
            (
              <span
                aria-hidden="true"
                className="sort-indicator"
              >
                {sortDirection ===
                    'ascending'
                  ? '▲'
                  : '▼'}
              </span>
            )}
          <ColumnResizer />
        </div>
      )}
    </Column>
  );
}

We can now use this component in place of <Column> to render a table with support for both resizing and sorting columns using a dropdown menu.

import type {SortDescriptor} from 'react-aria-components';

function Example() {
  let [sortDescriptor, setSortDescriptor] = React.useState<SortDescriptor>({
    column: 'file',
    direction: 'ascending'
  });

  let items = [
    // ...
  ].sort((a, b) => {
    let d = a[sortDescriptor.column].localeCompare(b[sortDescriptor.column]);
    return sortDescriptor.direction === 'descending' ? -d : d;
  });

  return (
    <ResizableTableContainer>
      <Table
        aria-label="Table with resizable columns"
        sortDescriptor={sortDescriptor}
        onSortChange={setSortDescriptor}
      >
        <TableHeader>
          <ResizableTableColumn id="file" isRowHeader allowsSorting>
            File Name
          </ResizableTableColumn>
          <ResizableTableColumn id="size" allowsSorting>
            Size
          </ResizableTableColumn>
          <ResizableTableColumn id="date" allowsSorting>
            Date Modified
          </ResizableTableColumn>
        </TableHeader>
        <TableBody items={items}>
          {(item) => (
            <Row>
              <Cell>{item.file}</Cell>
              <Cell>{item.size}</Cell>
              <Cell>{item.date}</Cell>
            </Row>
          )}
        </TableBody>
      </Table>
    </ResizableTableContainer>
  );
}
import type {SortDescriptor} from 'react-aria-components';

function Example() {
  let [sortDescriptor, setSortDescriptor] = React.useState<
    SortDescriptor
  >({
    column: 'file',
    direction: 'ascending'
  });

  let items = [
    // ...
  ].sort((a, b) => {
    let d = a[sortDescriptor.column].localeCompare(
      b[sortDescriptor.column]
    );
    return sortDescriptor.direction === 'descending'
      ? -d
      : d;
  });

  return (
    <ResizableTableContainer>
      <Table
        aria-label="Table with resizable columns"
        sortDescriptor={sortDescriptor}
        onSortChange={setSortDescriptor}
      >
        <TableHeader>
          <ResizableTableColumn
            id="file"
            isRowHeader
            allowsSorting
          >
            File Name
          </ResizableTableColumn>
          <ResizableTableColumn id="size" allowsSorting>
            Size
          </ResizableTableColumn>
          <ResizableTableColumn id="date" allowsSorting>
            Date Modified
          </ResizableTableColumn>
        </TableHeader>
        <TableBody items={items}>
          {(item) => (
            <Row>
              <Cell>{item.file}</Cell>
              <Cell>{item.size}</Cell>
              <Cell>{item.date}</Cell>
            </Row>
          )}
        </TableBody>
      </Table>
    </ResizableTableContainer>
  );
}
import type {SortDescriptor} from 'react-aria-components';

function Example() {
  let [
    sortDescriptor,
    setSortDescriptor
  ] = React.useState<
    SortDescriptor
  >({
    column: 'file',
    direction:
      'ascending'
  });

  let items = [
    // ...
  ].sort((a, b) => {
    let d =
      a[
        sortDescriptor
          .column
      ].localeCompare(
        b[
          sortDescriptor
            .column
        ]
      );
    return sortDescriptor
        .direction ===
        'descending'
      ? -d
      : d;
  });

  return (
    <ResizableTableContainer>
      <Table
        aria-label="Table with resizable columns"
        sortDescriptor={sortDescriptor}
        onSortChange={setSortDescriptor}
      >
        <TableHeader>
          <ResizableTableColumn
            id="file"
            isRowHeader
            allowsSorting
          >
            File Name
          </ResizableTableColumn>
          <ResizableTableColumn
            id="size"
            allowsSorting
          >
            Size
          </ResizableTableColumn>
          <ResizableTableColumn
            id="date"
            allowsSorting
          >
            Date Modified
          </ResizableTableColumn>
        </TableHeader>
        <TableBody
          items={items}
        >
          {(item) => (
            <Row>
              <Cell>
                {item
                  .file}
              </Cell>
              <Cell>
                {item
                  .size}
              </Cell>
              <Cell>
                {item
                  .date}
              </Cell>
            </Row>
          )}
        </TableBody>
      </Table>
    </ResizableTableContainer>
  );
}
Show CSS
.react-aria-Menu {
  --highlight-background: slateblue;
  --highlight-foreground: white;
  --separator-color: var(--spectrum-global-color-gray-500);
  --text-color: var(--spectrum-alias-text-color);
  --text-color-disabled: var(--spectrum-alias-text-color-disabled);

  max-height: inherit;
  box-sizing: border-box;
  overflow: auto;
  padding: 2px;
  margin: 0;
  min-width: 150px;
  box-sizing: border-box;
  outline: none;

  .react-aria-Item {
    margin: 2px;
    padding: 0.286rem 0.571rem;
    border-radius: 6px;
    outline: none;
    cursor: default;
    color: var(--text-color);
    font-size: 1.072rem;
    position: relative;
    display: grid;
    grid-template-areas: "label kbd"
                        "desc  kbd";
    align-items: center;
    column-gap: 20px;

    &[data-focused] {
      background: var(--highlight-background);
      color: var(--highlight-foreground);
    }
  }
}

.react-aria-Popover {
  --background-color: var(--page-background);
  --border-color: var(--spectrum-global-color-gray-400);

  border: 1px solid var(--border-color);
  box-shadow: 0 8px 20px rgba(0 0 0 / 0.1);
  border-radius: 6px;
  background: var(--background-color);
  outline: none;

  &[data-placement=top] {
    --origin: translateY(8px);
  }

  &[data-placement=bottom] {
    --origin: translateY(-8px);
  }

  &[data-entering] {
    animation: slide 200ms;
  }

  &[data-exiting] {
    animation: slide 200ms reverse ease-in;
  }
}

@keyframes slide {
  from {
    transform: var(--origin);
    opacity: 0;
  }

  to {
    transform: translateY(0);
    opacity: 1;
  }
}

@media (forced-colors: active) {
  .react-aria-Menu {
    forced-color-adjust: none;

    --highlight-background: Highlight;
    --highlight-foreground: HighlightText;
    --separator-color: ButtonBorder;
    --text-color: ButtonText;
    --text-color-disabled: GrayText;
  }
}
.react-aria-Menu {
  --highlight-background: slateblue;
  --highlight-foreground: white;
  --separator-color: var(--spectrum-global-color-gray-500);
  --text-color: var(--spectrum-alias-text-color);
  --text-color-disabled: var(--spectrum-alias-text-color-disabled);

  max-height: inherit;
  box-sizing: border-box;
  overflow: auto;
  padding: 2px;
  margin: 0;
  min-width: 150px;
  box-sizing: border-box;
  outline: none;

  .react-aria-Item {
    margin: 2px;
    padding: 0.286rem 0.571rem;
    border-radius: 6px;
    outline: none;
    cursor: default;
    color: var(--text-color);
    font-size: 1.072rem;
    position: relative;
    display: grid;
    grid-template-areas: "label kbd"
                        "desc  kbd";
    align-items: center;
    column-gap: 20px;

    &[data-focused] {
      background: var(--highlight-background);
      color: var(--highlight-foreground);
    }
  }
}

.react-aria-Popover {
  --background-color: var(--page-background);
  --border-color: var(--spectrum-global-color-gray-400);

  border: 1px solid var(--border-color);
  box-shadow: 0 8px 20px rgba(0 0 0 / 0.1);
  border-radius: 6px;
  background: var(--background-color);
  outline: none;

  &[data-placement=top] {
    --origin: translateY(8px);
  }

  &[data-placement=bottom] {
    --origin: translateY(-8px);
  }

  &[data-entering] {
    animation: slide 200ms;
  }

  &[data-exiting] {
    animation: slide 200ms reverse ease-in;
  }
}

@keyframes slide {
  from {
    transform: var(--origin);
    opacity: 0;
  }

  to {
    transform: translateY(0);
    opacity: 1;
  }
}

@media (forced-colors: active) {
  .react-aria-Menu {
    forced-color-adjust: none;

    --highlight-background: Highlight;
    --highlight-foreground: HighlightText;
    --separator-color: ButtonBorder;
    --text-color: ButtonText;
    --text-color-disabled: GrayText;
  }
}
.react-aria-Menu {
  --highlight-background: slateblue;
  --highlight-foreground: white;
  --separator-color: var(--spectrum-global-color-gray-500);
  --text-color: var(--spectrum-alias-text-color);
  --text-color-disabled: var(--spectrum-alias-text-color-disabled);

  max-height: inherit;
  box-sizing: border-box;
  overflow: auto;
  padding: 2px;
  margin: 0;
  min-width: 150px;
  box-sizing: border-box;
  outline: none;

  .react-aria-Item {
    margin: 2px;
    padding: 0.286rem 0.571rem;
    border-radius: 6px;
    outline: none;
    cursor: default;
    color: var(--text-color);
    font-size: 1.072rem;
    position: relative;
    display: grid;
    grid-template-areas: "label kbd"
                        "desc  kbd";
    align-items: center;
    column-gap: 20px;

    &[data-focused] {
      background: var(--highlight-background);
      color: var(--highlight-foreground);
    }
  }
}

.react-aria-Popover {
  --background-color: var(--page-background);
  --border-color: var(--spectrum-global-color-gray-400);

  border: 1px solid var(--border-color);
  box-shadow: 0 8px 20px rgba(0 0 0 / 0.1);
  border-radius: 6px;
  background: var(--background-color);
  outline: none;

  &[data-placement=top] {
    --origin: translateY(8px);
  }

  &[data-placement=bottom] {
    --origin: translateY(-8px);
  }

  &[data-entering] {
    animation: slide 200ms;
  }

  &[data-exiting] {
    animation: slide 200ms reverse ease-in;
  }
}

@keyframes slide {
  from {
    transform: var(--origin);
    opacity: 0;
  }

  to {
    transform: translateY(0);
    opacity: 1;
  }
}

@media (forced-colors: active) {
  .react-aria-Menu {
    forced-color-adjust: none;

    --highlight-background: Highlight;
    --highlight-foreground: HighlightText;
    --separator-color: ButtonBorder;
    --text-color: ButtonText;
    --text-color-disabled: GrayText;
  }
}

Drag and drop#


Table supports drag and drop interactions when the dragAndDropHooks prop is provided using the useDragAndDrop hook. Users can drop data on the table as a whole, on individual rows, insert new items between existing rows, or reorder rows.

React Aria supports traditional mouse and touch based drag and drop, but also implements keyboard and screen reader friendly interactions. Users can press Enter on a draggable element to enter drag and drop mode. Then, they can press Tab to navigate between drop targets. A table is treated as a single drop target, so that users can easily tab past it to get to the next drop target. Within a table, keys such as ArrowDown and ArrowUp can be used to select a drop position, such as on an row, or between rows.

Draggable rows must include a focusable drag handle using a <Button slot="drag">. This enables keyboard and screen reader users to initiate drag and drop. The MyRow component defined in the reusable wrappers section above includes this as an extra column automatically when the table allows dragging.

See the drag and drop introduction to learn more.

Reorderable#

This example shows a basic table that allows users to reorder rows via drag and drop. This is enabled using the onReorder event handler, provided to the useDragAndDrop hook. The getItems function must also be implemented for items to become draggable. See below for more details.

This uses useListData from React Stately to manage the item list. Note that useListData is a convenience hook, not a requirement. You can manage your state however you wish.

import {useListData} from 'react-stately';
import {useDragAndDrop, Button} from 'react-aria-components';

function Example() {
  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></Column>
        <Column><MyCheckbox /></Column>
        <Column isRowHeader>Name</Column>
        <Column>Type</Column>
        <Column>Date Modified</Column>
      </TableHeader>
      <TableBody items={list.items}>
        {item => (
          <Row>
            <Cell><Button slot="drag"></Button></Cell>            <Cell><MyCheckbox /></Cell>
            <Cell>{item.name}</Cell>
            <Cell>{item.type}</Cell>
            <Cell>{item.date}</Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}
import {useListData} from 'react-stately';
import {
  Button,
  useDragAndDrop
} from 'react-aria-components';

function Example() {
  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></Column>
        <Column>
          <MyCheckbox />
        </Column>
        <Column isRowHeader>Name</Column>
        <Column>Type</Column>
        <Column>Date Modified</Column>
      </TableHeader>
      <TableBody items={list.items}>
        {(item) => (
          <Row>
            <Cell>
              <Button slot="drag"></Button>
            </Cell>            <Cell>
              <MyCheckbox />
            </Cell>
            <Cell>{item.name}</Cell>
            <Cell>{item.type}</Cell>
            <Cell>{item.date}</Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}
import {useListData} from 'react-stately';
import {
  Button,
  useDragAndDrop
} from 'react-aria-components';

function Example() {
  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></Column>
        <Column>
          <MyCheckbox />
        </Column>
        <Column
          isRowHeader
        >
          Name
        </Column>
        <Column>
          Type
        </Column>
        <Column>
          Date Modified
        </Column>
      </TableHeader>
      <TableBody
        items={list
          .items}
      >
        {(item) => (
          <Row>
            <Cell>
              <Button slot="drag"></Button>
            </Cell>            <Cell>
              <MyCheckbox />
            </Cell>
            <Cell>
              {item.name}
            </Cell>
            <Cell>
              {item.type}
            </Cell>
            <Cell>
              {item.date}
            </Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}
Show CSS
.react-aria-Row {
  &[data-dragging] {
    opacity: 0.6;
    transform: translateZ(0);
  }

  [slot=drag] {
    all: unset;
    width: 15px;
    text-align: center;

    &[data-focus-visible] {
      border-radius: 4px;
      box-shadow: 0 0 0 2px var(--highlight-background);
    }
  }

  &[data-selected] {
    [slot=drag][data-focus-visible] {
      box-shadow: 0 0 0 2px var(--highlight-foreground);
    }
  }
}

.react-aria-DropIndicator[data-drop-target] {
  outline: 1px solid var(--highlight-background);
  transform: translateZ(0);
}
.react-aria-Row {
  &[data-dragging] {
    opacity: 0.6;
    transform: translateZ(0);
  }

  [slot=drag] {
    all: unset;
    width: 15px;
    text-align: center;

    &[data-focus-visible] {
      border-radius: 4px;
      box-shadow: 0 0 0 2px var(--highlight-background);
    }
  }

  &[data-selected] {
    [slot=drag][data-focus-visible] {
      box-shadow: 0 0 0 2px var(--highlight-foreground);
    }
  }
}

.react-aria-DropIndicator[data-drop-target] {
  outline: 1px solid var(--highlight-background);
  transform: translateZ(0);
}
.react-aria-Row {
  &[data-dragging] {
    opacity: 0.6;
    transform: translateZ(0);
  }

  [slot=drag] {
    all: unset;
    width: 15px;
    text-align: center;

    &[data-focus-visible] {
      border-radius: 4px;
      box-shadow: 0 0 0 2px var(--highlight-background);
    }
  }

  &[data-selected] {
    [slot=drag][data-focus-visible] {
      box-shadow: 0 0 0 2px var(--highlight-foreground);
    }
  }
}

.react-aria-DropIndicator[data-drop-target] {
  outline: 1px solid var(--highlight-background);
  transform: translateZ(0);
}

Custom drag preview#

By default, the drag preview shown under the user's pointer or finger is a copy of the original element that started the drag. A custom preview can be rendered by implementing the renderDragPreview function, passed to useDragAndDrop. This receives the dragged data that was returned by getItems, and returns a rendered preview for those items.

This example renders a custom drag preview which shows the number of items being dragged.

import {useListData} from 'react-stately';
import {useDragAndDrop} from 'react-aria-components';

function Example() {
  let {dragAndDropHooks} = useDragAndDrop({
    // ...
    renderDragPreview(items) {
      return (
        <div className="drag-preview">
          {items[0]['text/plain']}
          <span className="badge">{items.length}</span>
        </div>
      );
    }  });

  // ...
}
import {useListData} from 'react-stately';
import {useDragAndDrop} from 'react-aria-components';

function Example() {
  let {dragAndDropHooks} = useDragAndDrop({
    // ...
    renderDragPreview(items) {
      return (
        <div className="drag-preview">
          {items[0]['text/plain']}
          <span className="badge">{items.length}</span>
        </div>
      );
    }  });

  // ...
}
import {useListData} from 'react-stately';
import {useDragAndDrop} from 'react-aria-components';

function Example() {
  let {
    dragAndDropHooks
  } = useDragAndDrop({
    // ...
    renderDragPreview(
      items
    ) {
      return (
        <div className="drag-preview">
          {items[0][
            'text/plain'
          ]}
          <span className="badge">
            {items
              .length}
          </span>
        </div>
      );
    }  });

  // ...
}
Show CSS
.drag-preview {
  width: 150px;
  padding: 4px 8px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 4px;
  background: slateblue;
  color: white;
  border-radius: 4px;

  .badge {
    background: white;
    color: slateblue;
    padding: 0 8px;
    border-radius: 4px;
  }
}
.drag-preview {
  width: 150px;
  padding: 4px 8px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 4px;
  background: slateblue;
  color: white;
  border-radius: 4px;

  .badge {
    background: white;
    color: slateblue;
    padding: 0 8px;
    border-radius: 4px;
  }
}
.drag-preview {
  width: 150px;
  padding: 4px 8px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 4px;
  background: slateblue;
  color: white;
  border-radius: 4px;

  .badge {
    background: white;
    color: slateblue;
    padding: 0 8px;
    border-radius: 4px;
  }
}

Drag data#

Data for draggable items can be provided in multiple formats at once. This allows drop targets to choose data in a format that they understand. For example, you could serialize a complex object as JSON in a custom format for use within your own application, and also provide plain text and/or rich HTML fallbacks that can be used when a user drops data in an external application (e.g. an email message).

This can be done by returning multiple keys for an item from the getItems function. Types can either be a standard mime type for interoperability with external applications, or a custom string for use within your own app.

This example provides representations of each item as plain text, HTML, and a custom app-specific data format. Dropping on the drop targets in this page will use the custom data format to render formatted items. If you drop in an external application supporting rich text, the HTML representation will be used. Dropping in a text editor will use the plain text format.

function DraggableTable() {
  let items = [
    {id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67'},
    {id: 2, name: 'Blastoise', type: 'Water', level: '56'},
    {id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83'},
    {id: 4, name: 'Pikachu', type: 'Electric', level: '100'}
  ];

  let { dragAndDropHooks } = useDragAndDrop({
    getItems(keys) {
      return [...keys].map(key => {
        let item = items.find(item => item.id === key)!;
        return {
          'text/plain': `${item.name}${item.type}`,
          'text/html': `<strong>${item.name}</strong> – <em>${item.type}</em>`,
          'pokemon': JSON.stringify(item)
        };
      });
    },  });

  return (
    <PokemonTable
      items={items}
      selectionMode="multiple"
      dragAndDropHooks={dragAndDropHooks} />
  );
}

<div style={{display: 'flex', gap: 12, flexWrap: 'wrap'}}>
  <DraggableTable />
  {/* see below */}
  <DroppableTable />
</div>
function DraggableTable() {
  let items = [
    {
      id: 1,
      name: 'Charizard',
      type: 'Fire, Flying',
      level: '67'
    },
    {
      id: 2,
      name: 'Blastoise',
      type: 'Water',
      level: '56'
    },
    {
      id: 3,
      name: 'Venusaur',
      type: 'Grass, Poison',
      level: '83'
    },
    {
      id: 4,
      name: 'Pikachu',
      type: 'Electric',
      level: '100'
    }
  ];

  let { dragAndDropHooks } = useDragAndDrop({
    getItems(keys) {
      return [...keys].map((key) => {
        let item = items.find((item) => item.id === key)!;
        return {
          'text/plain': `${item.name}${item.type}`,
          'text/html':
            `<strong>${item.name}</strong> – <em>${item.type}</em>`,
          'pokemon': JSON.stringify(item)
        };
      });
    }  });

  return (
    <PokemonTable
      items={items}
      selectionMode="multiple"
      dragAndDropHooks={dragAndDropHooks}
    />
  );
}

<div
  style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
>
  <DraggableTable />
  {/* see below */}
  <DroppableTable />
</div>
function DraggableTable() {
  let items = [
    {
      id: 1,
      name: 'Charizard',
      type:
        'Fire, Flying',
      level: '67'
    },
    {
      id: 2,
      name: 'Blastoise',
      type: 'Water',
      level: '56'
    },
    {
      id: 3,
      name: 'Venusaur',
      type:
        'Grass, Poison',
      level: '83'
    },
    {
      id: 4,
      name: 'Pikachu',
      type: 'Electric',
      level: '100'
    }
  ];

  let {
    dragAndDropHooks
  } = useDragAndDrop({
    getItems(keys) {
      return [...keys]
        .map((key) => {
          let item =
            items.find(
              (item) =>
                item
                  .id ===
                  key
            )!;
          return {
            'text/plain':
              `${item.name}${item.type}`,
            'text/html':
              `<strong>${item.name}</strong> – <em>${item.type}</em>`,
            'pokemon':
              JSON
                .stringify(
                  item
                )
          };
        });
    }  });

  return (
    <PokemonTable
      items={items}
      selectionMode="multiple"
      dragAndDropHooks={dragAndDropHooks}
    />
  );
}

<div
  style={{
    display: 'flex',
    gap: 12,
    flexWrap: 'wrap'
  }}
>
  <DraggableTable />
  {/* see below */}
  <DroppableTable />
</div>

Dropping on the collection#

Dropping on the Table as a whole can be enabled using the onRootDrop event. When a valid drag hovers over the Table, it receives the isDropTarget state and can be styled using the [data-drop-target] CSS selector.

import {isTextDropItem} from 'react-aria-components';

function Example() {
  let [items, setItems] = React.useState<Pokemon[]>([]);

  let { dragAndDropHooks } = useDragAndDrop({
    async onRootDrop(e) {
      let items = await Promise.all(
        e.items
          .filter(isTextDropItem)
          .map(async item => (
        JSON.parse(await item.getText('pokemon'))
      )));
      setItems(items);
    }  });

  return (
    <div style={{display: 'flex', gap: 12, flexWrap: 'wrap'}}>
      <DraggableTable />
      <PokemonTable
        items={items}
        dragAndDropHooks={dragAndDropHooks}
        renderEmptyState={() => 'Drop items here'} />
    </div>
  );
}
import {isTextDropItem} from 'react-aria-components';

function Example() {
  let [items, setItems] = React.useState<Pokemon[]>([]);

  let { dragAndDropHooks } = useDragAndDrop({
    async onRootDrop(e) {
      let items = await Promise.all(
        e.items
          .filter(isTextDropItem)
          .map(async (item) => (
            JSON.parse(await item.getText('pokemon'))
          ))
      );
      setItems(items);
    }  });

  return (
    <div
      style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
    >
      <DraggableTable />
      <PokemonTable
        items={items}
        dragAndDropHooks={dragAndDropHooks}
        renderEmptyState={() => 'Drop items here'}
      />
    </div>
  );
}
import {isTextDropItem} from 'react-aria-components';

function Example() {
  let [items, setItems] =
    React.useState<
      Pokemon[]
    >([]);

  let {
    dragAndDropHooks
  } = useDragAndDrop({
    async onRootDrop(e) {
      let items =
        await Promise
          .all(
            e.items
              .filter(
                isTextDropItem
              )
              .map(
                async (item) => (
                  JSON
                    .parse(
                      await item
                        .getText(
                          'pokemon'
                        )
                    )
                )
              )
          );
      setItems(items);
    }  });

  return (
    <div
      style={{
        display: 'flex',
        gap: 12,
        flexWrap: 'wrap'
      }}
    >
      <DraggableTable />
      <PokemonTable
        items={items}
        dragAndDropHooks={dragAndDropHooks}
        renderEmptyState={() =>
          'Drop items here'}
      />
    </div>
  );
}
Show CSS
.react-aria-Table[data-drop-target] {
  border-color: var(--highlight-background);
  box-shadow: 0 0 0 1px var(--highlight-background);
  background: rgb(from slateblue r g b / 15%);
}
.react-aria-Table[data-drop-target] {
  border-color: var(--highlight-background);
  box-shadow: 0 0 0 1px var(--highlight-background);
  background: rgb(from slateblue r g b / 15%);
}
.react-aria-Table[data-drop-target] {
  border-color: var(--highlight-background);
  box-shadow: 0 0 0 1px var(--highlight-background);
  background: rgb(from slateblue r g b / 15%);
}

Dropping on items#

Dropping on items can be enabled using the onItemDrop event. When a valid drag hovers over an item, it receives the isDropTarget state and can be styled using the [data-drop-target] CSS selector.

function Example() {
  let { dragAndDropHooks } = useDragAndDrop({
    onItemDrop(e) {
      alert(`Dropped on ${e.target.key}`);
    }  });

  return (
    <div style={{display: 'flex', gap: 12, flexWrap: 'wrap'}}>
      {/* see above */}
      <DraggableTable />
      <FileTable dragAndDropHooks={dragAndDropHooks} />
    </div>
  );
}
function Example() {
  let { dragAndDropHooks } = useDragAndDrop({
    onItemDrop(e) {
      alert(`Dropped on ${e.target.key}`);
    }  });

  return (
    <div
      style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
    >
      {/* see above */}
      <DraggableTable />
      <FileTable dragAndDropHooks={dragAndDropHooks} />
    </div>
  );
}
function Example() {
  let {
    dragAndDropHooks
  } = useDragAndDrop({
    onItemDrop(e) {
      alert(
        `Dropped on ${e.target.key}`
      );
    }  });

  return (
    <div
      style={{
        display: 'flex',
        gap: 12,
        flexWrap: 'wrap'
      }}
    >
      {/* see above */}
      <DraggableTable />
      <FileTable
        dragAndDropHooks={dragAndDropHooks}
      />
    </div>
  );
}
Show CSS
.react-aria-Row[data-drop-target] {
  outline: 2px solid var(--highlight-background);
  background: rgb(from slateblue r g b / 15%);
}
.react-aria-Row[data-drop-target] {
  outline: 2px solid var(--highlight-background);
  background: rgb(from slateblue r g b / 15%);
}
.react-aria-Row[data-drop-target] {
  outline: 2px solid var(--highlight-background);
  background: rgb(from slateblue r g b / 15%);
}

Dropping between items#

Dropping between items can be enabled using the onInsert event. Table renders a DropIndicator between items to indicate the insertion position, which can be styled using the .react-aria-DropIndicator selector. When it is active, it receives the [data-drop-target] state.

import {isTextDropItem} from 'react-aria-components';

function Example() {
  let list = useListData({
    initialItems: [
      { id: 1, name: 'Bulbasaur', type: 'Grass, Poison', level: '65' },
      { id: 2, name: 'Charmander', type: 'Fire', level: '89' },
      { id: 3, name: 'Squirtle', type: 'Water', level: '77' },
      { id: 4, name: 'Caterpie', type: 'Bug', level: '46' }
    ]
  });

  let { dragAndDropHooks } = useDragAndDrop({
    async onInsert(e) {
      let items = await Promise.all(
        e.items.filter(isTextDropItem).map(async (item) => {
          let { name, type, level } = JSON.parse(await item.getText('pokemon'));
          return { id: Math.random(), name, type, level };
        })
      );

      if (e.target.dropPosition === 'before') {
        list.insertBefore(e.target.key, ...items);
      } else if (e.target.dropPosition === 'after') {
        list.insertAfter(e.target.key, ...items);
      }
    }  });

  return (
    <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
      <DraggableTable />
      <PokemonTable items={list.items} dragAndDropHooks={dragAndDropHooks} />
    </div>
  );
}
import {isTextDropItem} from 'react-aria-components';

function Example() {
  let list = useListData({
    initialItems: [
      {
        id: 1,
        name: 'Bulbasaur',
        type: 'Grass, Poison',
        level: '65'
      },
      {
        id: 2,
        name: 'Charmander',
        type: 'Fire',
        level: '89'
      },
      {
        id: 3,
        name: 'Squirtle',
        type: 'Water',
        level: '77'
      },
      { id: 4, name: 'Caterpie', type: 'Bug', level: '46' }
    ]
  });

  let { dragAndDropHooks } = useDragAndDrop({
    async onInsert(e) {
      let items = await Promise.all(
        e.items.filter(isTextDropItem).map(async (item) => {
          let { name, type, level } = JSON.parse(
            await item.getText('pokemon')
          );
          return { id: Math.random(), name, type, level };
        })
      );

      if (e.target.dropPosition === 'before') {
        list.insertBefore(e.target.key, ...items);
      } else if (e.target.dropPosition === 'after') {
        list.insertAfter(e.target.key, ...items);
      }
    }  });

  return (
    <div
      style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
    >
      <DraggableTable />
      <PokemonTable
        items={list.items}
        dragAndDropHooks={dragAndDropHooks}
      />
    </div>
  );
}
import {isTextDropItem} from 'react-aria-components';

function Example() {
  let list = useListData(
    {
      initialItems: [
        {
          id: 1,
          name:
            'Bulbasaur',
          type:
            'Grass, Poison',
          level: '65'
        },
        {
          id: 2,
          name:
            'Charmander',
          type: 'Fire',
          level: '89'
        },
        {
          id: 3,
          name:
            'Squirtle',
          type: 'Water',
          level: '77'
        },
        {
          id: 4,
          name:
            'Caterpie',
          type: 'Bug',
          level: '46'
        }
      ]
    }
  );

  let {
    dragAndDropHooks
  } = useDragAndDrop({
    async onInsert(e) {
      let items =
        await Promise
          .all(
            e.items
              .filter(
                isTextDropItem
              ).map(
                async (item) => {
                  let {
                    name,
                    type,
                    level
                  } =
                    JSON
                      .parse(
                        await item
                          .getText(
                            'pokemon'
                          )
                      );
                  return {
                    id:
                      Math
                        .random(),
                    name,
                    type,
                    level
                  };
                }
              )
          );

      if (
        e.target
          .dropPosition ===
          'before'
      ) {
        list
          .insertBefore(
            e.target.key,
            ...items
          );
      } else if (
        e.target
          .dropPosition ===
          'after'
      ) {
        list.insertAfter(
          e.target.key,
          ...items
        );
      }
    }  });

  return (
    <div
      style={{
        display: 'flex',
        gap: 12,
        flexWrap: 'wrap'
      }}
    >
      <DraggableTable />
      <PokemonTable
        items={list
          .items}
        dragAndDropHooks={dragAndDropHooks}
      />
    </div>
  );
}
Show CSS
.react-aria-DropIndicator[data-drop-target] {
  outline: 1px solid var(--highlight-background);
  transform: translateZ(0);
}
.react-aria-DropIndicator[data-drop-target] {
  outline: 1px solid var(--highlight-background);
  transform: translateZ(0);
}
.react-aria-DropIndicator[data-drop-target] {
  outline: 1px solid var(--highlight-background);
  transform: translateZ(0);
}

A custom drop indicator can also be rendered with the renderDropIndicator function. This lets you customize the DOM structure and CSS classes applied to the drop indicator.

import {DropIndicator} from 'react-aria-components';
function Example() {
  let { dragAndDropHooks } = useDragAndDrop({
    // ...
    renderDropIndicator(target) {
      return (
        <DropIndicator
          target={target}
          className={({ isDropTarget }) =>
            `my-drop-indicator ${isDropTarget ? 'active' : ''}`}
        />
      );
    }  });

  // ...
}
import {DropIndicator} from 'react-aria-components';
function Example() {
  let { dragAndDropHooks } = useDragAndDrop({
    // ...
    renderDropIndicator(target) {
      return (
        <DropIndicator
          target={target}
          className={({ isDropTarget }) =>
            `my-drop-indicator ${
              isDropTarget ? 'active' : ''
            }`}
        />
      );
    }  });

  // ...
}
import {DropIndicator} from 'react-aria-components';
function Example() {
  let {
    dragAndDropHooks
  } = useDragAndDrop({
    // ...
    renderDropIndicator(
      target
    ) {
      return (
        <DropIndicator
          target={target}
          className={(
            {
              isDropTarget
            }
          ) =>
            `my-drop-indicator ${
              isDropTarget
                ? 'active'
                : ''
            }`}
        />
      );
    }  });

  // ...
}
Show CSS
.my-drop-indicator.active {
  outline: 1px solid #e70073;
  transform: translateZ(0);
}
.my-drop-indicator.active {
  outline: 1px solid #e70073;
  transform: translateZ(0);
}
.my-drop-indicator.active {
  outline: 1px solid #e70073;
  transform: translateZ(0);
}

Drop data#

Table allows users to drop one or more drag items, each of which contains data to be transferred from the drag source to drop target. There are three kinds of drag items:

  • text – represents data inline as a string in one or more formats
  • file – references a file on the user's device
  • directory – references the contents of a directory

Text#

A TextDropItem represents textual data in one or more different formats. These may be either standard mime types or custom app-specific formats. Representing data in multiple formats allows drop targets both within and outside an application to choose data in a format that they understand. For example, a complex object may be serialized in a custom format for use within an application, with fallbacks in plain text and/or rich HTML that can be used when a user drops data from an external application.

The example below uses the acceptedDragTypes prop to accept items that include a custom app-specific type, which is retrieved using the item's getText method. The same draggable component as used in the above example is used here, but rather than displaying the plain text representation, the custom format is used instead. When acceptedDragTypes is specified, the dropped items are filtered to include only items that include the accepted types.

import {isTextDropItem} from 'react-aria-components';

function DroppableTable() {
  let [items, setItems] = React.useState<Pokemon[]>([]);

  let { dragAndDropHooks } = useDragAndDrop({
    acceptedDragTypes: ['pokemon'],
    async onRootDrop(e) {
      let items = await Promise.all(
        e.items
          .filter(isTextDropItem)
          .map(async item => JSON.parse(await item.getText('pokemon')))
      );
      setItems(items);
    }  });

  return (
    <PokemonTable
      items={items}
      dragAndDropHooks={dragAndDropHooks}
      renderEmptyState={() => 'Drop items here'} />
  );
}

<div style={{display: 'flex', gap: 12, flexWrap: 'wrap'}}>
  {/* see above */}
  <DraggableTable />
  <DroppableTable />
</div>
import {isTextDropItem} from 'react-aria-components';

function DroppableTable() {
  let [items, setItems] = React.useState<Pokemon[]>([]);

  let { dragAndDropHooks } = useDragAndDrop({
    acceptedDragTypes: ['pokemon'],
    async onRootDrop(e) {
      let items = await Promise.all(
        e.items
          .filter(isTextDropItem)
          .map(async (item) =>
            JSON.parse(await item.getText('pokemon'))
          )
      );
      setItems(items);
    }  });

  return (
    <PokemonTable
      items={items}
      dragAndDropHooks={dragAndDropHooks}
      renderEmptyState={() => 'Drop items here'}
    />
  );
}

<div
  style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
>
  {/* see above */}
  <DraggableTable />
  <DroppableTable />
</div>
import {isTextDropItem} from 'react-aria-components';

function DroppableTable() {
  let [items, setItems] =
    React.useState<
      Pokemon[]
    >([]);

  let {
    dragAndDropHooks
  } = useDragAndDrop({
    acceptedDragTypes: [
      'pokemon'
    ],
    async onRootDrop(e) {
      let items =
        await Promise
          .all(
            e.items
              .filter(
                isTextDropItem
              )
              .map(
                async (item) =>
                  JSON
                    .parse(
                      await item
                        .getText(
                          'pokemon'
                        )
                    )
              )
          );
      setItems(items);
    }  });

  return (
    <PokemonTable
      items={items}
      dragAndDropHooks={dragAndDropHooks}
      renderEmptyState={() =>
        'Drop items here'}
    />
  );
}

<div
  style={{
    display: 'flex',
    gap: 12,
    flexWrap: 'wrap'
  }}
>
  {/* see above */}
  <DraggableTable />
  <DroppableTable />
</div>

Files#

A FileDropItem references a file on the user's device. It includes the name and mime type of the file, and methods to read the contents as plain text, or retrieve a native File object which can be attached to form data for uploading.

This example accepts JPEG and PNG image files, and renders them by creating a local object URL. When the list is empty, you can drop on the whole collection, and otherwise items can be inserted.

import {isFileDropItem} from 'react-aria-components';

interface ImageItem {
  id: number,
  url: string,
  name: string,
  type: string,
  lastModified: number
}

function Example() {
  let [items, setItems] = React.useState<ImageItem[]>([]);

  let { dragAndDropHooks } = useDragAndDrop({
    acceptedDragTypes: ['image/jpeg', 'image/png'],
    async onRootDrop(e) {
      let items = await Promise.all(
        e.items.filter(isFileDropItem).map(async item => {
          let file = await item.getFile();
          return {
            id: Math.random(),
            url: URL.createObjectURL(file),
            name: item.name,
            type: file.type,
            lastModified: file.lastModified
          };
        })
      );
      setItems(items);
    }  });

  return (
    <Table
      aria-label="Droppable table"
      dragAndDropHooks={dragAndDropHooks}>
      <TableHeader>
        <Column>Image</Column>
        <Column isRowHeader>Name</Column>
        <Column>Type</Column>
        <Column>Last Modified</Column>
      </TableHeader>
      <TableBody items={items} renderEmptyState={() => 'Drop images here'}>
        {item => (
          <Row>
            <Cell><img src={item.url} /></Cell>
            <Cell>{item.name}</Cell>
            <Cell>{item.type}</Cell>
            <Cell>{new Date(item.lastModified).toLocaleString()}</Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}
import {isFileDropItem} from 'react-aria-components';

interface ImageItem {
  id: number;
  url: string;
  name: string;
  type: string;
  lastModified: number;
}

function Example() {
  let [items, setItems] = React.useState<ImageItem[]>([]);

  let { dragAndDropHooks } = useDragAndDrop({
    acceptedDragTypes: ['image/jpeg', 'image/png'],
    async onRootDrop(e) {
      let items = await Promise.all(
        e.items.filter(isFileDropItem).map(async (item) => {
          let file = await item.getFile();
          return {
            id: Math.random(),
            url: URL.createObjectURL(file),
            name: item.name,
            type: file.type,
            lastModified: file.lastModified
          };
        })
      );
      setItems(items);
    }  });

  return (
    <Table
      aria-label="Droppable table"
      dragAndDropHooks={dragAndDropHooks}
    >
      <TableHeader>
        <Column>Image</Column>
        <Column isRowHeader>Name</Column>
        <Column>Type</Column>
        <Column>Last Modified</Column>
      </TableHeader>
      <TableBody
        items={items}
        renderEmptyState={() => 'Drop images here'}
      >
        {(item) => (
          <Row>
            <Cell>
              <img src={item.url} />
            </Cell>
            <Cell>{item.name}</Cell>
            <Cell>{item.type}</Cell>
            <Cell>
              {new Date(item.lastModified).toLocaleString()}
            </Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}
import {isFileDropItem} from 'react-aria-components';

interface ImageItem {
  id: number;
  url: string;
  name: string;
  type: string;
  lastModified: number;
}

function Example() {
  let [items, setItems] =
    React.useState<
      ImageItem[]
    >([]);

  let {
    dragAndDropHooks
  } = useDragAndDrop({
    acceptedDragTypes: [
      'image/jpeg',
      'image/png'
    ],
    async onRootDrop(e) {
      let items =
        await Promise
          .all(
            e.items
              .filter(
                isFileDropItem
              ).map(
                async (item) => {
                  let file =
                    await item
                      .getFile();
                  return {
                    id:
                      Math
                        .random(),
                    url:
                      URL
                        .createObjectURL(
                          file
                        ),
                    name:
                      item
                        .name,
                    type:
                      file
                        .type,
                    lastModified:
                      file
                        .lastModified
                  };
                }
              )
          );
      setItems(items);
    }  });

  return (
    <Table
      aria-label="Droppable table"
      dragAndDropHooks={dragAndDropHooks}
    >
      <TableHeader>
        <Column>
          Image
        </Column>
        <Column
          isRowHeader
        >
          Name
        </Column>
        <Column>
          Type
        </Column>
        <Column>
          Last Modified
        </Column>
      </TableHeader>
      <TableBody
        items={items}
        renderEmptyState={() =>
          'Drop images here'}
      >
        {(item) => (
          <Row>
            <Cell>
              <img
                src={item
                  .url}
              />
            </Cell>
            <Cell>
              {item.name}
            </Cell>
            <Cell>
              {item.type}
            </Cell>
            <Cell>
              {new Date(
                item
                  .lastModified
              ).toLocaleString()}
            </Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}
Show CSS
.react-aria-Cell img {
  height: 30px;
  width: 30px;
  object-fit: cover;
  display: block;
}
.react-aria-Cell img {
  height: 30px;
  width: 30px;
  object-fit: cover;
  display: block;
}
.react-aria-Cell img {
  height: 30px;
  width: 30px;
  object-fit: cover;
  display: block;
}

Directories#

A DirectoryDropItem references the contents of a directory on the user's device. It includes the name of the directory, as well as a method to iterate through the files and folders within the directory. The contents of any folders within the directory can be accessed recursively.

The getEntries method returns an async iterable object, which can be used in a for await...of loop. This provides each item in the directory as either a FileDropItem or DirectoryDropItem, and you can access the contents of each file as discussed above.

This example accepts directory drops over the whole collection, and renders the contents as items in the list. DIRECTORY_DRAG_TYPE is imported from react-aria-components and included in the acceptedDragTypes prop to limit the accepted items to only directories.

import File from '@spectrum-icons/workflow/FileTxt';
import Folder from '@spectrum-icons/workflow/Folder';
import {DIRECTORY_DRAG_TYPE, isDirectoryDropItem} from 'react-aria-components';
interface DirItem {
  name: string,
  kind: string,
  type: string
}

function Example() {
  let [files, setFiles] = React.useState<DirItem[]>([]);

  let { dragAndDropHooks } = useDragAndDrop({
    acceptedDragTypes: [DIRECTORY_DRAG_TYPE],
    async onRootDrop(e) {
      // Read entries in directory and update state with relevant info.
      let dir = e.items.find(isDirectoryDropItem)!;
      let files = [];
      for await (let entry of dir.getEntries()) {
        files.push({
          name: entry.name,
          kind: entry.kind,
          type: entry.kind === 'directory' ? 'Directory' : entry.type
        });
      }
      setFiles(files);
    }  });

  return (
    <Table
      aria-label="Droppable table"
      dragAndDropHooks={dragAndDropHooks}>
      <TableHeader>
        <Column>Kind</Column>
        <Column isRowHeader>Name</Column>
        <Column>Type</Column>
      </TableHeader>
      <TableBody items={files} renderEmptyState={() => 'Drop directory here'}>
        {item => (
          <Row id={item.name}>
            <Cell>{item.kind === 'directory' ? <Folder /> : <File />}</Cell>
            <Cell>{item.name}</Cell>
            <Cell>{item.type}</Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}
import File from '@spectrum-icons/workflow/FileTxt';
import Folder from '@spectrum-icons/workflow/Folder';
import {
  DIRECTORY_DRAG_TYPE,
  isDirectoryDropItem
} from 'react-aria-components';
interface DirItem {
  name: string;
  kind: string;
  type: string;
}

function Example() {
  let [files, setFiles] = React.useState<DirItem[]>([]);

  let { dragAndDropHooks } = useDragAndDrop({
    acceptedDragTypes: [DIRECTORY_DRAG_TYPE],
    async onRootDrop(e) {
      // Read entries in directory and update state with relevant info.
      let dir = e.items.find(isDirectoryDropItem)!;
      let files = [];
      for await (let entry of dir.getEntries()) {
        files.push({
          name: entry.name,
          kind: entry.kind,
          type: entry.kind === 'directory'
            ? 'Directory'
            : entry.type
        });
      }
      setFiles(files);
    }  });

  return (
    <Table
      aria-label="Droppable table"
      dragAndDropHooks={dragAndDropHooks}
    >
      <TableHeader>
        <Column>Kind</Column>
        <Column isRowHeader>Name</Column>
        <Column>Type</Column>
      </TableHeader>
      <TableBody
        items={files}
        renderEmptyState={() => 'Drop directory here'}
      >
        {(item) => (
          <Row id={item.name}>
            <Cell>
              {item.kind === 'directory'
                ? <Folder />
                : <File />}
            </Cell>
            <Cell>{item.name}</Cell>
            <Cell>{item.type}</Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}
import File from '@spectrum-icons/workflow/FileTxt';
import Folder from '@spectrum-icons/workflow/Folder';
import {
  DIRECTORY_DRAG_TYPE,
  isDirectoryDropItem
} from 'react-aria-components';
interface DirItem {
  name: string;
  kind: string;
  type: string;
}

function Example() {
  let [files, setFiles] =
    React.useState<
      DirItem[]
    >([]);

  let {
    dragAndDropHooks
  } = useDragAndDrop({
    acceptedDragTypes: [
      DIRECTORY_DRAG_TYPE
    ],
    async onRootDrop(e) {
      // Read entries in directory and update state with relevant info.
      let dir = e.items
        .find(
          isDirectoryDropItem
        )!;
      let files = [];
      for await (
        let entry of dir
          .getEntries()
      ) {
        files.push({
          name:
            entry.name,
          kind:
            entry.kind,
          type:
            entry
                .kind ===
                'directory'
              ? 'Directory'
              : entry
                .type
        });
      }
      setFiles(files);
    }  });

  return (
    <Table
      aria-label="Droppable table"
      dragAndDropHooks={dragAndDropHooks}
    >
      <TableHeader>
        <Column>
          Kind
        </Column>
        <Column
          isRowHeader
        >
          Name
        </Column>
        <Column>
          Type
        </Column>
      </TableHeader>
      <TableBody
        items={files}
        renderEmptyState={() =>
          'Drop directory here'}
      >
        {(item) => (
          <Row
            id={item
              .name}
          >
            <Cell>
              {item
                  .kind ===
                  'directory'
                ? (
                  <Folder />
                )
                : (
                  <File />
                )}
            </Cell>
            <Cell>
              {item.name}
            </Cell>
            <Cell>
              {item.type}
            </Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}

Drop operations#

A DropOperation is an indication of what will happen when dragged data is dropped on a particular drop target. These are:

  • move – indicates that the dragged data will be moved from its source location to the target location.
  • copy – indicates that the dragged data will be copied to the target destination.
  • link – indicates that there will be a relationship established between the source and target locations.
  • cancel – indicates that the drag and drop operation will be canceled, resulting in no changes made to the source or target.

Many operating systems display these in the form of a cursor change, e.g. a plus sign to indicate a copy operation. The user may also be able to use a modifier key to choose which drop operation to perform, such as Option or Alt to switch from move to copy.

onDragEnd#

The onDragEnd event allows the drag source to respond when a drag that it initiated ends, either because it was dropped or because it was canceled by the user. The dropOperation property of the event object indicates the operation that was performed. For example, when data is moved, the UI could be updated to reflect this change by removing the original dragged items.

This example removes the dragged items from the UI when a move operation is completed. Try holding the Option or Alt keys to change the operation to copy, and see how the behavior changes.

function Example() {
  let list = useListData({
    initialItems: [
      {id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67'},
      {id: 2, name: 'Blastoise', type: 'Water', level: '56'},
      {id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83'},
      {id: 4, name: 'Pikachu', type: 'Electric', level: '100'}
    ]
  });

  let { dragAndDropHooks } = useDragAndDrop({
    // ...
    onDragEnd(e) {
      if (e.dropOperation === 'move') {
        list.remove(...e.keys);
      }
    }  });

  return (
    <div style={{display: 'flex', gap: 12, flexWrap: 'wrap'}}>
      <PokemonTable
        items={list.items}
        selectionMode="multiple"
        dragAndDropHooks={dragAndDropHooks} />
      <DroppableTable />
    </div>
  );
}
function Example() {
  let list = useListData({
    initialItems: [
      {
        id: 1,
        name: 'Charizard',
        type: 'Fire, Flying',
        level: '67'
      },
      {
        id: 2,
        name: 'Blastoise',
        type: 'Water',
        level: '56'
      },
      {
        id: 3,
        name: 'Venusaur',
        type: 'Grass, Poison',
        level: '83'
      },
      {
        id: 4,
        name: 'Pikachu',
        type: 'Electric',
        level: '100'
      }
    ]
  });

  let { dragAndDropHooks } = useDragAndDrop({
    // ...
    onDragEnd(e) {
      if (e.dropOperation === 'move') {
        list.remove(...e.keys);
      }
    }  });

  return (
    <div
      style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
    >
      <PokemonTable
        items={list.items}
        selectionMode="multiple"
        dragAndDropHooks={dragAndDropHooks}
      />
      <DroppableTable />
    </div>
  );
}
function Example() {
  let list = useListData(
    {
      initialItems: [
        {
          id: 1,
          name:
            'Charizard',
          type:
            'Fire, Flying',
          level: '67'
        },
        {
          id: 2,
          name:
            'Blastoise',
          type: 'Water',
          level: '56'
        },
        {
          id: 3,
          name:
            'Venusaur',
          type:
            'Grass, Poison',
          level: '83'
        },
        {
          id: 4,
          name:
            'Pikachu',
          type:
            'Electric',
          level: '100'
        }
      ]
    }
  );

  let {
    dragAndDropHooks
  } = useDragAndDrop({
    // ...
    onDragEnd(e) {
      if (
        e.dropOperation ===
          'move'
      ) {
        list.remove(
          ...e.keys
        );
      }
    }  });

  return (
    <div
      style={{
        display: 'flex',
        gap: 12,
        flexWrap: 'wrap'
      }}
    >
      <PokemonTable
        items={list
          .items}
        selectionMode="multiple"
        dragAndDropHooks={dragAndDropHooks}
      />
      <DroppableTable />
    </div>
  );
}

getAllowedDropOperations#

The drag source can also control which drop operations are allowed for the data. For example, if moving data is not allowed, and only copying is supported, the getAllowedDropOperations function could be implemented to indicate this. When you drag the element below, the cursor now shows the copy affordance by default, and pressing a modifier to switch drop operations results in the drop being canceled.

function Example() {
  // ...
  
  let { dragAndDropHooks } = useDragAndDrop({
    // ...
    getAllowedDropOperations: () => ['copy']  });

  return (
    <div style={{display: 'flex', gap: 12, flexWrap: 'wrap'}}>
      <PokemonTable
        items={list.items}
        selectionMode="multiple"
        dragAndDropHooks={dragAndDropHooks} />
      <DroppableTable />
    </div>
  );
}
function Example() {
  // ...

  let { dragAndDropHooks } = useDragAndDrop({
    // ...
    getAllowedDropOperations: () => ['copy']  });

  return (
    <div
      style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
    >
      <PokemonTable
        items={list.items}
        selectionMode="multiple"
        dragAndDropHooks={dragAndDropHooks}
      />
      <DroppableTable />
    </div>
  );
}
function Example() {
  // ...

  let {
    dragAndDropHooks
  } = useDragAndDrop({
    // ...
    getAllowedDropOperations:
      () => ['copy']  });

  return (
    <div
      style={{
        display: 'flex',
        gap: 12,
        flexWrap: 'wrap'
      }}
    >
      <PokemonTable
        items={list
          .items}
        selectionMode="multiple"
        dragAndDropHooks={dragAndDropHooks}
      />
      <DroppableTable />
    </div>
  );
}

getDropOperation#

The getDropOperation function passed to useDragAndDrop can be used to provide appropriate feedback to the user when a drag hovers over the drop target. This function receives the drop target, set of types contained in the drag, and a list of allowed drop operations as specified by the drag source. It should return one of the drop operations in allowedOperations, or a specific drop operation if only that drop operation is supported. It may also return 'cancel' to reject the drop. If the returned operation is not in allowedOperations, then the drop target will act as if 'cancel' was returned.

In the below example, the drop target only supports dropping PNG images. If a PNG is dragged over the target, it will be highlighted and the operating system displays a copy cursor. If another type is dragged over the target, then there is no visual feedback, indicating that a drop is not accepted there. If the user holds a modifier key such as Control while dragging over the drop target in order to change the drop operation, then the drop target does not accept the drop.

function Example() {
  let [items, setItems] = React.useState<ImageItem[]>([]);

  let { dragAndDropHooks } = useDragAndDrop({
    getDropOperation: () => 'copy',    acceptedDragTypes: ['image/png'],
    async onRootDrop(e) {
      // ...
    }
  });

  // See "Files" example above...
}
function Example() {
  let [items, setItems] = React.useState<ImageItem[]>([]);

  let { dragAndDropHooks } = useDragAndDrop({
    getDropOperation: () => 'copy',    acceptedDragTypes: ['image/png'],
    async onRootDrop(e) {
      // ...
    }
  });

  // See "Files" example above...
}
function Example() {
  let [items, setItems] =
    React.useState<
      ImageItem[]
    >([]);

  let {
    dragAndDropHooks
  } = useDragAndDrop({
    getDropOperation:
      () => 'copy',    acceptedDragTypes: [
      'image/png'
    ],
    async onRootDrop(e) {
      // ...
    }
  });

  // See "Files" example above...
}

Drop events#

Drop events such as onInsert, onItemDrop, etc. also include the dropOperation. This can be used to perform different actions accordingly, for example, when communicating with a backend API.

let onItemDrop = async (e) => {
  let data = JSON.parse(await e.items[0].getText('my-app-file'));
  switch (e.dropOperation) {
    case 'move':
      MyAppFileService.move(data.filePath, props.filePath);
      break;
    case 'copy':
      MyAppFileService.copy(data.filePath, props.filePath);
      break;
    case 'link':
      MyAppFileService.link(data.filePath, props.filePath);
      break;
  }};
let onItemDrop = async (e) => {
  let data = JSON.parse(
    await e.items[0].getText('my-app-file')
  );
  switch (e.dropOperation) {
    case 'move':
      MyAppFileService.move(data.filePath, props.filePath);
      break;
    case 'copy':
      MyAppFileService.copy(data.filePath, props.filePath);
      break;
    case 'link':
      MyAppFileService.link(data.filePath, props.filePath);
      break;
  }};
let onItemDrop = async (
  e
) => {
  let data = JSON.parse(
    await e.items[0]
      .getText(
        'my-app-file'
      )
  );
  switch (
    e.dropOperation
  ) {
    case 'move':
      MyAppFileService
        .move(
          data.filePath,
          props.filePath
        );
      break;
    case 'copy':
      MyAppFileService
        .copy(
          data.filePath,
          props.filePath
        );
      break;
    case 'link':
      MyAppFileService
        .link(
          data.filePath,
          props.filePath
        );
      break;
  }};

Drag between tables#

This example puts together many of the concepts described above, allowing users to drag items between tables bidirectionally. It also supports reordering items within the same table. When a table is empty, it accepts drops on the whole collection. getDropOperation ensures that items are always moved rather than copied, which avoids duplicate items.

import {isTextDropItem} from 'react-aria-components';

interface FileItem {
  id: string,
  name: string,
  type: string
}

interface DndTableProps {
  initialItems: FileItem[],
  'aria-label': string
}

function DndTable(props: DndTableProps) {
  let list = useListData({
    initialItems: props.initialItems
  });

  let { dragAndDropHooks } = useDragAndDrop({
    // Provide drag data in a custom format as well as plain text.
    getItems(keys) {
      return [...keys].map((key) => {
        let item = list.getItem(key);
        return {
          'custom-app-type': JSON.stringify(item),
          'text/plain': item.name
        };
      });
    },

    // Accept drops with the custom format.
    acceptedDragTypes: ['custom-app-type'],

    // Ensure items are always moved rather than copied.
    getDropOperation: () => 'move',

    // Handle drops between items from other lists.
    async onInsert(e) {
      let processedItems = await Promise.all(
        e.items
          .filter(isTextDropItem)
          .map(async item => JSON.parse(await item.getText('custom-app-type')))
      );
      if (e.target.dropPosition === 'before') {
        list.insertBefore(e.target.key, ...processedItems);
      } else if (e.target.dropPosition === 'after') {
        list.insertAfter(e.target.key, ...processedItems);
      }
    },

    // Handle drops on the collection when empty.
    async onRootDrop(e) {
      let processedItems = await Promise.all(
        e.items
          .filter(isTextDropItem)
          .map(async item => JSON.parse(await item.getText('custom-app-type')))
      );
      list.append(...processedItems);
    },

    // Handle reordering items within the same list.
    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);
      }
    },

    // Remove the items from the source list on drop
    // if they were moved to a different list.
    onDragEnd(e) {
      if (e.dropOperation === 'move' && !e.isInternal) {
        list.remove(...e.keys);
      }
    }
  });

  return (
    <Table
      aria-label={props['aria-label']}
      selectionMode="multiple"
      selectedKeys={list.selectedKeys}
      onSelectionChange={list.setSelectedKeys}
      dragAndDropHooks={dragAndDropHooks}>
      <TableHeader>
        <Column />
        <Column><MyCheckbox /></Column>
        <Column>ID</Column>
        <Column isRowHeader>Name</Column>
        <Column>Type</Column>
      </TableHeader>
      <TableBody items={list.items} renderEmptyState={() => 'Drop items here'}>
        {item => (
          <Row>
            <Cell><Button slot="drag"></Button></Cell>
            <Cell><MyCheckbox /></Cell>
            <Cell>{item.id}</Cell>
            <Cell>{item.name}</Cell>
            <Cell>{item.type}</Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}

<div style={{display: 'flex', gap: 12, flexWrap: 'wrap'}}>
  <DndTable
    initialItems={[
      { id: '1', type: 'file', name: 'Adobe Photoshop' },
      { id: '2', type: 'file', name: 'Adobe XD' },
      { id: '3', type: 'folder', name: 'Documents' },
      { id: '4', type: 'file', name: 'Adobe InDesign' },
      { id: '5', type: 'folder', name: 'Utilities' },
      { id: '6', type: 'file', name: 'Adobe AfterEffects' }
    ]}
    aria-label="First Table"
  />
  <DndTable
    initialItems={[
      { id: '7', type: 'folder', name: 'Pictures' },
      { id: '8', type: 'file', name: 'Adobe Fresco' },
      { id: '9', type: 'folder', name: 'Apps' },
      { id: '10', type: 'file', name: 'Adobe Illustrator' },
      { id: '11', type: 'file', name: 'Adobe Lightroom' },
      { id: '12', type: 'file', name: 'Adobe Dreamweaver' }
    ]}
    aria-label="Second Table"
  />
</div>
import {isTextDropItem} from 'react-aria-components';

interface FileItem {
  id: string;
  name: string;
  type: string;
}

interface DndTableProps {
  initialItems: FileItem[];
  'aria-label': string;
}

function DndTable(props: DndTableProps) {
  let list = useListData({
    initialItems: props.initialItems
  });

  let { dragAndDropHooks } = useDragAndDrop({
    // Provide drag data in a custom format as well as plain text.
    getItems(keys) {
      return [...keys].map((key) => {
        let item = list.getItem(key);
        return {
          'custom-app-type': JSON.stringify(item),
          'text/plain': item.name
        };
      });
    },

    // Accept drops with the custom format.
    acceptedDragTypes: ['custom-app-type'],

    // Ensure items are always moved rather than copied.
    getDropOperation: () => 'move',

    // Handle drops between items from other lists.
    async onInsert(e) {
      let processedItems = await Promise.all(
        e.items
          .filter(isTextDropItem)
          .map(async (item) =>
            JSON.parse(
              await item.getText('custom-app-type')
            )
          )
      );
      if (e.target.dropPosition === 'before') {
        list.insertBefore(e.target.key, ...processedItems);
      } else if (e.target.dropPosition === 'after') {
        list.insertAfter(e.target.key, ...processedItems);
      }
    },

    // Handle drops on the collection when empty.
    async onRootDrop(e) {
      let processedItems = await Promise.all(
        e.items
          .filter(isTextDropItem)
          .map(async (item) =>
            JSON.parse(
              await item.getText('custom-app-type')
            )
          )
      );
      list.append(...processedItems);
    },

    // Handle reordering items within the same list.
    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);
      }
    },

    // Remove the items from the source list on drop
    // if they were moved to a different list.
    onDragEnd(e) {
      if (e.dropOperation === 'move' && !e.isInternal) {
        list.remove(...e.keys);
      }
    }
  });

  return (
    <Table
      aria-label={props['aria-label']}
      selectionMode="multiple"
      selectedKeys={list.selectedKeys}
      onSelectionChange={list.setSelectedKeys}
      dragAndDropHooks={dragAndDropHooks}
    >
      <TableHeader>
        <Column />
        <Column>
          <MyCheckbox />
        </Column>
        <Column>ID</Column>
        <Column isRowHeader>Name</Column>
        <Column>Type</Column>
      </TableHeader>
      <TableBody
        items={list.items}
        renderEmptyState={() => 'Drop items here'}
      >
        {(item) => (
          <Row>
            <Cell>
              <Button slot="drag"></Button>
            </Cell>
            <Cell>
              <MyCheckbox />
            </Cell>
            <Cell>{item.id}</Cell>
            <Cell>{item.name}</Cell>
            <Cell>{item.type}</Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}

<div
  style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}
>
  <DndTable
    initialItems={[
      { id: '1', type: 'file', name: 'Adobe Photoshop' },
      { id: '2', type: 'file', name: 'Adobe XD' },
      { id: '3', type: 'folder', name: 'Documents' },
      { id: '4', type: 'file', name: 'Adobe InDesign' },
      { id: '5', type: 'folder', name: 'Utilities' },
      {
        id: '6',
        type: 'file',
        name: 'Adobe AfterEffects'
      }
    ]}
    aria-label="First Table"
  />
  <DndTable
    initialItems={[
      { id: '7', type: 'folder', name: 'Pictures' },
      { id: '8', type: 'file', name: 'Adobe Fresco' },
      { id: '9', type: 'folder', name: 'Apps' },
      {
        id: '10',
        type: 'file',
        name: 'Adobe Illustrator'
      },
      { id: '11', type: 'file', name: 'Adobe Lightroom' },
      {
        id: '12',
        type: 'file',
        name: 'Adobe Dreamweaver'
      }
    ]}
    aria-label="Second Table"
  />
</div>
import {isTextDropItem} from 'react-aria-components';

interface FileItem {
  id: string;
  name: string;
  type: string;
}

interface DndTableProps {
  initialItems:
    FileItem[];
  'aria-label': string;
}

function DndTable(
  props: DndTableProps
) {
  let list = useListData(
    {
      initialItems:
        props
          .initialItems
    }
  );

  let {
    dragAndDropHooks
  } = useDragAndDrop({
    // Provide drag data in a custom format as well as plain text.
    getItems(keys) {
      return [...keys]
        .map((key) => {
          let item = list
            .getItem(
              key
            );
          return {
            'custom-app-type':
              JSON
                .stringify(
                  item
                ),
            'text/plain':
              item.name
          };
        });
    },

    // Accept drops with the custom format.
    acceptedDragTypes: [
      'custom-app-type'
    ],

    // Ensure items are always moved rather than copied.
    getDropOperation:
      () => 'move',

    // Handle drops between items from other lists.
    async onInsert(e) {
      let processedItems =
        await Promise
          .all(
            e.items
              .filter(
                isTextDropItem
              )
              .map(
                async (item) =>
                  JSON
                    .parse(
                      await item
                        .getText(
                          'custom-app-type'
                        )
                    )
              )
          );
      if (
        e.target
          .dropPosition ===
          'before'
      ) {
        list
          .insertBefore(
            e.target.key,
            ...processedItems
          );
      } else if (
        e.target
          .dropPosition ===
          'after'
      ) {
        list.insertAfter(
          e.target.key,
          ...processedItems
        );
      }
    },

    // Handle drops on the collection when empty.
    async onRootDrop(e) {
      let processedItems =
        await Promise
          .all(
            e.items
              .filter(
                isTextDropItem
              )
              .map(
                async (item) =>
                  JSON
                    .parse(
                      await item
                        .getText(
                          'custom-app-type'
                        )
                    )
              )
          );
      list.append(
        ...processedItems
      );
    },

    // Handle reordering items within the same list.
    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
        );
      }
    },

    // Remove the items from the source list on drop
    // if they were moved to a different list.
    onDragEnd(e) {
      if (
        e.dropOperation ===
          'move' &&
        !e.isInternal
      ) {
        list.remove(
          ...e.keys
        );
      }
    }
  });

  return (
    <Table
      aria-label={props[
        'aria-label'
      ]}
      selectionMode="multiple"
      selectedKeys={list
        .selectedKeys}
      onSelectionChange={list
        .setSelectedKeys}
      dragAndDropHooks={dragAndDropHooks}
    >
      <TableHeader>
        <Column />
        <Column>
          <MyCheckbox />
        </Column>
        <Column>
          ID
        </Column>
        <Column
          isRowHeader
        >
          Name
        </Column>
        <Column>
          Type
        </Column>
      </TableHeader>
      <TableBody
        items={list
          .items}
        renderEmptyState={() =>
          'Drop items here'}
      >
        {(item) => (
          <Row>
            <Cell>
              <Button slot="drag"></Button>
            </Cell>
            <Cell>
              <MyCheckbox />
            </Cell>
            <Cell>
              {item.id}
            </Cell>
            <Cell>
              {item.name}
            </Cell>
            <Cell>
              {item.type}
            </Cell>
          </Row>
        )}
      </TableBody>
    </Table>
  );
}

<div
  style={{
    display: 'flex',
    gap: 12,
    flexWrap: 'wrap'
  }}
>
  <DndTable
    initialItems={[
      {
        id: '1',
        type: 'file',
        name:
          'Adobe Photoshop'
      },
      {
        id: '2',
        type: 'file',
        name:
          'Adobe XD'
      },
      {
        id: '3',
        type: 'folder',
        name:
          'Documents'
      },
      {
        id: '4',
        type: 'file',
        name:
          'Adobe InDesign'
      },
      {
        id: '5',
        type: 'folder',
        name:
          'Utilities'
      },
      {
        id: '6',
        type: 'file',
        name:
          'Adobe AfterEffects'
      }
    ]}
    aria-label="First Table"
  />
  <DndTable
    initialItems={[
      {
        id: '7',
        type: 'folder',
        name:
          'Pictures'
      },
      {
        id: '8',
        type: 'file',
        name:
          'Adobe Fresco'
      },
      {
        id: '9',
        type: 'folder',
        name: 'Apps'
      },
      {
        id: '10',
        type: 'file',
        name:
          'Adobe Illustrator'
      },
      {
        id: '11',
        type: 'file',
        name:
          'Adobe Lightroom'
      },
      {
        id: '12',
        type: 'file',
        name:
          'Adobe Dreamweaver'
      }
    ]}
    aria-label="Second Table"
  />
</div>

Props#


Table#

NameTypeDefaultDescription
childrenReactNodeThe elements that make up the table. Includes the TableHeader, TableBody, Columns, and Rows.
selectionBehaviorSelectionBehavior"toggle"How multiple selection should behave in the collection.
disabledBehaviorDisabledBehavior"selection"Whether disabledKeys applies to all interactions, or only selection.
dragAndDropHooksDragAndDropHooksThe drag and drop hooks returned by useDragAndDrop used to enable drag and drop behavior for the Table.
disabledKeysIterable<Key>A list of row keys to disable.
selectionModeSelectionModeThe type of selection that is allowed in the collection.
disallowEmptySelectionbooleanWhether the collection allows empty selection.
selectedKeys'all'Iterable<Key>The currently selected keys in the collection (controlled).
defaultSelectedKeys'all'Iterable<Key>The initial selected keys in the collection (uncontrolled).
sortDescriptorSortDescriptorThe current sorted column and direction.
classNamestring( (values: TableRenderProps )) => stringThe CSS className for the element. A function may be provided to compute the class based on component state.
styleCSSProperties( (values: TableRenderProps )) => CSSPropertiesThe inline style for the element. A function may be provided to compute the style based on component state.
Events
NameTypeDescription
onRowAction( (key: Key )) => voidHandler that is called when a user performs an action on the row.
onCellAction( (key: Key )) => voidHandler that is called when a user performs an action on the cell.
onSelectionChange( (keys: Selection )) => anyHandler that is called when the selection changes.
onSortChange( (descriptor: SortDescriptor )) => anyHandler that is called when the sorted column or direction changes.
Layout
NameTypeDescription
slotstringnull

A slot name for the component. Slots allow the component to receive props from a parent component. An explicit null value indicates that the local props completely override all props received from a parent.

Accessibility
NameTypeDescription
aria-labelstringDefines a string value that labels the current element.
aria-labelledbystringIdentifies the element (or elements) that labels the current element.
aria-describedbystringIdentifies the element (or elements) that describes the object.
aria-detailsstringIdentifies the element (or elements) that provide a detailed, extended description for the object.

TableHeader#

NameTypeDescription
columnsobject[]A list of table columns.
childrenReactNode( (item: object )) => ReactElementA list of Column(s) or a function. If the latter, a list of columns must be provided using the columns prop.
classNamestringThe CSS className for the element.
styleCSSPropertiesThe inline style for the element.

Column#

NameTypeDescription
titleReactNodeRendered contents of the column if children contains child columns.
childColumnsIterable<object>A list of child columns used when dynamically rendering nested child columns.
allowsSortingbooleanWhether the column allows sorting.
isRowHeaderbooleanWhether a column is a row header and should be announced by assistive technology during row navigation.
textValuestringA string representation of the column's contents, used for accessibility announcements.
childrenReactNode( (values: ColumnRenderProps )) => ReactNodeThe children of the component. A function may be provided to alter the children based on component state.
classNamestring( (values: ColumnRenderProps )) => stringThe CSS className for the element. A function may be provided to compute the class based on component state.
styleCSSProperties( (values: ColumnRenderProps )) => CSSPropertiesThe inline style for the element. A function may be provided to compute the style based on component state.
Sizing
NameTypeDescription
widthColumnSizenullThe width of the column. This prop only applies when the <Table> is wrapped in a <ResizableTableContainer>.
minWidthColumnStaticSizenullThe minimum width of the column. This prop only applies when the <Table> is wrapped in a <ResizableTableContainer>.
maxWidthColumnStaticSizenullThe maximum width of the column. This prop only applies when the <Table> is wrapped in a <ResizableTableContainer>.
defaultWidthColumnSizenullThe default width of the column. This prop only applies when the <Table> is wrapped in a <ResizableTableContainer>.
Accessibility
NameTypeDescription
idKey

TableBody#

NameTypeDescription
renderEmptyState( (props: TableBodyRenderProps )) => ReactNodeProvides content to display when there are no rows in the table.
childrenReactNode( (item: object )) => ReactNodeThe contents of the collection.
itemsIterable<object>Item objects in the collection.
disabledKeysIterable<Key>The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with.
classNamestring( (values: TableBodyRenderProps )) => stringThe CSS className for the element. A function may be provided to compute the class based on component state.
styleCSSProperties( (values: TableBodyRenderProps )) => CSSPropertiesThe inline style for the element. A function may be provided to compute the style based on component state.

Row#

NameTypeDescription
columnsIterable<object>A list of columns used when dynamically rendering cells.
childrenReactNode( (item: object )) => ReactElementThe cells within the row. Supports static items or a function for dynamic rendering.
textValuestringA string representation of the row's contents, used for features like typeahead.
classNamestring( (values: RowRenderProps )) => stringThe CSS className for the element. A function may be provided to compute the class based on component state.
styleCSSProperties( (values: RowRenderProps )) => CSSPropertiesThe inline style for the element. A function may be provided to compute the style based on component state.
hrefstringA URL to link to. See MDN.
targetHTMLAttributeAnchorTargetThe target window for the link. See MDN.
relstringThe relationship between the linked resource and the current page. See MDN.
downloadbooleanstringCauses the browser to download the linked URL. A string may be provided to suggest a file name. See MDN.
pingstringA space-separated list of URLs to ping when the link is followed. See MDN.
referrerPolicyHTMLAttributeReferrerPolicyHow much of the referrer to send when following the link. See MDN.
Accessibility
NameTypeDescription
idKey

Cell#

NameTypeDescription
textValuestringA string representation of the cell's contents, used for features like typeahead.
childrenReactNode( (values: CellRenderProps )) => ReactNodeThe children of the component. A function may be provided to alter the children based on component state.
classNamestring( (values: CellRenderProps )) => stringThe CSS className for the element. A function may be provided to compute the class based on component state.
styleCSSProperties( (values: CellRenderProps )) => CSSPropertiesThe inline style for the element. A function may be provided to compute the style based on component state.
Accessibility
NameTypeDescription
idKey

ResizableTableContainer#

NameTypeDescription
childrenReactNodeThe children of the component.
classNamestringThe CSS className for the element.
styleCSSPropertiesThe inline style for the element.
Events
NameTypeDescription
onResizeStart( (widths: Map<Key, ColumnSize> )) => voidHandler that is called when a user starts a column resize.
onResize( (widths: Map<Key, ColumnSize> )) => void

Handler that is called when a user performs a column resize. Can be used with the width property on columns to put the column widths into a controlled state.

onResizeEnd( (widths: Map<Key, ColumnSize> )) => void

Handler that is called after a user performs a column resize. Can be used to store the widths of columns for another future session.

ColumnResizer#

NameTypeDescription
childrenReactNode( (values: ColumnResizerRenderProps )) => ReactNodeThe children of the component. A function may be provided to alter the children based on component state.
classNamestring( (values: ColumnResizerRenderProps )) => stringThe CSS className for the element. A function may be provided to compute the class based on component state.
styleCSSProperties( (values: ColumnResizerRenderProps )) => CSSPropertiesThe inline style for the element. A function may be provided to compute the style based on component state.
Accessibility
NameTypeDescription
aria-labelstringA custom accessibility label for the resizer.

Styling#


React Aria components can be styled in many ways, including using CSS classes, inline styles, utility classes (e.g. Tailwind), CSS-in-JS (e.g. Styled Components), etc. By default, all components include a builtin className attribute which can be targeted using CSS selectors. These follow the react-aria-ComponentName naming convention.

.react-aria-Table {
  /* ... */
}
.react-aria-Table {
  /* ... */
}
.react-aria-Table {
  /* ... */
}

A custom className can also be specified on any component. This overrides the default className provided by React Aria with your own.

<Table className="my-table">
  {/* ... */}
</Table>
<Table className="my-table">
  {/* ... */}
</Table>
<Table className="my-table">
  {/* ... */}
</Table>

In addition, some components support multiple UI states (e.g. pressed, hovered, etc.). React Aria components expose states using data attributes, which you can target in CSS selectors. For example:

.react-aria-Row[data-selected] {
  /* ... */
}

.react-aria-Row[data-focused] {
  /* ... */
}
.react-aria-Row[data-selected] {
  /* ... */
}

.react-aria-Row[data-focused] {
  /* ... */
}
.react-aria-Row[data-selected] {
  /* ... */
}

.react-aria-Row[data-focused] {
  /* ... */
}

The className and style props also accept functions which receive states for styling. This lets you dynamically determine the classes or styles to apply, which is useful when using utility CSS libraries like Tailwind.

<Row
  className={({ isSelected }) => isSelected ? 'bg-blue-400' : 'bg-gray-100'}
>
  Item
</Row>
<Row
  className={({ isSelected }) =>
    isSelected ? 'bg-blue-400' : 'bg-gray-100'}
>
  Item
</Row>
<Row
  className={(
    { isSelected }
  ) =>
    isSelected
      ? 'bg-blue-400'
      : 'bg-gray-100'}
>
  Item
</Row>

Render props may also be used as children to alter what elements are rendered based on the current state. For example, you could render a sort indicator in sortable columns.

<Column>
  {({allowsSorting, sortDirection}) => (
    <>
      Column Title
      {allowsSorting && <MySortIndicator direction={sortDirection} />}
    </>
  )}
</Column>
<Column>
  {({ allowsSorting, sortDirection }) => (
    <>
      Column Title
      {allowsSorting && (
        <MySortIndicator direction={sortDirection} />
      )}
    </>
  )}
</Column>
<Column>
  {(
    {
      allowsSorting,
      sortDirection
    }
  ) => (
    <>
      Column Title
      {allowsSorting &&
        (
          <MySortIndicator
            direction={sortDirection}
          />
        )}
    </>
  )}
</Column>

The states and selectors for each component used in a Table are documented below.

Table#

A Table can be targeted with the .react-aria-Table CSS selector, or by overriding with a custom className. It supports the following states and render props:

NameCSS SelectorDescription
isFocused[data-focused]Whether the table is currently focused.
isFocusVisible[data-focus-visible]Whether the table is currently keyboard focused.
isDropTarget[data-drop-target]Whether the table is currently the active drop target.
stateState of the table.

TableHeader#

A TableHeader can be targeted with the .react-aria-TableHeader CSS selector, or by overriding with a custom className.

Column#

A Column can be targeted with the .react-aria-Column CSS selector, or by overriding with a custom className. It supports the following states and render props:

NameCSS SelectorDescription
isHovered[data-hovered]Whether the item is currently hovered with a mouse.
isFocused[data-focused]Whether the item is currently focused.
isFocusVisible[data-focus-visible]Whether the item is currently keyboard focused.
allowsSorting[data-allows-sorting]Whether the column allows sorting.
sortDirection[data-sort-direction="ascending | descending"]The current sort direction.
isResizing[data-resizing]Whether the column is currently being resized.
sortTriggers sorting for this column in the given direction.
startResizeStarts column resizing if the table is contained in a <ResizableTableContainer> element.

TableBody#

A TableBody can be targeted with the .react-aria-TableBody CSS selector, or by overriding with a custom className. It supports the following states and render props:

NameCSS SelectorDescription
isEmpty[data-empty]Whether the table body has no rows and should display its empty state.
isDropTarget[data-drop-target]Whether the Table is currently the active drop target.

Row#

A Row can be targeted with the .react-aria-Row CSS selector, or by overriding with a custom className. It supports the following states and render props:

NameCSS SelectorDescription
isHovered[data-hovered]Whether the item is currently hovered with a mouse.
isPressed[data-pressed]Whether the item is currently in a pressed state.
isSelected[data-selected]Whether the item is currently selected.
isFocused[data-focused]Whether the item is currently focused.
isFocusVisible[data-focus-visible]Whether the item is currently keyboard focused.
isDisabled[data-disabled]

Whether the item is non-interactive, i.e. both selection and actions are disabled and the item may not be focused. Dependent on disabledKeys and disabledBehavior.

selectionMode[data-selection-mode="single | multiple"]The type of selection that is allowed in the collection.
selectionBehaviorThe selection behavior for the collection.
allowsDragging[data-allows-dragging]Whether the item allows dragging.
isDragging[data-dragging]Whether the item is currently being dragged.
isDropTarget[data-drop-target]Whether the item is currently an active drop target.

Cell#

A Cell can be targeted with the .react-aria-Cell CSS selector, or by overriding with a custom className. It supports the following states and render props:

NameCSS SelectorDescription
isPressed[data-pressed]Whether the cell is currently in a pressed state.
isFocused[data-focused]Whether the cell is currently focused.
isFocusVisible[data-focus-visible]Whether the cell is currently keyboard focused.
isHovered[data-hovered]Whether the cell is currently hovered with a mouse.

ResizableTableContainer#

A ResizableTableContainer can be targeted with the .react-aria-ResizableTableContainer CSS selector, or by overriding with a custom className.

ColumnResizer#

A ColumnResizer can be targeted with the .react-aria-ColumnResizer CSS selector, or by overriding with a custom className. It supports the following states and render props:

NameCSS SelectorDescription
isHovered[data-hovered]Whether the resizer is currently hovered with a mouse.
isFocused[data-focused]Whether the resizer is currently focused.
isFocusVisible[data-focus-visible]Whether the resizer is currently keyboard focused.
isResizing[data-resizing]Whether the resizer is currently being resized.
resizableDirection[data-resizable-direction="right | left | both"]The direction that the column is currently resizable.

Advanced customization#


Contexts#

All React Aria Components export a corresponding context that can be used to send props to them from a parent element. This enables you to build your own compositional APIs similar to those found in React Aria Components itself. You can send any prop or ref via context that you could pass to the corresponding component. The local props and ref on the component are merged with the ones passed via context, with the local props taking precedence (following the rules documented in mergeProps).

ComponentContextPropsRef
TableTableContextTablePropsHTMLTableElement

This example shows a component that accepts a Table and a ToggleButton as children, and allows the user to turn selection mode for the table on and off by pressing the button.

import type {SelectionMode} from 'react-aria-components';
import {ToggleButtonContext, TableContext} from 'react-aria-components';

function Selectable({children}) {
  let [isSelected, onChange] = React.useState(false);
  let selectionMode: SelectionMode = isSelected ? 'multiple' : 'none';
  return (
    <ToggleButtonContext.Provider value={{isSelected, onChange}}>
      <TableContext.Provider value={{selectionMode}}>        {children}
      </TableContext.Provider>
    </ToggleButtonContext.Provider>
  );
}
import type {SelectionMode} from 'react-aria-components';
import {
  TableContext,
  ToggleButtonContext
} from 'react-aria-components';

function Selectable({ children }) {
  let [isSelected, onChange] = React.useState(false);
  let selectionMode: SelectionMode = isSelected
    ? 'multiple'
    : 'none';
  return (
    <ToggleButtonContext.Provider
      value={{ isSelected, onChange }}
    >
      <TableContext.Provider value={{ selectionMode }}>        {children}
      </TableContext.Provider>
    </ToggleButtonContext.Provider>
  );
}
import type {SelectionMode} from 'react-aria-components';
import {
  TableContext,
  ToggleButtonContext
} from 'react-aria-components';

function Selectable(
  { children }
) {
  let [
    isSelected,
    onChange
  ] = React.useState(
    false
  );
  let selectionMode:
    SelectionMode =
      isSelected
        ? 'multiple'
        : 'none';
  return (
    <ToggleButtonContext.Provider
      value={{
        isSelected,
        onChange
      }}
    >
      <TableContext.Provider
        value={{
          selectionMode
        }}
      >        {children}
      </TableContext.Provider>
    </ToggleButtonContext.Provider>
  );
}

The Selectable component can be reused to make the selection mode of any nested Table controlled by a ToggleButton.

import {ToggleButton} from 'react-aria-components';

<Selectable>
  <ToggleButton>Select</ToggleButton>
  <PokemonTable />
</Selectable>
import {ToggleButton} from 'react-aria-components';

<Selectable>
  <ToggleButton>Select</ToggleButton>
  <PokemonTable />
</Selectable>
import {ToggleButton} from 'react-aria-components';

<Selectable>
  <ToggleButton>
    Select
  </ToggleButton>
  <PokemonTable />
</Selectable>
Show CSS
.react-aria-ToggleButton {
  --deselected-border-color: var(--spectrum-gray-400);
  --deselected-border-color-pressed: var(--spectrum-gray-500);
  --deselected-background-color: var(--spectrum-gray-50);
  --deselected-background-color-pressed: var(--spectrum-gray-100);
  --selected-color: var(--spectrum-gray-800);
  --selected-color-pressed: var(--spectrum-gray-900);
  --text-color: var(--spectrum-alias-text-color);
  --text-color-selected: var(--spectrum-gray-50);

  color: var(--text-color);
  background: var(--deselected-background-color);
  border: 1px solid var(--deselected-border-color);
  border-radius: 4px;
  appearance: none;
  vertical-align: middle;
  font-size: 1rem;
  text-align: center;
  margin: 0 0 8px 0;
  outline: none;
  padding: 4px 12px;

  &[data-pressed] {
    box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1);
    background: var(--deselected-background-color-pressed);
    border-color: var(--deselected-border-color-pressed);
  }

  &[aria-pressed=true] {
    background: var(--selected-color);
    border-color: var(--selected-color);
    color: var(--text-color-selected);

    &[data-pressed] {
      background: var(--selected-color-pressed);
      border-color: var(--selected-color-pressed);
    }
  }

  &[data-focus-visible] {
    border-color: var(--selected-color);
    box-shadow: 0 0 0 1px var(--selected-color);
  }
}

@media (forced-colors: active) {
  .react-aria-ToggleButton {
    forced-color-adjust: none;

    --deselected-border-color: ButtonBorder;
    --deselected-border-color-pressed: ButtonBorder;
    --deselected-background-color: ButtonFace;
    --deselected-background-color-pressed: ButtonFace;
    --selected-color: Highlight;
    --selected-color-pressed: Highlight;
    --text-color: ButtonText;
    --text-color-selected: HighlightText;
  }
}
.react-aria-ToggleButton {
  --deselected-border-color: var(--spectrum-gray-400);
  --deselected-border-color-pressed: var(--spectrum-gray-500);
  --deselected-background-color: var(--spectrum-gray-50);
  --deselected-background-color-pressed: var(--spectrum-gray-100);
  --selected-color: var(--spectrum-gray-800);
  --selected-color-pressed: var(--spectrum-gray-900);
  --text-color: var(--spectrum-alias-text-color);
  --text-color-selected: var(--spectrum-gray-50);

  color: var(--text-color);
  background: var(--deselected-background-color);
  border: 1px solid var(--deselected-border-color);
  border-radius: 4px;
  appearance: none;
  vertical-align: middle;
  font-size: 1rem;
  text-align: center;
  margin: 0 0 8px 0;
  outline: none;
  padding: 4px 12px;

  &[data-pressed] {
    box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1);
    background: var(--deselected-background-color-pressed);
    border-color: var(--deselected-border-color-pressed);
  }

  &[aria-pressed=true] {
    background: var(--selected-color);
    border-color: var(--selected-color);
    color: var(--text-color-selected);

    &[data-pressed] {
      background: var(--selected-color-pressed);
      border-color: var(--selected-color-pressed);
    }
  }

  &[data-focus-visible] {
    border-color: var(--selected-color);
    box-shadow: 0 0 0 1px var(--selected-color);
  }
}

@media (forced-colors: active) {
  .react-aria-ToggleButton {
    forced-color-adjust: none;

    --deselected-border-color: ButtonBorder;
    --deselected-border-color-pressed: ButtonBorder;
    --deselected-background-color: ButtonFace;
    --deselected-background-color-pressed: ButtonFace;
    --selected-color: Highlight;
    --selected-color-pressed: Highlight;
    --text-color: ButtonText;
    --text-color-selected: HighlightText;
  }
}
.react-aria-ToggleButton {
  --deselected-border-color: var(--spectrum-gray-400);
  --deselected-border-color-pressed: var(--spectrum-gray-500);
  --deselected-background-color: var(--spectrum-gray-50);
  --deselected-background-color-pressed: var(--spectrum-gray-100);
  --selected-color: var(--spectrum-gray-800);
  --selected-color-pressed: var(--spectrum-gray-900);
  --text-color: var(--spectrum-alias-text-color);
  --text-color-selected: var(--spectrum-gray-50);

  color: var(--text-color);
  background: var(--deselected-background-color);
  border: 1px solid var(--deselected-border-color);
  border-radius: 4px;
  appearance: none;
  vertical-align: middle;
  font-size: 1rem;
  text-align: center;
  margin: 0 0 8px 0;
  outline: none;
  padding: 4px 12px;

  &[data-pressed] {
    box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1);
    background: var(--deselected-background-color-pressed);
    border-color: var(--deselected-border-color-pressed);
  }

  &[aria-pressed=true] {
    background: var(--selected-color);
    border-color: var(--selected-color);
    color: var(--text-color-selected);

    &[data-pressed] {
      background: var(--selected-color-pressed);
      border-color: var(--selected-color-pressed);
    }
  }

  &[data-focus-visible] {
    border-color: var(--selected-color);
    box-shadow: 0 0 0 1px var(--selected-color);
  }
}

@media (forced-colors: active) {
  .react-aria-ToggleButton {
    forced-color-adjust: none;

    --deselected-border-color: ButtonBorder;
    --deselected-border-color-pressed: ButtonBorder;
    --deselected-background-color: ButtonFace;
    --deselected-background-color-pressed: ButtonFace;
    --selected-color: Highlight;
    --selected-color-pressed: Highlight;
    --text-color: ButtonText;
    --text-color-selected: HighlightText;
  }
}

Custom children#

Table passes props to its child components, such as the selection checkboxes, via their associated contexts. These contexts are exported so you can also consume them in your own custom components. This enables you to reuse existing components from your app or component library together with React Aria Components.

ComponentContextPropsRef
CheckboxCheckboxContextCheckboxPropsHTMLInputElement
ButtonButtonContextButtonPropsHTMLButtonElement

This example consumes from CheckboxContext in an existing styled checkbox component to make it compatible with React Aria Components. The useContextProps hook merges the local props and ref with the ones provided via context by Table. See useCheckbox for more details about the hooks used in this example.

import type {CheckboxProps} from 'react-aria-components';
import {CheckboxContext, useContextProps} from 'react-aria-components';
import {useToggleState} from 'react-stately';
import {useCheckbox} from 'react-aria';

const MyCustomCheckbox = React.forwardRef(
  (props: CheckboxProps, ref: React.ForwardedRef<HTMLInputElement>) => {
    // Merge the local props and ref with the ones provided via context.
    [props, ref] = useContextProps(props, ref, CheckboxContext);
    let state = useToggleState(props);
    let { inputProps } = useCheckbox(props, state, ref);
    return <input {...inputProps} ref={ref} />;
  }
);
import type {CheckboxProps} from 'react-aria-components';
import {
  CheckboxContext,
  useContextProps
} from 'react-aria-components';
import {useToggleState} from 'react-stately';
import {useCheckbox} from 'react-aria';

const MyCustomCheckbox = React.forwardRef(
  (
    props: CheckboxProps,
    ref: React.ForwardedRef<HTMLInputElement>
  ) => {
    // Merge the local props and ref with the ones provided via context.
    [props, ref] = useContextProps(
      props,
      ref,
      CheckboxContext
    );
    let state = useToggleState(props);
    let { inputProps } = useCheckbox(props, state, ref);
    return <input {...inputProps} ref={ref} />;
  }
);
import type {CheckboxProps} from 'react-aria-components';
import {
  CheckboxContext,
  useContextProps
} from 'react-aria-components';
import {useToggleState} from 'react-stately';
import {useCheckbox} from 'react-aria';

const MyCustomCheckbox =
  React.forwardRef(
    (
      props:
        CheckboxProps,
      ref:
        React.ForwardedRef<
          HTMLInputElement
        >
    ) => {
      // Merge the local props and ref with the ones provided via context.
      [props, ref] =
        useContextProps(
          props,
          ref,
          CheckboxContext
        );
      let state =
        useToggleState(
          props
        );
      let {
        inputProps
      } = useCheckbox(
        props,
        state,
        ref
      );
      return (
        <input
          {...inputProps}
          ref={ref}
        />
      );
    }
  );

Now you can use MyCustomCheckbox within a Table, in place of the builtin React Aria Components Checkbox.

<Table>
  <TableHeader>
    {/* ... */}
  </TableHeader>
  <TableBody>
    <Row>
      <Cell><MyCustomCheckbox slot="selection" /></Cell>      {/* ... */}
    </Row>
  </TableBody>
</Table>
<Table>
  <TableHeader>
    {/* ... */}
  </TableHeader>
  <TableBody>
    <Row>
      <Cell><MyCustomCheckbox slot="selection" /></Cell>      {/* ... */}
    </Row>
  </TableBody>
</Table>
<Table>
  <TableHeader>
    {/* ... */}
  </TableHeader>
  <TableBody>
    <Row>
      <Cell>
        <MyCustomCheckbox slot="selection" />
      </Cell>      {/* ... */}
    </Row>
  </TableBody>
</Table>

Hooks#

If you need to customize things even further, such as accessing internal state or customizing DOM structure, you can drop down to the lower level Hook-based API. See useTable for more details.