# Filter Table

> Status chips that filter a table in place.

- **Slug:** `filter-table`
- **Kind:** component · **Category:** Data
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/FilterTable.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/filter-table
- **All files in one bundle:** https://viewership.ai/ui/components/filter-table.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/filter-table.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/filter-table/`:

   - `FilterTable.astro` (raw: https://viewership.ai/ui/raw/filter-table/FilterTable.astro.txt)

npm packages: none.

## Usage

```astro
---
import FilterTable from "../components/viewership/filter-table/FilterTable.astro";
---

<FilterTable />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import FilterTable, { type FilterColumn, type FilterRow } from "./FilterTable.astro";

const columns: FilterColumn[] = [
  { key: "task", label: "Task name", width: 1.3, kind: "title" },
  { key: "date", label: "Date", width: 0.6, kind: "date" },
  { key: "status", label: "Status", width: 0.95, kind: "status" },
  { key: "account", label: "Channel", width: 0.9, kind: "muted" },
];

const rows: FilterRow[] = [
  { task: "Draft spring trail email", date: "Dec 03", status: "todo", account: "Email" },
  { task: "Review GEO citation report", date: "Sep 22", status: "progress", account: "Perplexity" },
  { task: "Approve gear guide video", date: "Jan 02", status: "todo", account: "YouTube" },
  { task: "Reply to camping thread", date: "Nov 08", status: "progress", account: "Reddit" },
  { task: "Send weekly GA4 report", date: "Apr 14", status: "done", account: "GA4" },
];
---

<div class="stage">
  <FilterTable rows={rows} columns={columns} caption="Marketing tasks" labels={{ result: "{filter}: showing {shown} of {total} tasks" }} />
</div>

<style>
  .stage {
    display: flex;
    justify-content: center;
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `rows` | `Record<string, string>[]` | `[]` | One object per row, keyed by column key. |
| `columns` | `{ key; label; width?; kind?: "title" \| "muted" \| "date" \| "status" }[]` | `task / date / status / owner` | Column order, labels, widths and cell style. |
| `statuses` | `{ key; label; tone?: "amber" \| "cyan" \| "green" \| "red" \| "violet" \| "gray" }[]` | `To do / In Progress / Completed` | Filter chips and status pills. Counts come from `rows`. |
| `statusKey` | `string` | `"status"` | Row field holding the status key. |
| `filter` | `string` | `"all"` | Initial filter. |
| `caption` | `string` | `"Tasks"` | Hidden table caption. |
| `labels` | `{ all?, filterGroup?, result? }` |  | Chip and screen reader text. `result` takes {shown}, {total}, {filter}. |

## Source

### `src/components/viewership/filter-table/FilterTable.astro`

```astro
---
/**
 * FilterTable: status chips that filter a table in place. Chip counts come
 * from the data.
 *
 * Ported from Beautiful UI `components/primitives/FilterTable.tsx`
 * (MIT, © 2026 Shane Levine).
 *
 * @example
 * <FilterTable rows={tasks} caption="Marketing tasks" />
 *
 * Events: `vs-filter-change` (detail: { filter, shown, total }) on the root.
 */
import type { HTMLAttributes } from "astro/types";

export type FilterTone = "amber" | "cyan" | "green" | "red" | "violet" | "gray";
export type FilterStatus = { key: string; label: string; tone?: FilterTone };
export type FilterColumn = {
  key: string;
  label: string;
  /** Relative column width. */
  width?: number;
  /** title = emphasised, muted = secondary ink, date = muted + tabular, status = pill. */
  kind?: "title" | "muted" | "date" | "status";
};
export type FilterRow = Record<string, string>;

type Props = HTMLAttributes<"div"> & {
  rows?: FilterRow[];
  columns?: FilterColumn[];
  statuses?: FilterStatus[];
  /** Row field that holds the status key. */
  statusKey?: string;
  /** Initially selected filter ("all" or a status key). */
  filter?: string;
  /** Visually hidden table caption (also names the scroll region). */
  caption?: string;
  labels?: {
    all?: string;
    filterGroup?: string;
    /** Announced after filtering. Tokens: {shown}, {total}, {filter}. */
    result?: string;
  };
};

const DEFAULT_COLUMNS: FilterColumn[] = [
  { key: "task", label: "Task name", width: 1.3, kind: "title" },
  { key: "date", label: "Date", width: 0.6, kind: "date" },
  { key: "status", label: "Status", width: 0.95, kind: "status" },
  { key: "owner", label: "Owner", width: 0.9, kind: "muted" },
];

const DEFAULT_STATUSES: FilterStatus[] = [
  { key: "todo", label: "To do", tone: "amber" },
  { key: "progress", label: "In Progress", tone: "cyan" },
  { key: "done", label: "Completed", tone: "green" },
];

const {
  rows = [],
  columns = DEFAULT_COLUMNS,
  statuses = DEFAULT_STATUSES,
  statusKey = "status",
  filter = "all",
  caption = "Tasks",
  labels,
  class: className,
  ...rest
} = Astro.props;

const text = {
  all: "All",
  filterGroup: "Filter by status",
  result: "{filter}: showing {shown} of {total} rows",
  ...labels,
};

const uid = `vs-ft-${Math.random().toString(36).slice(2, 8)}`;
const statusOf = new Map(statuses.map((s) => [s.key, s]));
const filters = [
  { key: "all", label: text.all, tone: undefined as FilterTone | undefined, count: rows.length },
  ...statuses.map((s) => ({ ...s, count: rows.filter((r) => r[statusKey] === s.key).length })),
];
const totalWidth = columns.reduce((n, c) => n + (c.width ?? 1), 0);
const shown = (row: FilterRow) => filter === "all" || row[statusKey] === filter;
---

<div class:list={["vs-filter-table", className]} data-status-key={statusKey} data-result={text.result} {...rest}>
  <div class="chips" role="group" aria-label={text.filterGroup}>
    {filters.map((f) => (
      <button type="button" class="chip" aria-pressed={filter === f.key ? "true" : "false"} aria-controls={uid} data-filter={f.key} data-label={f.label}>
        {f.tone && <span class:list={["dot", `tone-${f.tone}`]} aria-hidden="true"></span>}
        {f.label}
        <span class="count">{f.count}</span>
      </button>
    ))}
  </div>

  <div class="scroll" role="region" aria-label={`${caption}, scrollable`} tabindex="0">
    <table id={uid}>
      <caption class="sr-only">{caption}</caption>
      <colgroup>
        {columns.map((c) => <col style={`width: ${(((c.width ?? 1) / totalWidth) * 100).toFixed(3)}%`} />)}
      </colgroup>
      <thead>
        <tr>
          {columns.map((c) => <th scope="col">{c.label}</th>)}
        </tr>
      </thead>
      <tbody>
        {rows.map((row) => (
          <tr data-status={row[statusKey]} data-hidden={shown(row) ? undefined : ""} aria-hidden={shown(row) ? undefined : "true"}>
            {columns.map((c) => {
              const value = row[c.key] ?? "";
              const status = c.kind === "status" ? statusOf.get(value) : undefined;
              return (
                <td>
                  <div class="ft-collapse">
                    <div class="clip">
                      <div class:list={["cell", `cell--${c.kind ?? "muted"}`]}>
                        {c.kind === "status" ? (
                          <span class:list={["pill", `tone-${status?.tone ?? "gray"}`]}>{status?.label ?? value}</span>
                        ) : (
                          <span class="value">{value}</span>
                        )}
                      </div>
                    </div>
                  </div>
                </td>
              );
            })}
          </tr>
        ))}
      </tbody>
    </table>
  </div>

  <p class="sr-only" role="status" aria-live="polite" data-announce></p>
</div>

<script>
  function init(root: HTMLElement) {
    const chips = [...root.querySelectorAll<HTMLButtonElement>(".chip")];
    const rows = [...root.querySelectorAll<HTMLTableRowElement>("tbody tr")];
    const announce = root.querySelector<HTMLElement>("[data-announce]");
    const template = root.dataset.result ?? "";

    const apply = (key: string, chip: HTMLButtonElement) => {
      let shown = 0;
      for (const row of rows) {
        const visible = key === "all" || row.dataset.status === key;
        if (visible) shown++;
        row.toggleAttribute("data-hidden", !visible);
        if (visible) row.removeAttribute("aria-hidden");
        else row.setAttribute("aria-hidden", "true");
      }
      chips.forEach((c) => c.setAttribute("aria-pressed", String(c === chip)));
      if (announce) {
        announce.textContent = template
          .replace("{shown}", String(shown))
          .replace("{total}", String(rows.length))
          .replace("{filter}", chip.dataset.label ?? key);
      }
      root.dispatchEvent(
        new CustomEvent("vs-filter-change", { detail: { filter: key, shown, total: rows.length }, bubbles: true }),
      );
    };

    chips.forEach((chip) =>
      chip.addEventListener("click", () => {
        if (chip.getAttribute("aria-pressed") === "true") return;
        apply(chip.dataset.filter ?? "all", chip);
      }),
    );
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-filter-table:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .vs-filter-table {
    width: 100%;
    max-width: 420px;
  }

  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }

  /* status tones */
  .tone-amber {
    --tag-base: oklch(0.757 0.153 66.401);
  }
  .tone-cyan {
    --tag-base: oklch(0.671 0.118 219.351);
  }
  .tone-green {
    --tag-base: oklch(0.652 0.131 162.865);
  }
  .tone-red {
    --tag-base: oklch(0.65 0.17 24);
  }
  .tone-violet {
    --tag-base: oklch(0.62 0.19 300);
  }
  .tone-gray {
    --tag-base: var(--vs-ink-3);
  }

  /* filter chips */
  .chips {
    display: flex;
    align-items: center;
    gap: 4px;
    margin: 0 -4px 4px;
    padding: 4px;
    overflow-x: auto;
    scrollbar-width: none;
  }
  .chips::-webkit-scrollbar {
    display: none;
  }
  .chip {
    display: flex;
    flex-shrink: 0;
    align-items: center;
    gap: 6px;
    height: 26px;
    padding: 0 10px;
    border: none;
    border-radius: var(--vs-radius-pill);
    background: transparent;
    color: var(--vs-ink-2);
    font: inherit;
    font-size: 12px;
    font-weight: 500;
    cursor: pointer;
    transition:
      background-color 200ms,
      box-shadow 200ms,
      color 200ms;
  }
  .chip:hover {
    background: var(--vs-hover);
  }
  .chip[aria-pressed="true"] {
    background: var(--vs-surface);
    color: var(--vs-ink);
    box-shadow: var(--vs-shadow-btn);
  }
  .chip:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--tag-base);
  }
  .count {
    padding: 0 4px;
    border-radius: 4px;
    font-size: 10.5px;
    font-variant-numeric: tabular-nums;
    color: var(--vs-ink-3);
    transition: background-color 200ms, color 200ms;
  }
  .chip[aria-pressed="true"] .count {
    background: var(--vs-field);
    color: var(--vs-ink-2);
  }

  /* table */
  .scroll {
    overflow-x: auto;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
    scrollbar-width: none;
  }
  .scroll::-webkit-scrollbar {
    display: none;
  }
  .scroll:focus-visible {
    outline: none;
    box-shadow: var(--vs-shadow-card), var(--vs-focus-ring);
  }
  table {
    width: 100%;
    min-width: 420px;
    table-layout: fixed;
    border-collapse: separate;
    border-spacing: 0;
  }
  th,
  td {
    padding: 0;
    vertical-align: top;
    text-align: left;
    border-right: 1px solid var(--vs-grid-line);
  }
  th:last-child,
  td:last-child {
    border-right: none;
  }
  th {
    padding: 8px 12px;
    border-bottom: 1px solid var(--vs-grid-line);
    font-size: 12.5px;
    font-weight: 500;
    color: var(--vs-ink-2);
  }
  tbody tr {
    transition: background-color 100ms;
  }
  tbody tr:hover:not([data-hidden]) {
    background: var(--vs-hover);
  }

  /* row collapse: grid-template-rows 1fr ↔ 0fr */
  .ft-collapse {
    display: grid;
    grid-template-rows: 1fr;
    opacity: 1;
    transition:
      grid-template-rows 300ms var(--vs-ease-out),
      opacity 300ms var(--vs-ease-out);
  }
  tr[data-hidden] .ft-collapse {
    grid-template-rows: 0fr;
    opacity: 0;
  }
  .clip {
    min-height: 0;
    overflow: hidden;
  }
  .cell {
    display: flex;
    align-items: center;
    box-sizing: border-box;
    min-width: 0;
    min-height: 40px;
    padding: 8px 12px;
    border-bottom: 1px solid var(--vs-grid-line);
    font-size: 13px;
    color: var(--vs-ink-2);
  }
  .value {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .cell--title .value {
    font-weight: 500;
    color: var(--vs-ink);
  }
  .cell--date .value {
    font-variant-numeric: tabular-nums;
  }

  /* status pill */
  .pill {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    height: 23px;
    padding: 0 7px;
    border: 1px solid color-mix(in srgb, var(--tag-base) 34%, var(--vs-surface));
    border-radius: 8px;
    background: color-mix(in srgb, var(--tag-base) 20%, var(--vs-surface));
    color: color-mix(in srgb, var(--tag-base) 92%, var(--vs-ink));
    font-size: 13px;
    font-weight: 500;
    white-space: nowrap;
  }
  :global(:root[data-theme="dark"]) .pill {
    background: color-mix(in srgb, var(--tag-base) 34%, var(--vs-surface));
    color: color-mix(in srgb, var(--tag-base) 86%, white);
  }
  @media (prefers-color-scheme: dark) {
    :global(:root:not([data-theme="light"])) .pill {
      background: color-mix(in srgb, var(--tag-base) 34%, var(--vs-surface));
      color: color-mix(in srgb, var(--tag-base) 86%, white);
    }
  }
</style>
```
