Skip to content
ilinxa/pro-ui

Filter Panel

alphav0.2.1

Schema-driven filter panel — checkbox lists, toggles, text, and custom filter types with AND composition and debounced input.

Category: FormsUpdated: 2026-08-18Created: 2026-04-29Author: ilinxa

Context

Tier 1 pro-component for the graph-system. Generic over the item type; the host supplies items, categories, predicates, and decides what to do with the filtered output. AND-across-categories with OR-or-AND-within-category controlled by the host's predicate. Pairs with the graph in force-graph v0.4 for the groups/filter panel; useful standalone wherever a faceted-filter sidebar is needed.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/filter-panel

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/filter-panel-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Kind
Search by name
Pinned only
Off
  • Rina Okaforperson
    securityauth
  • Marc Bernalperson
    frontend
  • Acme Corporg
    customertier-1
  • Verda Holdingsorg
    customer
  • auth-v2 migrationproject
    securityauthtier-1
  • registry publishproject
    frontendops
  • v2-runbook.mddoc
    securityauth
  • design-tokens.mddoc
    frontend

Demo source

demo.tsxtsx
"use client"; import { useCallback, useMemo, useState } from "react";import { Pin } from "lucide-react";import { Badge } from "@/components/ui/badge";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { FilterPanel } from "./filter-panel";import {  KIND_OPTIONS,  NODE_FIXTURES,  TAG_OPTIONS,  type GraphNodeFixture,  type GraphNodeKind,} from "./dummy-data";import type {  FilterCategory,  FilterMode,  FilterValue,} from "./types"; function asStringArray(v: FilterValue): string[] {  if (!Array.isArray(v)) return [];  return v.filter((x): x is string => typeof x === "string");} function FilteredList({ items }: { items: ReadonlyArray<GraphNodeFixture> }) {  if (items.length === 0) {    return (      <p className="rounded-md border border-dashed border-border p-4 text-center text-xs text-muted-foreground">        No matches.      </p>    );  }  return (    <ul className="flex flex-col gap-1.5">      {items.map((n) => (        <li          key={n.id}          className="flex items-center justify-between gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm"        >          <div className="flex flex-col">            <span className="flex items-center gap-2 font-medium text-foreground">              {n.label}              {n.pinned ? (                <Pin aria-hidden="true" className="size-3 text-primary" />              ) : null}            </span>            <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">              {n.kind}            </span>          </div>          <div className="flex flex-wrap justify-end gap-1">            {n.tags.map((t) => (              <Badge key={t} variant="secondary" className="font-mono text-[10px]">                {t}              </Badge>            ))}          </div>        </li>      ))}    </ul>  );} function PanelLayout({  panel,  list,}: {  panel: React.ReactNode;  list: React.ReactNode;}) {  return (    <div className="grid gap-4 lg:grid-cols-[280px_1fr]">      <div className="rounded-md border border-border bg-card/30 p-3">        {panel}      </div>      <div>{list}</div>    </div>  );} function BasicDemo() {  const [values, setValues] = useState<Record<string, FilterValue>>({});  const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>(    () => [      {        id: "kind",        type: "checkbox-list",        label: "Kind",        options: KIND_OPTIONS as unknown as Array<{          value: string;          label: string;        }>,        predicate: (item, value) => {          const sel = asStringArray(value);          return sel.length === 0 || sel.includes(item.kind);        },      },      {        id: "search",        type: "text",        label: "Search by name",        placeholder: "Type to filter…",        predicate: (item, value) => {          if (typeof value !== "string" || value.length === 0) return true;          return item.label.toLowerCase().includes(value.toLowerCase());        },      },      {        id: "pinned",        type: "toggle",        label: "Pinned only",        isEmpty: (v) => v !== true,        predicate: (item, value) => (value === true ? item.pinned : true),      },    ],    [],  );   const filtered = useMemo(() => {    return NODE_FIXTURES.filter((n) =>      categories.every((c) => c.predicate(n, values[c.id])),    );  }, [categories, values]);   return (    <PanelLayout      panel={        <FilterPanel<GraphNodeFixture>          items={NODE_FIXTURES}          categories={categories}          values={values}          onChange={setValues}          ariaLabel="Basic facets"        />      }      list={<FilteredList items={filtered} />}    />  );} function ModeToggleDemo() {  const [values, setValues] = useState<Record<string, FilterValue>>({    tags: ["security", "frontend"],  });   const tagMode: FilterMode =    values["tags__mode"] === "intersection" ? "intersection" : "union";   const tagsPredicate = useCallback(    (item: GraphNodeFixture, value: FilterValue) => {      const sel = asStringArray(value);      if (sel.length === 0) return true;      if (tagMode === "intersection") {        return sel.every((t) => item.tags.includes(t));      }      return sel.some((t) => item.tags.includes(t));    },    [tagMode],  );   const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>(    () => [      {        id: "tags",        type: "checkbox-list",        label: "Tags",        description:          "Union: any selected tag matches. Intersection: all selected tags must match.",        options: TAG_OPTIONS as unknown as Array<{          value: string;          label: string;        }>,        modeToggle: true,        defaultMode: "union",        predicate: tagsPredicate,      },    ],    [tagsPredicate],  );   const filtered = useMemo(    () =>      NODE_FIXTURES.filter((n) =>        categories.every((c) => c.predicate(n, values[c.id])),      ),    [categories, values],  );   return (    <PanelLayout      panel={        <FilterPanel<GraphNodeFixture>          items={NODE_FIXTURES}          categories={categories}          values={values}          onChange={setValues}          ariaLabel="Mode toggle"        />      }      list={<FilteredList items={filtered} />}    />  );} function SoloButtonsDemo() {  const [values, setValues] = useState<Record<string, FilterValue>>({});   const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>(    () => [      {        id: "kind",        type: "checkbox-list",        label: "Kind",        description:          "Hover a row to reveal the solo affordance — collapses selection to that one option.",        options: KIND_OPTIONS as unknown as Array<{          value: string;          label: string;        }>,        showSoloButtons: true,        predicate: (item, value) => {          const sel = asStringArray(value);          return sel.length === 0 || sel.includes(item.kind);        },      },    ],    [],  );   const filtered = useMemo(    () =>      NODE_FIXTURES.filter((n) =>        categories.every((c) => c.predicate(n, values[c.id])),      ),    [categories, values],  );   return (    <PanelLayout      panel={        <FilterPanel<GraphNodeFixture>          items={NODE_FIXTURES}          categories={categories}          values={values}          onChange={setValues}          ariaLabel="Solo buttons"        />      }      list={<FilteredList items={filtered} />}    />  );} function CustomRangeDemo() {  const [values, setValues] = useState<Record<string, FilterValue>>({});   const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>(    () => [      {        id: "members",        type: "custom",        label: "Members",        description: "Custom range slot — host owns the renderer.",        isEmpty: (v) => {          if (!Array.isArray(v) || v.length !== 2) return true;          const [min, max] = v as [number, number];          return min === 0 && max >= 1000;        },        predicate: (item, value) => {          if (!Array.isArray(value) || value.length !== 2) return true;          const [min, max] = value as [number, number];          return item.members >= min && item.members <= max;        },        render: ({ value, onChange, fieldId }) => {          const range =            Array.isArray(value) && value.length === 2              ? (value as [number, number])              : ([0, 1000] as [number, number]);          return (            <div className="flex flex-col gap-2 px-1">              <div className="flex items-center gap-2 text-sm">                <label                  htmlFor={`${fieldId}-min`}                  className="text-xs text-muted-foreground"                >                  min                </label>                <input                  id={`${fieldId}-min`}                  type="number"                  value={range[0]}                  min={0}                  onChange={(e) =>                    onChange([Number(e.target.value) || 0, range[1]])                  }                  className="h-8 w-24 rounded-md border border-input bg-transparent px-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"                />                <label                  htmlFor={`${fieldId}-max`}                  className="text-xs text-muted-foreground"                >                  max                </label>                <input                  id={`${fieldId}-max`}                  type="number"                  value={range[1]}                  min={0}                  onChange={(e) =>                    onChange([range[0], Number(e.target.value) || 0])                  }                  className="h-8 w-24 rounded-md border border-input bg-transparent px-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"                />              </div>              <p className="text-[10px] text-muted-foreground">                Default range 0–1000 reads as &quot;empty&quot;.              </p>            </div>          );        },      },    ],    [],  );   const filtered = useMemo(    () =>      NODE_FIXTURES.filter((n) =>        categories.every((c) => c.predicate(n, values[c.id])),      ),    [categories, values],  );   return (    <PanelLayout      panel={        <FilterPanel<GraphNodeFixture>          items={NODE_FIXTURES}          categories={categories}          values={values}          onChange={setValues}          ariaLabel="Custom range"        />      }      list={<FilteredList items={filtered} />}    />  );} function RichDemo() {  const [values, setValues] = useState<Record<string, FilterValue>>({});   const tagMode: FilterMode =    values["tags__mode"] === "intersection" ? "intersection" : "union";   const tagsPredicate = useCallback(    (item: GraphNodeFixture, value: FilterValue) => {      const sel = asStringArray(value);      if (sel.length === 0) return true;      if (tagMode === "intersection") {        return sel.every((t) => item.tags.includes(t));      }      return sel.some((t) => item.tags.includes(t));    },    [tagMode],  );   const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>(    () => [      {        id: "search",        type: "text",        label: "Search",        placeholder: "Filter by label…",        predicate: (item, value) => {          if (typeof value !== "string" || value.length === 0) return true;          return item.label.toLowerCase().includes(value.toLowerCase());        },      },      {        id: "kind",        type: "checkbox-list",        label: "Kind",        options: KIND_OPTIONS as unknown as Array<{          value: string;          label: string;        }>,        showSoloButtons: true,        predicate: (item, value) => {          const sel = asStringArray(value);          return sel.length === 0 || sel.includes(item.kind as GraphNodeKind);        },      },      {        id: "tags",        type: "checkbox-list",        label: "Tags",        options: TAG_OPTIONS as unknown as Array<{          value: string;          label: string;        }>,        modeToggle: true,        defaultMode: "union",        predicate: tagsPredicate,      },      {        id: "pinned",        type: "toggle",        label: "Pinned only",        isEmpty: (v) => v !== true,        predicate: (item, value) => (value === true ? item.pinned : true),      },    ],    [tagsPredicate],  );   const filtered = useMemo(    () =>      NODE_FIXTURES.filter((n) =>        categories.every((c) => c.predicate(n, values[c.id])),      ),    [categories, values],  );   return (    <PanelLayout      panel={        <FilterPanel<GraphNodeFixture>          items={NODE_FIXTURES}          categories={categories}          values={values}          onChange={setValues}          ariaLabel="Rich faceted"        />      }      list={<FilteredList items={filtered} />}    />  );} function OnFilteredChangeDemo() {  const [values, setValues] = useState<Record<string, FilterValue>>({});  const [emitCount, setEmitCount] = useState(0);  const [lastSize, setLastSize] = useState<number | null>(null);   const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>(    () => [      {        id: "kind",        type: "checkbox-list",        label: "Kind",        options: KIND_OPTIONS as unknown as Array<{          value: string;          label: string;        }>,        predicate: (item, value) => {          const sel = asStringArray(value);          return sel.length === 0 || sel.includes(item.kind);        },      },      {        id: "search",        type: "text",        label: "Search by name",        placeholder: "Type to filter…",        predicate: (item, value) => {          if (typeof value !== "string" || value.length === 0) return true;          return item.label.toLowerCase().includes(value.toLowerCase());        },      },    ],    [],  );   const handleFilteredChange = useCallback(    (filtered: ReadonlyArray<GraphNodeFixture>) => {      setEmitCount((n) => n + 1);      setLastSize(filtered.length);    },    [],  );   const filtered = useMemo(() => {    return NODE_FIXTURES.filter((n) =>      categories.every((c) => c.predicate(n, values[c.id])),    );  }, [categories, values]);   return (    <div className="flex flex-col gap-4">      <PanelLayout        panel={          <FilterPanel<GraphNodeFixture>            items={NODE_FIXTURES}            categories={categories}            values={values}            onChange={setValues}            onFilteredChange={handleFilteredChange}            ariaLabel="Filter panel with onFilteredChange consumer"          />        }        list={<FilteredList items={filtered} />}      />      <div className="rounded-md border border-border bg-muted/40 p-3 text-xs text-muted-foreground">        <code>onFilteredChange</code> fired <code>{emitCount}</code> times;        last batch size <code>{lastSize ?? "—"}</code>.{" "}        Use this hook for analytics (track which filter combinations users        commit to), URL-state sync (push the filtered IDs into a query        param), or downstream side effects (kick off a server fetch keyed on        the filtered IDs).      </div>    </div>  );} export default function FilterPanelDemo() {  return (    <Tabs defaultValue="basic">      <SwipeTabsList>        <TabsTrigger value="basic">Basic facets</TabsTrigger>        <TabsTrigger value="mode">Mode toggle</TabsTrigger>        <TabsTrigger value="solo">Solo buttons</TabsTrigger>        <TabsTrigger value="custom">Custom range</TabsTrigger>        <TabsTrigger value="rich">All combined</TabsTrigger>        <TabsTrigger value="callback">onFilteredChange</TabsTrigger>      </SwipeTabsList>      <TabsContent value="basic" className="mt-4">        <BasicDemo />      </TabsContent>      <TabsContent value="mode" className="mt-4">        <ModeToggleDemo />      </TabsContent>      <TabsContent value="solo" className="mt-4">        <SoloButtonsDemo />      </TabsContent>      <TabsContent value="custom" className="mt-4">        <CustomRangeDemo />      </TabsContent>      <TabsContent value="rich" className="mt-4">        <RichDemo />      </TabsContent>      <TabsContent value="callback" className="mt-4">        <OnFilteredChangeDemo />      </TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for FilterPanel when you have a list of typed items and want a stacked filter panel — checkbox lists, toggles, debounced text, custom range pickers — composing AND-across categories. Generic over the item type; the host owns items, predicates, and what to do with the filtered output. Sync-only predicates in v0.1; async slot is a v0.2 additive prop.

Basic example

import {
  FilterPanel,
  type FilterCategory,
} from "@/components/filter-panel";

interface Project {
  id: string;
  name: string;
  status: "todo" | "in-progress" | "done";
  tags: string[];
}

const CATEGORIES: ReadonlyArray<FilterCategory<Project>> = [
  {
    id: "status",
    type: "checkbox-list",
    label: "Status",
    options: [
      { value: "todo", label: "To do" },
      { value: "in-progress", label: "In progress" },
      { value: "done", label: "Done" },
    ],
    predicate: (item, value) => {
      const sel = (value as string[]) ?? [];
      return sel.length === 0 || sel.includes(item.status);
    },
  },
  {
    id: "search",
    type: "text",
    label: "Search",
    placeholder: "Filter by name…",
    predicate: (item, value) =>
      typeof value !== "string" || value.length === 0 ||
      item.name.toLowerCase().includes(value.toLowerCase()),
  },
];

export function ProjectFilters({ projects }: { projects: Project[] }) {
  const [values, setValues] = useState<Record<string, unknown>>({});
  return (
    <FilterPanel<Project>
      items={projects}
      categories={CATEGORIES}
      values={values}
      onChange={setValues}
      onFilteredChange={setFilteredProjects}
    />
  );
}

Filter types

  • checkbox-list — multi-select. Optional modeToggle for Union / Intersection. Optional showSoloButtons for per-row solo affordance.
  • toggle — boolean switch. isEmpty required (host intent governs — typically (v) => v !== true).
  • text — debounced input. debounceMs defaults to 250. ESC clears the field.
  • custom — escape hatch. render(props) receives { value, onChange, items, fieldId }; isEmpty required.

Composition semantics

AND-across categories: every active category's predicate must return true. Within a category, the host decides OR vs AND inside predicate. The mode-toggle affordance for checkbox-list is a hint stored at values["${id}__mode"] (reserved suffix); your predicate reads it and switches.

Empty detection

A category is "empty" when its isEmpty(value) returns true; empty categories are skipped during filtering. Defaults: checkbox-list = empty array; text = empty string. Required for toggle and custom because only the host knows the value shape.

Imperative handle

const ref = useRef<FilterPanelHandle>(null);
// ...
ref.current?.clearAll();
ref.current?.clear("status");
ref.current?.isEmpty();   // true iff every category is empty

Categories reference stability

Inline categories={[...]} rebuilds category objects on every parent render and re-runs the filter pipeline. In-repo, the React Compiler memoizes inline literals at the call site. For NPM consumers without the Compiler, hoist to module scope or wrap with useMemo.

const CATEGORIES: FilterCategory<Item>[] = [/* ... */];
// or
const categories = useMemo(() => buildCategories(...), [deps]);

onFilteredChange semantics

Fires when the filtered array's reference changes — "may have changed", not "definitely differs". Same items + same values + same categories → no fire. Cost-conscious hosts dedupe via shallow-equal-by-id on their side.

Reserved id suffixes

__mode is reserved for internal mode storage on checkbox-list categories with modeToggle: true. Schema validation (dev-only) flags category ids ending in reserved suffixes, duplicate ids, and checkbox-list categories with empty options.

What ships in v0.2+

  • Built-in range / date-range filter types.
  • Per-category collapsibles (collapsible + defaultExpanded).
  • Horizontal layout via direction prop.
  • Async predicate support.
  • Deep-equal change detection for onFilteredChange.

Features

  • Four built-in filter types — checkbox-list, toggle, text, custom
  • AND-across-categories composition; host-defined within-category semantics
  • Mode toggle (Union / Intersection) on checkbox-list via a plain-button segmented control
  • Per-option solo button with tooltip on checkbox-list
  • Debounced text input (default 250ms) with flush-on-blur, ESC-clears
  • Custom render slot with error boundary and label-association via fieldId
  • Per-category clear button + global clear-all in footer
  • Dev-only schema validation — reserved suffix, duplicate id, empty options
  • Categories-reference-instability dev warning (>5 successive unstable renders)
  • Imperative handle — clearAll / clear / isEmpty

Tags

filter-panelfilterfacetsgraph-system

Dependencies

shadcn primitives: button, checkbox, input, switch, tooltip
npm peer deps: lucide-react@^1.11.0