Filter Table
Status chips that reorganize live data.
"use client";
/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import "./styles/foundation.css";
import { useState } from "react";
/* ─────────────────────────────────────────────────────────
* FILTER TABLE
* Status chips directly filter the task table.
* ───────────────────────────────────────────────────────── */
type Status = "todo" | "progress" | "done";
export type TableRow = { task: string; date: string; status: Status; owner: string };
export type FilterTableLabels = {
columns: { task: string; date: string; status: string; owner: string };
};
const FILTERS: { key: "all" | Status; label: string; dot?: string; count: number }[] = [
{ key: "all", label: "All", count: 5 },
{ key: "todo", label: "To do", dot: "#f09a2f", count: 2 },
{ key: "progress", label: "In Progress", dot: "#16a6c7", count: 2 },
{ key: "done", label: "Completed", dot: "#25a878", count: 1 },
];
const ROWS: TableRow[] = [
{ task: "Restock mango sorbet", date: "Dec 03", status: "todo", owner: "Mango Moon Brand" },
{ task: "Plan black sesame", date: "Sep 22", status: "progress", owner: "Kumo Studio" },
{ task: "Print summer menu", date: "Jan 02", status: "todo", owner: "Coral Coast Sorbet" },
{ task: "Taste-test batch 42", date: "Nov 08", status: "progress", owner: "Maple Orbit" },
{ task: "Order design assets", date: "Apr 14", status: "done", owner: "Aurora Works" },
];
const LABELS: FilterTableLabels = {
columns: { task: "Task name", date: "Date", status: "Status", owner: "Advisor" },
};
const PILLS: Record<Status, { label: string; cls: string }> = {
todo: { label: "To do", cls: "filter-status-todo" },
progress: { label: "In Progress", cls: "filter-status-progress" },
done: { label: "Completed", cls: "filter-status-done" },
};
export default function FilterTable({
rows = ROWS,
labels = LABELS,
}: {
rows?: TableRow[];
labels?: FilterTableLabels;
variant?: string;
} = {}) {
const [filter, setFilter] = useState<"all" | Status>("all");
return (
<div className="w-full max-w-105">
{/* filter chips */}
<div
className="-mx-1 mb-1 flex items-center gap-1 overflow-x-auto px-1 py-1"
style={{ scrollbarWidth: "none" }}
>
{FILTERS.map((f) => {
const active = filter === f.key;
return (
<button
key={f.key}
type="button"
aria-pressed={active}
onClick={() => setFilter(f.key)}
className={`flex h-6.5 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px]
font-medium transition-[background-color,box-shadow,color] duration-200
${active ? "bg-card text-ink shadow-btn" : "text-ink-2 hover:bg-hover"}`}
>
{f.dot && <span className="size-1.5 rounded-full" style={{ background: f.dot }} />}
{f.label}
<span
className={`rounded-[4px] px-1 text-[10.5px] tabular-nums
${active ? "bg-field text-ink-2" : "text-ink-3"}`}
>
{f.count}
</span>
</button>
);
})}
</div>
{/* table */}
<div
aria-label="Scrollable task table"
className="overflow-x-auto rounded-card bg-card shadow-card"
role="region"
tabIndex={0}
style={{ scrollbarWidth: "none" }}
>
<div className="min-w-[420px]">
<div className="grid grid-cols-[minmax(0,1.3fr)_minmax(0,0.6fr)_minmax(0,0.95fr)_minmax(0,0.9fr)] border-b border-[var(--grid-line)] text-[12.5px] font-medium text-ink-2">
<span className="border-r border-[var(--grid-line)] px-3 py-2">{labels.columns.task}</span>
<span className="border-r border-[var(--grid-line)] px-3 py-2">{labels.columns.date}</span>
<span className="border-r border-[var(--grid-line)] px-3 py-2">{labels.columns.status}</span>
<span className="px-3 py-2">{labels.columns.owner}</span>
</div>
{rows.map((row) => {
const shown = filter === "all" || row.status === filter;
const pill = PILLS[row.status];
return (
<div
key={row.task}
className="grid transition-[grid-template-rows,opacity] duration-300"
style={{
gridTemplateRows: shown ? "1fr" : "0fr",
opacity: shown ? 1 : 0,
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<div className="overflow-hidden">
<div
className="grid grid-cols-[minmax(0,1.3fr)_minmax(0,0.6fr)_minmax(0,0.95fr)_minmax(0,0.9fr)] border-b
border-[var(--grid-line)] text-[13px] transition-colors duration-100 hover:bg-hover"
>
<span className="flex min-w-0 items-center border-r border-[var(--grid-line)] px-3 py-2">
<span className="truncate font-medium text-ink">{row.task}</span>
</span>
<span className="flex items-center whitespace-nowrap border-r border-[var(--grid-line)] px-3 py-2 text-ink-2 tabular-nums">
{row.date}
</span>
<span className="flex items-center border-r border-[var(--grid-line)] px-3 py-2">
<span
className={`inline-flex h-[23px] shrink-0 items-center whitespace-nowrap rounded-[8px] border px-[7px]
text-[13px] font-medium ${pill.cls}`}
>
{pill.label}
</span>
</span>
<span className="flex min-w-0 items-center px-3 py-2 text-ink-2">
<span className="truncate">{row.owner}</span>
</span>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add @ward/filter-tablenpx shadcn@latest add @ward/filter-tableyarn dlx shadcn@latest add @ward/filter-tablebunx --bun shadcn@latest add @ward/filter-tableAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/filter-table.json
Add the Ward items it builds on.
npx shadcn@latest add @ward/foundationCopy each file from the Code tab into:
components/ward/FilterTable.tsx
Update the import paths to match your project.
Usage
import FilterTable from "@/components/ward/FilterTable";<FilterTable
labels={{ columns: { task: "Task", date: "Due", status: "Status", owner: "Owner" } }}
rows={[
{ task: "Restock mango sorbet", date: "Dec 03", status: "todo", owner: "Mango Moon Brand" },
{ task: "Order design assets", date: "Apr 14", status: "done", owner: "Aurora Works" },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | string | "Default" | Accepted for registry parity; the component has a single variant. |
rows | TableRow[] | ROWS | Task rows shown in the table, each with a task name, date, status and owner. |
labels | FilterTableLabels | LABELS | Column header text for the task, date, status and owner columns. |
Ward · Version 1.1.0 · Registry manifest