Skip to content

Table ​

A table here is a real one — TableContent renders a <table> with a header, body and rows — and everything beyond that is a part you add. Selection, sorting, resizing, expanding, dragging and loading more are each opt-in, so a plain table costs nothing.

ts
import { Table, TableBody, TableCell, TableColumn, TableRow } from "ropav";

The frame ​

Table is the frame, TableScrollContainer is what scrolls, and TableContent is the table itself. Keeping them apart is what lets the header stay put while the body scrolls, and what gives a wide table somewhere to overflow that is not the page.

NameRoleJoined
Ada LovelaceEngineering2024
Grace HopperEngineering2025
Katherine JohnsonResearch2026
table-basic.vue
vue
<script setup lang="ts">
import {
  Table,
  TableBody,
  TableCell,
  TableColumn,
  TableContent,
  TableHeader,
  TableRow,
  TableScrollContainer,
} from "ropav";

const columns = [
  { id: "name", isRowHeader: true, name: "Name" },
  { id: "role", name: "Role" },
  { id: "joined", name: "Joined" },
];

const people = [
  { id: "1", joined: "2024", name: "Ada Lovelace", role: "Engineering" },
  { id: "2", joined: "2025", name: "Grace Hopper", role: "Engineering" },
  { id: "3", joined: "2026", name: "Katherine Johnson", role: "Research" },
];
</script>

<template>
  <div class="frame">
    <Table>
      <TableScrollContainer>
        <TableContent aria-label="Team" class="table">
          <TableHeader>
            <TableColumn
              v-for="column in columns"
              :id="column.id"
              :key="column.id"
              :is-row-header="column.isRowHeader"
            >
              {{ column.name }}
            </TableColumn>
          </TableHeader>

          <TableBody>
            <TableRow v-for="person in people" :id="person.id" :key="person.id">
              <TableCell v-for="column in columns" :key="column.id">
                {{ person[column.id as keyof typeof person] }}
              </TableCell>
            </TableRow>
          </TableBody>
        </TableContent>
      </TableScrollContainer>
    </Table>
  </div>
</template>

<style scoped>
.frame {
  width: 100%;
  max-width: var(--rp-container-2xl);
}

.table {
  min-width: 480px;
}
</style>

One column should carry is-row-header: it is the cell that names the row, and what a screen reader reads back when the reader moves down a column. Give that cell text-value when its content is a control rather than text, so the row keeps its name while it is being edited.

Selection ​

selection-mode on TableContent turns it on, and TableSelectionCheckbox is the box to put in the first cell of the header and of each row. The selected keys are row ids, controlled or uncontrolled like any other value here.

Sorting ​

allows-sorting on a TableColumn makes its header a button, and sort-descriptor on TableContent says which column is sorted and which way. The table does not sort your data — it reports what was asked for and renders what you give back, which is the only version that works once the data is on a server.

Resizing ​

TableResizableContainer and TableColumnResizer add column resizing; width, min-width and max-width on a column set its bounds.

Long tables ​

TableLoadMore renders a sentinel at the end of the body that reports when it comes into view. For a table long enough that the rows themselves are the cost, reach for the virtualizer instead.

API ​

Table

PropTypeDefaultDescription
variant"primary" | "secondary""primary"Visual variant.

Slots: default.

TableBody

PropTypeDefaultDescription
itemKey((item: T, index: number) => CollectionKey)—A row's key. Defaults to the item's own id, then key, then its index.
itemsreadonly T[]—The rows' data, for a table inside a Virtualizer. Only then: without one every row would be rendered anyway, which is what the plain slot already does. Each row is rendered from the default slot, and has to carry the id its item is keyed by — that key is what pairs the row's cells with the geometry the layout worked out for them.
itemTextValue((item: T) => string | undefined)—Text a row is matched on by typeahead. Rendering is what creates cells here, so typeahead cannot read a row that is outside the window — it has no cells yet. Such a row has to be named from the data instead.

Slots: default, empty, loader.

TableCell

PropTypeDefaultDescription
textValuestring—Text this cell contributes to its row's text value. Derived from its content when absent.

The default slot receives:

Slot propType
hasChildRowsboolean
isDisabledboolean
isExpandedboolean
isSelectedboolean
isTreeColumnboolean

TableColumn

PropTypeDefaultDescription
allowsSortingboolean—Whether pressing this column header sorts the table. Declared as boolean so Vue casts a bare allows-sorting attribute instead of reading "".
defaultWidthFlexSize"1fr"Width the column starts at, when the caller is not controlling it.
idCollectionKey—Identity of the column. Falls back to a generated key.
isRowHeaderboolean—Whether cells under this column name their row. Declared as boolean rather than through the variants type, so Vue casts a bare is-row-header attribute instead of reading "".
maxWidthFlexSize—Largest width a resize may reach. Cannot be given in fr.
minWidthFlexSize75Smallest width a resize may reach. Cannot be given in fr.
widthFlexSize—Width the caller controls, in pixels, as a percentage of the table, or in fr units. A resize never changes it. Only read inside a TableResizableContainer.

