---
title: "ListTable"
description: "A resource’s whole list from rows, columns and callbacks: the toolbar (search, filters, saved views, chips, columns), sortable headers, rows that open their record, row menus, every state inside the frame, pages by number or cursor, and a bar for the selected rows. Built from ListToolbar, DataTable, RowTitle, RowMenu, EmptyState, Pagination and SelectionBar, each usable alone."
---

> Documentation Index
> Fetch the complete documentation index at: https://epidermis.getarbol.com/llms.txt
> Use this file to discover all available pages before exploring further.

# ListTable

## Rows and columns

The shortest list: headers sort, a press anywhere on a row opens it.

```tsx
import { ListTable } from '@arbol-ai/ui';
import type { DataTableColumn } from '@arbol-ai/ui';

interface Service {
  id: string;
  name: string;
  minutes: number;
  price: number;
}

const SERVICES: Service[] = [
  { id: 's1', name: 'General check-up', minutes: 30, price: 80_000 },
  { id: 's2', name: 'Dental cleaning', minutes: 45, price: 120_000 },
  { id: 's3', name: 'Vaccination', minutes: 15, price: 45_000 },
];

const SERVICE_COLUMNS: DataTableColumn<Service>[] = [
  { accessorKey: 'name', header: 'Service', meta: { fill: true } },
  { accessorKey: 'minutes', header: 'Minutes', size: 110, meta: { align: 'end' } },
  { accessorKey: 'price', header: 'Price', size: 120, meta: { align: 'end', cellClassName: 'tabular-nums' } },
];

<ListTable
  aria-label="Services"
  columns={SERVICE_COLUMNS}
  data={SERVICES}
  getRowHref={(service) => `#/services/${service.id}`}
/>
```

## A list the browser holds

A team or a catalog read whole: the search, the status chips and the pages run in the browser, and the rows have menus, selection and a bulk action.

```tsx
import { DownloadSimpleIcon, UsersIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Avatar, Badge, Button, EmptyState, FilterChips, ListTable, RowTitle } from '@arbol-ai/ui';
import type { DataTableColumn } from '@arbol-ai/ui';

type PatientStatus = 'active' | 'archived';

interface Patient {
  id: string;
  name: string;
  document: string;
  status: PatientStatus;
  visits: number;
}

const PATIENT_NAMES = [
  'Ana Torres',
  'Luis Gómez',
  'Marta Ruiz',
  'Carlos Peña',
  'Sofía Rojas',
  'Juan Castro',
  'Laura Mejía',
  'Pedro Díaz',
  'Valentina Ortiz',
  'Andrés Vargas',
  'Camila Herrera',
  'Diego Morales',
  'Isabel Navarro',
  'Jorge Salazar',
  'Lucía Romero',
  'Miguel Cárdenas',
  'Paula Ríos',
];

const PATIENTS: Patient[] = PATIENT_NAMES.map((name, index) => ({
  id: `p${index + 1}`,
  name,
  document: `CC ${(1_020_300 + index * 7919).toLocaleString('es-CO')}`,
  status: index % 6 === 5 ? 'archived' : 'active',
  visits: (index * 5) % 13,
}));

const PATIENT_COLUMNS: DataTableColumn<Patient>[] = [
  {
    accessorKey: 'name',
    header: 'Patient',
    cell: ({ row }) => (
      <RowTitle
        description={row.original.document}
        href={`#/patients/${row.original.id}`}
        leading={<Avatar name={row.original.name} />}
      >
        {row.original.name}
      </RowTitle>
    ),
    meta: { fill: true },
  },
  {
    accessorKey: 'status',
    header: 'Status',
    size: 120,
    cell: ({ row }) =>
      row.original.status === 'active' ? <Badge tone="success">Active</Badge> : <Badge>Archived</Badge>,
    meta: { minTableWidth: 560 },
  },
  {
    accessorKey: 'visits',
    header: 'Visits',
    size: 100,
    meta: { align: 'end', cellClassName: 'tabular-nums' },
  },
];

const PatientsExample = () => {
  const [status, setStatus] = useState<PatientStatus | null>(null);
  const [pageNumber, setPageNumber] = useState(1);
  return (
    <ListTable
      aria-label="Patients"
      renderBulkActions={() => (
        <Button size="sm" variant="outline">
          <DownloadSimpleIcon aria-hidden /> Export
        </Button>
      )}
      chips={
        <FilterChips
          label="Status"
          onChange={(next) => {
            setStatus(next);
            setPageNumber(1);
          }}
          options={[
            { label: 'Active', value: 'active' },
            { label: 'Archived', value: 'archived' },
          ]}
          value={status}
        />
      }
      columns={PATIENT_COLUMNS}
      data={status === null ? PATIENTS : PATIENTS.filter((patient) => patient.status === status)}
      emptyState={
        <EmptyState
          actions={<Button>New patient</Button>}
          description="Everyone the clinic books or registers appears here."
          icon={<UsersIcon aria-hidden />}
          title="No patients yet"
        />
      }
      getRowHref={(patient) => `#/patients/${patient.id}`}
      getRowId={(patient) => patient.id}
      getSearchText={(patient) => `${patient.name} ${patient.document}`}
      isFiltered={status !== null}
      onClearFilters={() => {
        setStatus(null);
      }}
      pagination={{ onPage: setPageNumber, page: pageNumber }}
      rowActions={(patient) => [
        { label: 'Book an appointment', href: `#/book?patient=${patient.id}` },
        { label: 'Archive', isDestructive: true, onAction: () => undefined },
      ]}
      rowActionsLabel={(patient) => `Actions for ${patient.name}`}
      searchPlaceholder="Name or document"
      selectionMode="multiple"
    />
  );
};

