Web InnoventixFreeCode

Search Table

Original · free

table with a search filter

byWeb InnoventixReact + Tailwind
tablesearchtables
Open

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.json
table-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 quote

Similar components

Browse all →