The default slot receives:

Slot propType
allowsSortingboolean
isFocusVisibleboolean
isHoveredboolean
isPressedboolean
isResizingboolean
sort(direction?: TableSortDirection | undefined) => void
sortDirectionTableSortDirection | undefined
startResize() => void

TableColumnResizer

PropTypeDefaultDescription
ariaLabelstring"Resize column"Accessible name of the resizer.

TableContent

PropTypeDefaultDescription
defaultExpandedKeysIterable<CollectionKey>—Expanded row keys the table starts with.
defaultSelectedKeys"all" | Iterable<CollectionKey>—Selected row keys the table starts with.
disabledBehavior"all" | "selection""all"Whether a disabled row is also unfocusable.
disabledKeysIterable<CollectionKey>—Row keys that cannot be selected, and by default cannot be focused either.
disallowEmptySelectionboolean—Whether the table must always keep a row selected. Declared as boolean so Vue casts a bare attribute instead of reading "".
dragAndDropHooksDragAndDropHooks—Drag and drop behaviour, from useDragAndDrop.
expandedKeysIterable<CollectionKey>—Expanded row keys, for controlled use.
selectedKeys"all" | Iterable<CollectionKey>—Selected row keys, for controlled use.
selectionBehavior"replace" | "toggle""toggle"What a press on a row means when several rows can be selected.
selectionMode"none" | "single" | "multiple""none"Whether one, many or no rows can be selected.
sortDescriptorTableSortDescriptor | null—The column being sorted and the direction it is sorted in. Controlled with no internal fallback, exactly as React Aria has it: pass back what sortChange hands you.
treeColumnCollectionKey—The column that carries the expand control and the indentation, which is what turns the grid into a tree grid.

Slots: default.

EventPayload
expandedChangeSet<CollectionKey>
selectionChangeCollectionSelection
sortChangeTableSortDescriptor
update:expandedKeysSet<CollectionKey>
update:selectedKeysCollectionSelection
update:sortDescriptorTableSortDescriptor

TableDragHandle

PropTypeDefaultDescription
ariaLabelstring—Accessible name. Defaults to naming the row, or the whole selection when several travel.

Slots: default.

TableDropIndicator

PropTypeDefaultDescription
target*DropTarget—The position this indicator stands for.

TableExpandTrigger

Slots: default.

TableFooter

Slots: default.

TableHeader

The default slot receives:

Slot propType
allowsDraggingboolean

TableLoadMore

PropTypeDefaultDescription
isLoadingboolean—Whether the next page is on its way, which is when the indicator row is rendered.
scrollOffsetnumber1How far from the end of the scroll box loading starts, as a multiple of the box's own height. 1 is one screen ahead; 0 waits until the very end comes into view.

Slots: default.

EventPayload
loadMore—

TableLoadMoreContent

Slots: default.

TableResizableContainer

Slots: default.

EventPayload
resizeMap<CollectionKey, FlexSize>
resizeEndMap<CollectionKey, FlexSize>
resizeStartMap<CollectionKey, FlexSize>

TableRow

PropTypeDefaultDescription
hasChildRowsboolean—Whether the row has children, which is what decides whether it reports expansion at all. Declared as boolean so Vue casts a bare attribute instead of reading "".
idCollectionKey—Identity of the row. Falls back to a generated key.
levelnumber0Depth in a tree grid, counted from zero.
parentKeyCollectionKey—The row this one hangs off, in a tree grid.
textValuestring—Text the row is matched on by typeahead. Derived from its row header cells when absent.

The default slot receives:

Slot propType
allowsDraggingboolean
hasChildRowsboolean
isDisabledboolean
isDraggingboolean
isDropTargetboolean
isExpandedboolean
isFocusedboolean
isFocusVisibleboolean
isHoveredboolean
isPressedboolean
isSelectedboolean
selectionModeSelectionMode

TableScrollContainer

Slots: default.

TableSelectionCheckbox

PropTypeDefaultDescription
ariaLabelstring—Accessible name. Defaults to what React Aria names it: Select All in the header of a multiple-selection table, Select for a row or for a single-selection table.
variant"primary" | "secondary"—Visual variant of the checkbox.

TableSortableColumnHeader

PropTypeDefaultDescription
showIndicatorbooleantrueWhether to render the sort indicator when a direction is set.
sortDirection"ascending" | "descending"—Current sort direction for the column. Pass the sortDirection handed to the default slot of TableColumn.

Slots: default, indicator.