<PatientsExample />
```

## A list the server pages

The search, the agent filter, the sort and the cursor go to the server; while the next page loads, the rows dim and the pressed button waits. The person’s columns are kept by the app.

```tsx
import type { SortingState } from '@tanstack/react-table';
import { useState } from 'react';
import { FilterSelect, ListTable } from '@arbol-ai/ui';
import type { ColumnState, DataTableColumn } from '@arbol-ai/ui';

interface Conversation {
  id: string;
  contact: string;
  agent: string;
  started: string;
}

const CONVERSATIONS: Conversation[] = [
  { id: 'v1', contact: 'Ana Torres', agent: 'Front desk', started: '2026-11-07 09:12' },
  { id: 'v2', contact: 'Luis Gómez', agent: 'Reminders', started: '2026-11-07 08:47' },
  { id: 'v3', contact: 'Marta Ruiz', agent: 'Front desk', started: '2026-11-06 17:30' },
  { id: 'v4', contact: 'Carlos Peña', agent: 'Reminders', started: '2026-11-06 16:02' },
];

const CONVERSATION_COLUMNS: DataTableColumn<Conversation>[] = [
  { accessorKey: 'contact', header: 'Contact', meta: { fill: true } },
  { accessorKey: 'agent', enableSorting: false, header: 'Agent', size: 140 },
  {
    accessorKey: 'started',
    header: 'Started',
    size: 150,
    meta: { align: 'end', cellClassName: 'tabular-nums' },
  },
];

const AGENTS = [
  { value: 'Front desk', label: 'Front desk', description: 'Answers every call' },
  { value: 'Reminders', label: 'Reminders', description: 'Confirms appointments' },
];

const NO_VALUES: string[] = [];

const ConversationsExample = () => {
  const [q, setQ] = useState('');
  const [agents, setAgents] = useState<readonly string[]>(NO_VALUES);
  const [sorting, setSorting] = useState<SortingState>([{ id: 'started', desc: true }]);
  const [cursor, setCursor] = useState(0);
  const [columns, setColumns] = useState<Partial<ColumnState>>({});
  const [isUpdating, setIsUpdating] = useState(false);
  /** What the server would do: answer a moment later. */
  const ask = (change: () => void) => {
    change();
    setIsUpdating(true);
    setTimeout(() => {
      setIsUpdating(false);
    }, 900);
  };
  const needle = q.trim().toLowerCase();
  const rows = CONVERSATIONS.filter(
    (conversation) =>
      (needle === '' || conversation.contact.toLowerCase().includes(needle)) &&
      (agents.length === 0 || agents.includes(conversation.agent))
  ).toSorted((a, b) => {
    const order =
      sorting.at(0)?.id === 'contact'
        ? a.contact.localeCompare(b.contact)
        : a.started.localeCompare(b.started);
    return sorting.at(0)?.desc === true ? -order : order;
  });
  return (
    <ListTable
      activeFilterCount={agents.length > 0 ? 1 : 0}
      aria-label="Conversations"
      columnState={columns}
      columns={CONVERSATION_COLUMNS}
      data={rows.slice(cursor * 2, cursor * 2 + 2)}
      filters={
        <FilterSelect
          allLabel="All agents"
          label="Agent"
          onChange={(next) => {
            ask(() => {
              setAgents(next);
              setCursor(0);
            });
          }}
          options={AGENTS}
          value={agents}
        />
      }
      getRowId={(conversation) => conversation.id}
      isUpdating={isUpdating}
      manualSorting
      onClearFilters={() => {
        ask(() => {
          setAgents(NO_VALUES);
          setCursor(0);
        });
      }}
      onColumnStateChange={setColumns}
      onRowAction={() => undefined}
      onSearchChange={(next) => {
        ask(() => {
          setQ(next);
          setCursor(0);
        });
      }}
      onSortingChange={(next) => {
        ask(() => {
          setSorting(next);
          setCursor(0);
        });
      }}
      pagination={{
        hasNextPage: rows.length > (cursor + 1) * 2,
        hasPreviousPage: cursor > 0,
        onNext: () => {
          ask(() => {
            setCursor((current) => current + 1);
          });
        },
        onPrevious: () => {
          ask(() => {
            setCursor((current) => current - 1);
          });
        },
      }}
      searchDebounceMs={300}
      searchPlaceholder="Search by contact"
      searchValue={q}
      sorting={sorting}
    />
  );
};

<ConversationsExample />
```

## Source

The examples above are [`src/blocks/list-table/list-table.examples.tsx`](https://github.com/getarbol/project-arbol-ui/blob/main/src/blocks/list-table/list-table.examples.tsx), which the library's tests render too.

Source: https://epidermis.getarbol.com/components/blocks/list-table/index.mdx
