---
title: "ListPage"
description: "The page of a resource’s records: the title with the count of what the list holds, a line on what it is for and the action that adds one, over its ListTable. A list page is these two blocks and the app’s data."
---

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

# ListPage

## Contacts

Search, status chips, filters by channel and by agent (records with a second line), the column picker, rows that open their record with the name as a real link, row menus and pages. Narrow the page: the channel and agent columns leave first, and the filters fold behind one button.

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

type Channel = 'whatsapp' | 'voice';

type Status = 'active' | 'archived';

interface Contact {
  id: string;
  name: string;
  phone: string;
  channel: Channel;
  agent: string;
  status: Status;
  conversations: number;
}

const 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',
  'Ricardo Silva',
];

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

const CONTACTS: Contact[] = NAMES.map((name, index) => ({
  id: `c${index + 1}`,
  name,
  phone: `+57 300 555 ${1000 + index * 37}`,
  channel: index % 3 === 0 ? 'voice' : 'whatsapp',
  agent: AGENTS[index % 2]?.value ?? 'Front desk',
  status: index % 5 === 4 ? 'archived' : 'active',
  conversations: (index * 7) % 23,
}));

const CHANNEL_OPTIONS = [
  { value: 'whatsapp', label: 'WhatsApp', icon: <ChatCircleDotsIcon aria-hidden /> },
  { value: 'voice', label: 'Phone', icon: <PhoneIcon aria-hidden /> },
];

const COLUMNS: DataTableColumn<Contact>[] = [
  {
    accessorKey: 'name',
    header: 'Name',
    cell: ({ row }) => (
      <RowTitle
        description={row.original.phone}
        href={`#/contacts/${row.original.id}`}
        leading={<Avatar name={row.original.name} />}
      >
        {row.original.name}
      </RowTitle>
    ),
    meta: { fill: true },
  },
  {
    accessorKey: 'channel',
    enableSorting: false,
    header: 'Channel',
    size: 130,
    cell: ({ row }) => (row.original.channel === 'voice' ? 'Phone' : 'WhatsApp'),
    meta: { minTableWidth: 640 },
  },
  { accessorKey: 'agent', enableSorting: false, header: 'Agent', size: 150, meta: { minTableWidth: 820 } },
  {
    accessorKey: 'status',
    header: 'Status',
    size: 110,
    cell: ({ row }) =>
      row.original.status === 'active' ? <Badge tone="success">Active</Badge> : <Badge>Archived</Badge>,
  },
  {
    accessorKey: 'conversations',
    header: 'Conversations',
    size: 140,
    meta: { align: 'end', cellClassName: 'tabular-nums', defaultVisible: false },
  },
];

const NO_VALUES: string[] = [];

const ContactsExample = () => {
  const [status, setStatus] = useState<Status | null>(null);
  const [channels, setChannels] = useState<readonly string[]>(NO_VALUES);
  const [agents, setAgents] = useState<readonly string[]>(NO_VALUES);
  const [pageNumber, setPageNumber] = useState(1);
  const [columns, setColumns] = useState<Partial<ColumnState>>({});
  const rows = CONTACTS.filter(
    (contact) =>
      (status === null || contact.status === status) &&
      (channels.length === 0 || channels.includes(contact.channel)) &&
      (agents.length === 0 || agents.includes(contact.agent))
  );
  /** A filter starts again from the first page. */
  const filter = (change: () => void) => {
    change();
    setPageNumber(1);
  };
  return (
    <ListPage
      actions={
        <Button>
          <PlusIcon aria-hidden /> New contact
        </Button>
      }
      count={rows.length}
      description="Everyone your agents talked to."
      title="Contacts"
    >
      <ListTable
        activeFilterCount={[channels, agents].filter((values) => values.length > 0).length}
        aria-label="Contacts"
        chips={
          <FilterChips
            label="Status"
            onChange={(next) => {
              filter(() => {
                setStatus(next);
              });
            }}
            options={[
              { label: 'Active', value: 'active' },
              { label: 'Archived', value: 'archived' },
            ]}
            value={status}
          />
        }
        columnState={columns}
        columns={COLUMNS}
        data={rows}
        emptyState={
          <EmptyState
            actions={<Button>Add contact</Button>}
            description="Import a CSV or add your first contact by hand."
            icon={<UsersIcon aria-hidden />}
            title="No contacts yet"
          />
        }
        filters={
          <>
            <FilterSelect
              allLabel="All channels"
              label="Channel"
              onChange={(next) => {
                filter(() => {
                  setChannels(next);
                });
              }}
              options={CHANNEL_OPTIONS}
              selectionMode="multiple"
              value={channels}
            />
            <FilterSelect
              allLabel="All agents"
              label="Agent"
              onChange={(next) => {
                filter(() => {
                  setAgents(next);
                });
              }}
              options={AGENTS}
              value={agents}
            />
          </>
        }
        getRowHref={(contact) => `#/contacts/${contact.id}`}
        getRowId={(contact) => contact.id}
        getSearchText={(contact) => `${contact.name} ${contact.phone}`}
        isFiltered={status !== null}
        onClearFilters={() => {
          filter(() => {
            setStatus(null);
            setChannels(NO_VALUES);
            setAgents(NO_VALUES);
          });
        }}
        onColumnStateChange={setColumns}
        pagination={{ onPage: setPageNumber, page: pageNumber }}
        rowActions={(contact) => [
          { label: 'Call', href: `tel:${contact.phone.replaceAll(' ', '')}` },
          { label: 'Archive', isDestructive: true, onAction: () => undefined },
        ]}
        rowActionsLabel={(contact) => `Actions for ${contact.name}`}
        searchPlaceholder="Search by name or phone"
      />
    </ListPage>
  );
};

<ContactsExample />
```

## While the first page loads

The title and the toolbar stay put while placeholder rows hold the table’s place; the count shows once it is known.

```tsx
import { PlusIcon } from '@phosphor-icons/react';
import { Button, ListPage, ListTable } from '@arbol-ai/ui';
import type { DataTableColumn } from '@arbol-ai/ui';

interface Campaign {
  name: string;
  status: string;
  recipients: number;
}

const CAMPAIGN_COLUMNS: DataTableColumn<Campaign>[] = [
  { accessorKey: 'name', header: 'Name', meta: { fill: true } },
  { accessorKey: 'status', header: 'Status', size: 120 },
  { accessorKey: 'recipients', header: 'Recipients', size: 120, meta: { align: 'end' } },
];

const NO_CAMPAIGNS: Campaign[] = [];

<ListPage
  actions={
    <Button>
      <PlusIcon aria-hidden /> New campaign
    </Button>
  }
  title="Campaigns"
>
  <ListTable
    aria-label="Campaigns"
    columns={CAMPAIGN_COLUMNS}
    data={NO_CAMPAIGNS}
    isLoading
    onSearchChange={() => undefined}
    searchPlaceholder="Search campaigns"
  />
</ListPage>
```

## Source

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

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