# Filter Table

> Filter a table's rows with a row of chips that each show how many rows they match.

Source: https://ai.honestui.com/docs/components/filter-table

```tsx
"use client"

import { FilterTable } from "@/components/ai/filter-table"

type AgentTask = { id: string; task: string; owner: string; status: "todo" | "running" | "done" }

// Sample tasks.
const sampleTasks: AgentTask[] = [
  { id: "1", task: "Reconcile March invoices", owner: "Billing agent", status: "running" },
  { id: "2", task: "Draft supplier emails", owner: "Outreach agent", status: "todo" },
  { id: "3", task: "Update the runbook", owner: "Docs agent", status: "done" },
  { id: "4", task: "Triage new tickets", owner: "Support agent", status: "running" },
  { id: "5", task: "Archive stale threads", owner: "Support agent", status: "todo" },
]

const statusLabel = { todo: "To do", running: "In progress", done: "Completed" }

export function FilterTableDemo() {
  return (
    <div className="w-full max-w-lg min-w-0">
      <FilterTable
        label="Agent tasks"
        rows={sampleTasks}
        getRowId={(row) => row.id}
        columns={[
          { id: "task", header: "Task", cell: (row) => row.task },
          { id: "owner", header: "Owner", cell: (row) => row.owner },
          { id: "status", header: "Status", cell: (row) => statusLabel[row.status] },
        ]}
        filters={[
          { id: "todo", label: "To do", matches: (row) => row.status === "todo" },
          { id: "running", label: "In progress", matches: (row) => row.status === "running" },
          { id: "done", label: "Completed", matches: (row) => row.status === "done" },
          { id: "blocked", label: "Blocked", matches: () => false },
        ]}
      />
    </div>
  )
}

```

## Overview [#overview]

Filter Table puts a row of chips above a table. Each chip is one filter and shows how many rows it matches. Pressing a chip shows only those rows.

The counts are computed from the rows you pass, so a chip never disagrees with the table under it.

