Search Table
Original · freetable with a search filter
byWeb InnoventixReact + Tailwind
tablesearchtables
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/table-search.jsontable-search.tsx
"use client";
import { useId, useMemo, useState, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
type Status = "active" | "away" | "offline";
type SortKey = "name" | "role" | "department" | "location";
type SortDir = "asc" | "desc";
type StatusFilter = "all" | Status;
interface Member {
id: number;
name: string;
email: string;
role: string;
department: string;
location: string;
status: Status;
}
interface SortState {
key: SortKey;
dir: SortDir;
}
const MEMBERS: Member[] = [
{ id: 1, name: "Priya Nair", email: "priya.nair@meridianlabs.dev", role: "Staff Engineer", department: "Platform", location: "Bengaluru, IN", status: "active" },
{ id: 2, name: "Marcus Feld", email: "marcus.feld@meridianlabs.dev", role: "Design Lead", department: "Product Design", location: "Berlin, DE", status: "away" },
{ id: 3, name: "Sofia Reyes", email: "sofia.reyes@meridianlabs.dev", role: "Engineering Manager", department: "Payments", location: "Lisbon, PT", status: "active" },
{ id: 4, name: "Daniel Kwon", email: "daniel.kwon@meridianlabs.dev", role: "Backend Engineer", department: "Infrastructure", location: "Seoul, KR", status: "offline" },
{ id: 5, name: "Amara Okafor", email: "amara.okafor@meridianlabs.dev", role: "Product Manager", department: "Growth", location: "Lagos, NG", status: "active" },
{ id: 6, name: "Liam Byrne", email: "liam.byrne@meridianlabs.dev", role: "QA Engineer", department: "Quality", location: "Dublin, IE", status: "away" },
{ id: 7, name: "Hana Suzuki", email: "hana.suzuki@meridianlabs.dev", role: "Data Scientist", department: "Analytics", location: "Osaka, JP", status: "active" },
{ id: 8, name: "Tomas Novak", email: "tomas.novak@meridianlabs.dev", role: "DevOps Engineer", department: "Infrastructure", location: "Prague, CZ", status: "offline" },
{ id: 9, name: "Élodie Martin", email: "elodie.martin@meridianlabs.dev", role: "UX Researcher", department: "Product Design", location: "Lyon, FR", status: "active" },
{ id: 10, name: "Rafael Costa", email: "rafael.costa@meridianlabs.dev", role: "Frontend Engineer", department: "Web", location: "São Paulo, BR", status: "away" },
{ id: 11, name: "Nadia Hassan", email: "nadia.hassan@meridianlabs.dev", role: "Security Engineer", department: "Platform", location: "Cairo, EG", status: "active" },
{ id: 12, name: "Owen Wright", email: "owen.wright@meridianlabs.dev", role: "Technical Writer", department: "Docs", location: "Austin, US", status: "offline" },
];
const STATUS_META: Record<Status, { label: string; dot: string; chip: string }> = {
active: {
label: "Active",
dot: "bg-emerald-500",
chip: "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
},
away: {
label: "Away",
dot: "bg-amber-500",
chip: "bg-amber-50 text-amber-700 ring-1 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
},
offline: {
label: "Offline",
dot: "bg-zinc-400 dark:bg-zinc-500",
chip: "bg-zinc-100 text-zinc-600 ring-1 ring-zinc-500/20 dark:bg-zinc-500/10 dark:text-zinc-400 dark:ring-zinc-400/20",
},
};
const COLUMNS: { key: SortKey; label: string }[] = [
{ key: "name", label: "Teammate" },
{ key: "role", label: "Role" },
{ key: "department", label: "Team" },
{ key: "location", label: "Location" },
];
const FILTERS: StatusFilter[] = ["all", "active", "away", "offline"];
function initials(name: string): string {
return name
.split(" ")
.map((part) => part[0])
.slice(0, 2)
.join("")
.toUpperCase();
}
function highlight(text: string, query: string): ReactNode {
const q = query.trim();
if (!q) return text;
const lower = text.toLowerCase();
const needle = q.toLowerCase();
const nodes: ReactNode[] = [];
let cursor = 0;
let key = 0;
while (cursor < text.length) {
const idx = lower.indexOf(needle, cursor);
if (idx === -1) {
nodes.push(text.slice(cursor));
break;
}
if (idx > cursor) nodes.push(text.slice(cursor, idx));
nodes.push(
<mark
key={key++}
className="rounded-[3px] bg-amber-200/70 px-0.5 text-neutral-900 dark:bg-amber-300/25 dark:text-amber-100"
>
{text.slice(idx, idx + q.length)}
</mark>,
);
cursor = idx + q.length;
}
return nodes;
}
function SortGlyph({ active, dir }: { active: boolean; dir: SortDir }) {
return (
<svg
viewBox="0 0 12 12"
aria-hidden="true"
className={`h-3 w-3 shrink-0 transition-transform duration-200 ${
active
? "text-indigo-600 dark:text-indigo-400"
: "text-neutral-300 group-hover:text-neutral-400 dark:text-neutral-600 dark:group-hover:text-neutral-500"
} ${active && dir === "desc" ? "rotate-180" : ""}`}
>
<path
d="M6 2.25v7.5M3 6l3-3 3 3"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function TableSearch() {
const reduce = useReducedMotion();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [sort, setSort] = useState<SortState>({ key: "name", dir: "asc" });
const countId = useId();
const captionId = useId();
const counts = useMemo(() => {
const base: Record<StatusFilter, number> = { all: MEMBERS.length, active: 0, away: 0, offline: 0 };
for (const m of MEMBERS) base[m.status] += 1;
return base;
}, []);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
const filtered = MEMBERS.filter((m) => {
if (statusFilter !== "all" && m.status !== statusFilter) return false;
if (!q) return true;
return [m.name, m.email, m.role, m.department, m.location].some((field) =>
field.toLowerCase().includes(q),
);
});
return [...filtered].sort((a, b) => {
const av = a[sort.key].toLowerCase();
const bv = b[sort.key].toLowerCase();
if (av < bv) return sort.dir === "asc" ? -1 : 1;
if (av > bv) return sort.dir === "asc" ? 1 : -1;
return 0;
});
}, [query, statusFilter, sort]);
function toggleSort(key: SortKey) {
setSort((prev) =>
prev.key === key ? { key, dir: prev.dir === "asc" ? "desc" : "asc" } : { key, dir: "asc" },
);
}
function ariaSort(key: SortKey): "ascending" | "descending" | "none" {
if (sort.key !== key) return "none";
return sort.dir === "asc" ? "ascending" : "descending";
}
function resetView() {
setQuery("");
setStatusFilter("all");
}
return (
<section className="relative w-full bg-neutral-50 px-4 py-16 text-neutral-900 sm:px-6 lg:px-8 dark:bg-neutral-950 dark:text-neutral-100">
<style>{`
@keyframes tblsrch-fade-up {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes tblsrch-ping {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.78); }
}
@media (prefers-reduced-motion: reduce) {
.tblsrch-row, .tblsrch-live { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<header className="mb-8 flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Meridian Labs · Directory
</span>
<h2 className="text-2xl font-semibold tracking-tight text-neutral-900 sm:text-3xl dark:text-white">
Find anyone on the team
</h2>
<p className="max-w-prose text-sm text-neutral-500 dark:text-neutral-400">
Search across names, roles, teams and locations. Sort any column and filter by
presence to see who is around right now.
</p>
</header>
<div className="mb-5 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="relative w-full md:max-w-sm">
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-3.5 flex items-center text-neutral-400 dark:text-neutral-500"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" aria-hidden="true">
<circle cx="9" cy="9" r="6" stroke="currentColor" strokeWidth="1.6" />
<path d="M14 14l3.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</span>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setQuery("");
}}
placeholder="Search name, role, team, location…"
aria-label="Search team members"
aria-describedby={countId}
className="w-full rounded-xl border border-neutral-200 bg-white py-2.5 pl-10 pr-10 text-sm text-neutral-900 shadow-sm outline-none transition placeholder:text-neutral-400 focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-500 dark:focus-visible:border-indigo-400 dark:focus-visible:ring-indigo-400/30"
/>
{query.length > 0 && (
<button
type="button"
onClick={() => setQuery("")}
aria-label="Clear search"
className="absolute inset-y-0 right-2.5 my-auto flex h-6 w-6 items-center justify-center rounded-md text-neutral-400 transition hover:bg-neutral-100 hover:text-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
>
<svg viewBox="0 0 14 14" className="h-3.5 w-3.5" aria-hidden="true">
<path
d="M3.5 3.5l7 7M10.5 3.5l-7 7"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</button>
)}
</div>
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Filter by presence">
{FILTERS.map((filter) => {
const selected = statusFilter === filter;
const label = filter === "all" ? "All" : STATUS_META[filter].label;
return (
<button
key={filter}
type="button"
onClick={() => setStatusFilter(filter)}
aria-pressed={selected}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950 ${
selected
? "bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 dark:bg-indigo-500"
: "bg-white text-neutral-600 ring-1 ring-neutral-200 hover:bg-neutral-100 dark:bg-neutral-900 dark:text-neutral-300 dark:ring-neutral-700 dark:hover:bg-neutral-800"
}`}
>
{filter !== "all" && (
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${selected ? "bg-white/90" : STATUS_META[filter].dot}`}
/>
)}
{label}
<span
className={`tabular-nums ${selected ? "text-white/70" : "text-neutral-400 dark:text-neutral-500"}`}
>
{counts[filter]}
</span>
</button>
);
})}
</div>
</div>
<p id={countId} aria-live="polite" className="mb-3 text-xs text-neutral-500 dark:text-neutral-400">
Showing{" "}
<motion.span
key={rows.length}
initial={reduce ? false : { opacity: 0, y: -3 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="inline-block font-semibold tabular-nums text-neutral-900 dark:text-neutral-100"
>
{rows.length}
</motion.span>{" "}
of {MEMBERS.length} teammates
{query.trim() && (
<>
{" "}
for <span className="font-medium text-neutral-700 dark:text-neutral-300">“{query.trim()}”</span>
</>
)}
</p>
<motion.div
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? {} : { opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900"
>
<div className="overflow-x-auto">
<table className="w-full min-w-[46rem] border-collapse text-left text-sm">
<caption id={captionId} className="sr-only">
Team directory. Use the column header buttons to sort.
</caption>
<thead>
<tr className="border-b border-neutral-200 dark:border-neutral-800">
{COLUMNS.map((col) => (
<th
key={col.key}
scope="col"
aria-sort={ariaSort(col.key)}
className="whitespace-nowrap px-5 py-3.5 font-medium"
>
<button
type="button"
onClick={() => toggleSort(col.key)}
className="group -mx-1.5 inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-semibold uppercase tracking-wider text-neutral-500 transition hover:text-neutral-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-neutral-400 dark:hover:text-neutral-100"
>
{col.label}
<SortGlyph active={sort.key === col.key} dir={sort.dir} />
</button>
</th>
))}
<th
scope="col"
className="whitespace-nowrap px-5 py-3.5 text-right text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400"
>
Status
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-16">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-neutral-100 text-neutral-400 dark:bg-neutral-800 dark:text-neutral-500">
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" aria-hidden="true">
<circle cx="9" cy="9" r="6" stroke="currentColor" strokeWidth="1.6" />
<path d="M14 14l3.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</span>
<div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
No teammates match your filters
</p>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Try a different search term or clear the presence filter.
</p>
</div>
<button
type="button"
onClick={resetView}
className="mt-1 rounded-lg bg-indigo-600 px-3.5 py-2 text-xs font-medium text-white transition hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:ring-offset-neutral-900"
>
Reset view
</button>
</div>
</td>
</tr>
) : (
rows.map((member, index) => {
const meta = STATUS_META[member.status];
return (
<tr
key={member.id}
className="tblsrch-row border-b border-neutral-100 transition-colors last:border-b-0 hover:bg-neutral-50 dark:border-neutral-800/70 dark:hover:bg-neutral-800/40"
style={
reduce
? undefined
: {
animation: "tblsrch-fade-up 0.35s ease-out both",
animationDelay: `${Math.min(index, 12) * 28}ms`,
}
}
>
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-500 text-xs font-semibold text-white"
>
{initials(member.name)}
</span>
<div className="min-w-0">
<div className="truncate font-medium text-neutral-900 dark:text-neutral-100">
{highlight(member.name, query)}
</div>
<div className="truncate text-xs text-neutral-500 dark:text-neutral-400">
{highlight(member.email, query)}
</div>
</div>
</div>
</td>
<td className="px-5 py-3.5 text-neutral-700 dark:text-neutral-300">
{highlight(member.role, query)}
</td>
<td className="px-5 py-3.5">
<span className="inline-flex rounded-md bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
{highlight(member.department, query)}
</span>
</td>
<td className="px-5 py-3.5 text-neutral-600 dark:text-neutral-400">
{highlight(member.location, query)}
</td>
<td className="px-5 py-3.5 text-right">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${meta.chip}`}
>
<span
aria-hidden="true"
className={`tblsrch-live h-1.5 w-1.5 rounded-full ${meta.dot}`}
style={
reduce || member.status !== "active"
? undefined
: { animation: "tblsrch-ping 2s ease-in-out infinite" }
}
/>
{meta.label}
</span>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</motion.div>
<p className="mt-3 text-center text-xs text-neutral-400 dark:text-neutral-500">
Tip: press <kbd className="rounded border border-neutral-300 px-1 font-sans text-[0.65rem] text-neutral-500 dark:border-neutral-700 dark:text-neutral-400">Esc</kbd> while searching to clear.
</p>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Table
Originalclean basic data table

Striped Table
Originalstriped-row table

Bordered Table
Originalbordered table

Hover Table
Originalrow-hover highlight table

Sortable Table
Originalsortable-column table

Selectable Table
Originaltable with row checkboxes and select-all

Sticky Header Table
Originalsticky header scroll table

Pagination Table
Originaltable with footer pagination

Expandable Table
Originaltable with expandable rows

Actions Table
Originaltable with row action menus

Status Badges Table
Originaltable with status badges

Compact Table
Originaldense compact table

