# Paginated Table With Search Filter

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/paginated-table-with-search-filter).

- Page, with a live preview: https://shadcn.exchange/r/weinc/paginated-table-with-search-filter
- Screenshot: https://shadcn.exchange/shot/weinc/paginated-table-with-search-filter/light.png
- Registry item: https://we.inc/r/paginated-table-with-search-filter.json
- Listed: 2026-10-29, source last changed 2026-10-29

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/paginated-table-with-search-filter.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `components/we-inc/paginated-table-with-search-filter.tsx`

```tsx
import { useMemo, useState } from "react";

// Stock list for a plant nursery: name, type, pot size, price, stock.
const plants = [
  ["Japanese maple 'Bloodgood'", "Tree", "10 L", 89, 6], ["Lavender 'Hidcote'", "Shrub", "2 L", 9.5, 48], ["Hellebore 'Ivory Prince'", "Perennial", "3 L", 14, 22],
  ["Silver birch", "Tree", "15 L", 65, 9], ["Hydrangea 'Annabelle'", "Shrub", "5 L", 24, 15], ["Foxglove 'Pam's Choice'", "Perennial", "2 L", 8, 0],
  ["Box ball 30 cm", "Topiary", "7 L", 39, 12], ["Rosemary 'Miss Jessopp'", "Herb", "1 L", 6.5, 60], ["Crab apple 'Red Sentinel'", "Tree", "12 L", 75, 4],
  ["Salvia 'Caradonna'", "Perennial", "2 L", 9, 31], ["Pittosporum 'Tom Thumb'", "Shrub", "3 L", 18, 11], ["Thyme 'Silver Posie'", "Herb", "9 cm", 4, 75],
  ["Bay tree standard", "Topiary", "20 L", 120, 3], ["Geranium 'Rozanne'", "Perennial", "2 L", 10, 40], ["Olive tree", "Tree", "25 L", 149, 2],
  ["Mint 'Moroccan'", "Herb", "1 L", 5, 0], ["Viburnum tinus", "Shrub", "5 L", 22, 19], ["Agapanthus 'Navy Blue'", "Perennial", "3 L", 16, 27],
] as const;
const PER_PAGE = 5;
const gbp = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" });

export default function PaginatedTableWithSearchFilter() {
  const [query, setQuery] = useState("");
  const [page, setPage] = useState(1);
  const found = useMemo(() => {
    const q = query.trim().toLowerCase();
    return q ? plants.filter((p) => `${p[0]} ${p[1]}`.toLowerCase().includes(q)) : plants;
  }, [query]);
  const pages = Math.max(1, Math.ceil(found.length / PER_PAGE));
  const current = Math.min(page, pages);
  const shown = found.slice((current - 1) * PER_PAGE, current * PER_PAGE);
  const first = found.length ? (current - 1) * PER_PAGE + 1 : 0;

  return (
    <section className="bg-sky-50 px-5 py-14 sm:py-20">
      <div className="mx-auto max-w-4xl rounded-2xl border border-sky-100 bg-white p-5 shadow-sm sm:p-6">
        <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
          <h2 className="text-lg font-semibold text-slate-900">Greenhythe Nursery stock</h2>
          <div className="relative sm:w-72">
            <label htmlFor="tpg-q" className="sr-only">Search plants</label>
            <svg viewBox="0 0 20 20" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="9" cy="9" r="6" /><path d="m14 14 4 4" strokeLinecap="round" /></svg>
            <input
              id="tpg-q"
              type="search"
              value={query}
              onChange={(e) => { setQuery(e.target.value); setPage(1); }}
              placeholder="Search by name or type"
              className="h-10 w-full rounded-lg border border-slate-300 pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-500 focus:border-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-600/30"
            />
          </div>
        </div>
        <div className="mt-5 overflow-x-auto">
          <table className="w-full min-w-[560px] border-collapse text-left text-sm">
            <caption className="sr-only">Plants in stock, page {current} of {pages}</caption>
            <thead>
              <tr className="border-b border-slate-200 text-slate-600">
                <th scope="col" className="py-2.5 pr-3 font-medium">Plant</th>
                <th scope="col" className="px-3 py-2.5 font-medium">Type</th>
                <th scope="col" className="px-3 py-2.5 font-medium">Pot</th>
                <th scope="col" className="px-3 py-2.5 text-right font-medium">Price</th>
                <th scope="col" className="py-2.5 pl-3 text-right font-medium">In stock</th>
              </tr>
            </thead>
            <tbody className="divide-y divide-slate-100">
              {shown.map(([name, type, pot, price, stock]) => (
                <tr key={name}>
                  <th scope="row" className="py-3 pr-3 font-medium text-slate-900">{name}</th>
                  <td className="px-3 py-3 text-slate-600">{type}</td>
                  <td className="px-3 py-3 text-slate-600">{pot}</td>
                  <td className="px-3 py-3 text-right tabular-nums text-slate-900">{gbp.format(price)}</td>
                  <td className={`py-3 pl-3 text-right tabular-nums ${stock === 0 ? "font-medium text-rose-700" : "text-slate-700"}`}>{stock === 0 ? "Sold out" : stock}</td>
                </tr>
              ))}
              {shown.length === 0 && <tr><td colSpan={5} className="py-10 text-center text-slate-600">No plants match "{query}".</td></tr>}
            </tbody>
          </table>
        </div>
        <nav aria-label="Table pages" className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-4">
          <p className="text-sm text-slate-600" aria-live="polite">{first} to {Math.min(current * PER_PAGE, found.length)} of {found.length} plants</p>
          <div className="flex items-center gap-1">
            <button type="button" onClick={() => setPage(current - 1)} disabled={current === 1} className="h-10 rounded-lg px-3 text-sm font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600">Previous</button>
            {Array.from({ length: pages }, (_, i) => i + 1).map((n) => (
              <button key={n} type="button" onClick={() => setPage(n)} aria-current={n === current ? "page" : undefined} aria-label={`Page ${n}`} className={`h-10 w-10 rounded-lg text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 ${n === current ? "bg-sky-700 text-white" : "text-slate-700 hover:bg-slate-100"}`}>{n}</button>
            ))}
            <button type="button" onClick={() => setPage(current + 1)} disabled={current === pages} className="h-10 rounded-lg px-3 text-sm font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600">Next</button>
          </div>
        </nav>
      </div>
    </section>
  );
}
```