Use [Data Table](https://www.honestui.com/docs/product/data-table) when people also need search, sorting, selection, or pagination.

## Anatomy [#anatomy]

<Anatomy title="FilterTable">
  <AnatomyItem name="Chips" description="All, then one chip for each filter, with its count" />

  <AnatomyItem name="Table" description="The matching rows" />

  <AnatomyItem name="Empty row" description="A message when the chosen filter matches nothing" />
</Anatomy>

Installing `filter-table` brings [Table](https://www.honestui.com/docs/components/table) along as a registry dependency.

## Behavior [#behavior]

### Filters [#filters]

One filter is active at a time. All shows every row. A filter is a function, `matches`, so it can test anything on the row.

### Empty [#empty]

A chip that matches no rows shows 0 and can still be pressed. The table then shows `emptyMessage` in place of the rows.

## Accessibility [#accessibility]

The chips are a named group of toggle buttons, and the active one reports that it is pressed. The table uses native table markup with column headers, and is named by `label`.

When the filter changes, a polite status announces how many rows are shown, as "2 of 5".

| Key                                  | Action                   |
| ------------------------------------ | ------------------------ |
| <kbd>Tab</kbd>                       | Move across the chips    |
| <kbd>Enter</kbd> or <kbd>Space</kbd> | Apply the focused filter |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;@honestai/filter-table&#x22;]" />
  

  
    
      
        Add the components it is built from.
      

      <CliBlock commands="[&#x22;table&#x22;]" />

      
        Copy and paste this file into your project.
      

      ### components/ai/filter-table.tsx

```tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

export type FilterTableColumn<Row> = {
  id: string
  header: string
  cell: (row: Row) => React.ReactNode
}

export type FilterTableFilter<Row> = {
  id: string
  label: string
  matches: (row: Row) => boolean
}

export type FilterTableProps<Row> = {
  /** Names the table for screen readers. */
  label: string
  columns: FilterTableColumn<Row>[]
  rows: Row[]
  getRowId: (row: Row) => string
  /** One chip each. A chip shows how many rows it matches. */
  filters: FilterTableFilter<Row>[]
  /** The chip that shows every row. */
  allLabel?: string
  /** The group name read before the chips. */
  filtersLabel?: string
  /** Shown when the chosen filter matches no rows. */
  emptyMessage?: string
  activeFilter?: string | null
  defaultActiveFilter?: string | null
  onActiveFilterChange?: (filterId: string | null) => void
  className?: string
}

// Table's own header color is too light for small text, so these headers use the secondary foreground.
const headText = "text-[var(--hui-color-foreground-base-secondary)]!"

const chipText =
  "[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"

/**
 * A table with a row of filter chips above it. Counts are computed from the
 * rows you pass, so a chip never disagrees with what it shows.
 */
function FilterTable<Row>({
  label,
  columns,
  rows,
  getRowId,
  filters,
  allLabel = "All",
  filtersLabel = "Filter rows",
  emptyMessage = "No rows match this filter.",
  activeFilter: activeFilterProp,
  defaultActiveFilter = null,
  onActiveFilterChange,
  className,
}: FilterTableProps<Row>) {
  const [uncontrolledFilter, setUncontrolledFilter] = React.useState(defaultActiveFilter)
  const activeFilter = activeFilterProp !== undefined ? activeFilterProp : uncontrolledFilter
  const active = filters.find((filter) => filter.id === activeFilter)
  const visibleRows = active ? rows.filter(active.matches) : rows

  const select = (filterId: string | null) => {
    if (activeFilterProp === undefined) setUncontrolledFilter(filterId)
    onActiveFilterChange?.(filterId)
  }

  const chips = [
    { id: null as string | null, label: allLabel, count: rows.length },
    ...filters.map((filter) => ({
      id: filter.id as string | null,
      label: filter.label,
      count: rows.filter(filter.matches).length,
    })),
  ]

  return (
    <div data-slot="filter-table" className={cn("flex w-full min-w-0 flex-col gap-[var(--hui-space-3)]", className)}>
      <div
        role="group"
        aria-label={filtersLabel}
        className="flex min-w-0 flex-wrap items-center gap-[var(--hui-space-2)]"
      >
        {chips.map((chip) => {
          const isActive = chip.id === (active?.id ?? null)
          return (
            <button
              key={chip.id ?? "all"}
              type="button"
              aria-pressed={isActive}
              onClick={() => select(chip.id)}
              className={cn(
                "inline-flex cursor-pointer items-center gap-[var(--hui-space-2)] rounded-[var(--hui-radius-full)] border-[0.5px] border-transparent bg-transparent px-[var(--hui-space-3)] py-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-secondary)] outline-none [font-weight:var(--hui-font-weight-medium)] motion-safe:[transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)] aria-pressed:border-[var(--hui-color-border-base-secondary)] aria-pressed:bg-[var(--hui-color-background-base-primary)] aria-pressed:text-[var(--hui-color-foreground-base-primary)] aria-pressed:shadow-[var(--hui-shadow-feather)]",
                chipText
              )}
            >
              {chip.label}
              <span className="tabular-nums text-[var(--hui-color-foreground-base-secondary)]">
                {chip.count}
              </span>
            </button>
          )
        })}
      </div>

      <Table aria-label={label}>
        <TableHeader>
          <TableRow>
            {columns.map((column) => (
              <TableHead key={column.id} scope="col" className={headText}>
                {column.header}
              </TableHead>
            ))}
          </TableRow>
        </TableHeader>
        <TableBody>
          {visibleRows.length === 0 ? (
            <TableRow>
              <TableCell
                colSpan={columns.length}
                className="text-center text-[var(--hui-color-foreground-base-secondary)]"
              >
                {emptyMessage}
              </TableCell>
            </TableRow>
          ) : (
            visibleRows.map((row) => (
              <TableRow key={getRowId(row)}>
                {columns.map((column) => (
                  <TableCell key={column.id}>{column.cell(row)}</TableCell>
                ))}
              </TableRow>
            ))
          )}
        </TableBody>
      </Table>
      <p role="status" className="sr-only">
        {`${visibleRows.length} of ${rows.length}`}
      </p>
    </div>
  )
}

export { FilterTable }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { FilterTable } from "@/components/ai/filter-table"

<FilterTable
  label="Agent tasks"
  rows={tasks}
  getRowId={(task) => task.id}
  columns={[
    { id: "title", header: "Task", cell: (task) => task.title },
    { id: "owner", header: "Owner", cell: (task) => task.owner },
  ]}
  filters={[
    { id: "open", label: "Open", matches: (task) => !task.isDone },
    { id: "done", label: "Completed", matches: (task) => task.isDone },
  ]}
/>
```

## Examples [#examples]

### Controlled filter [#controlled-filter]

The active filter is held in state and starts on Failed.

```tsx
"use client"

import * as React from "react"

import { FilterTable } from "@/components/ai/filter-table"

type Run = { id: string; name: string; result: "passed" | "failed" }

// Sample runs.
const sampleRuns: Run[] = [
  { id: "r1", name: "Export totals", result: "passed" },
  { id: "r2", name: "Staging migration", result: "failed" },
  { id: "r3", name: "Ticket triage", result: "passed" },
]

export function FilterTableControlledExample() {
  const [filter, setFilter] = React.useState<string | null>("failed")

  return (
    <div className="w-full max-w-lg min-w-0">
      <FilterTable
        label="Agent runs"
        rows={sampleRuns}
        getRowId={(row) => row.id}
        activeFilter={filter}
        onActiveFilterChange={setFilter}
        columns={[
          { id: "name", header: "Run", cell: (row) => row.name },
          { id: "result", header: "Result", cell: (row) => (row.result === "passed" ? "Passed" : "Failed") },
        ]}
        filters={[
          { id: "passed", label: "Passed", matches: (row) => row.result === "passed" },
          { id: "failed", label: "Failed", matches: (row) => row.result === "failed" },
        ]}
      />
    </div>
  )
}

```

## API reference [#api-reference]

### FilterTable [#filtertable]

| Prop                   | Type                                 | Default  | Description                                    |
| ---------------------- | ------------------------------------ | -------- | ---------------------------------------------- |
| `label`                | string                               | Required | Names the table.                               |
| `columns`              | `FilterTableColumn<Row>[]`           | Required | Each has `id`, `header`, and `cell`.           |
| `rows`                 | `Row[]`                              | Required | The rows to show and count.                    |
| `getRowId`             | `(row: Row) => string`               | Required | A stable id for each row.                      |
| `filters`              | `FilterTableFilter<Row>[]`           | Required | Each has `id`, `label`, and `matches`.         |
| `allLabel`             | string                               | `"All"`  | The chip that shows every row.                 |
| `filtersLabel`         | string                               | English  | The group name read before the chips.          |
| `emptyMessage`         | string                               | English  | Shown when the filter matches no rows.         |
| `activeFilter`         | `string \| null`                     | None     | The active filter's `id`, when you control it. |
| `defaultActiveFilter`  | `string \| null`                     | `null`   | The initially active filter.                   |
| `onActiveFilterChange` | `(filterId: string \| null) => void` | None     | Runs when a chip is pressed.                   |
