Skip to content
Mendy UI

Filters

Examples

Text drafts, selections, and a custom editor you can adapt.

On this page

Use the typed filter demo for a new project. These smaller examples show standalone primitives when you need to own state or editor composition yourself.

Text with Enter

Applied: design, frontend

The parent owns the applied tags and editor open state. Enter parses the draft, stores it, and closes the editor. Shift+Enter inserts a line. Escape or outside dismissal unmounts the draft without committing it. Validation keeps an empty list from being applied.

View source
"use client";
import { useState } from "react";
import { AppliedFilter } from "@mendylanda/ui/filters";
import { FilterTextEditor } from "@mendylanda/ui/filters";
import { parseFilterValues } from "@mendylanda/ui/filters";
import { Button } from "@mendylanda/ui/primitives/button";

export function TextFilterDemo() {
  const [values, setValues] = useState(["design", "frontend"]);
  const [open, setOpen] = useState(false);
  return (
    <div className="flex flex-wrap items-center gap-3 rounded-lg border p-6">
      {values.length ? (
        <AppliedFilter
          label="Tags"
          open={open}
          onOpenChange={setOpen}
          onRemove={() => setValues([])}
          editor={
            <FilterTextEditor
              label="Tags, separated by commas"
              defaultValue={values.join(", ")}
              validate={(draft) =>
                parseFilterValues(draft).length === 0 ? "Enter at least one tag." : undefined
              }
              onApply={(draft) => {
                setValues(parseFilterValues(draft));
                setOpen(false);
              }}
            />
          }
        >
          Tags <span className="text-foreground">{values.join(", ")}</span>
        </AppliedFilter>
      ) : (
        <Button variant="outline" onClick={() => setValues(["design", "frontend"])}>
          Add tags
        </Button>
      )}
      <p role="status" className="text-xs text-muted-foreground">
        Applied: {values.length ? values.join(", ") : "none"}
      </p>
    </div>
  );
}

Single and multiple selection

Open the filter menu and choose Status or Assignee to see the options. Status accepts one choice. Assignee supports search and several selections. Choosing a value applies it and creates a chip. Click that chip to edit it later.

UI-042
Restore focus to the trigger after Escape
In progress
UI-041
Show an empty state when no rows match
Todo
UI-040
Add keyboard navigation to the option list
In progress
UI-039
Write the installation guide
Done
UI-038
Fix chip hover contrast in dark mode
Todo
UI-037
Wrap filter chips on narrow screens
In progress
UI-036
Label edit and remove buttons for screen readers
Done
UI-035
Save text filters with Enter
Todo
8 of 8 issuesDrag to select cells. Copy with ⌘C or Ctrl+C.
View source
"use client";

import { Circle, CircleCheck, Clock3, Flag, Users } from "lucide-react";
import { DataTable, defineColumns, useDataTable } from "@mendylanda/ui/table";
import { NuqsAdapter } from "nuqs/adapters/react";
import { defineFilters, filter } from "@mendylanda/ui/filters";
import { useUrlFilters } from "@mendylanda/ui/filters/nuqs";

const issues = [
  {
    id: "UI-042",
    title: "Restore focus to the trigger after Escape",
    status: "in-progress",
    priority: "high",
    assignee: "Mendy",
  },
  {
    id: "UI-041",
    title: "Show an empty state when no rows match",
    status: "todo",
    priority: "medium",
    assignee: "Alex",
  },
  {
    id: "UI-040",
    title: "Add keyboard navigation to the option list",
    status: "in-progress",
    priority: "high",
    assignee: "Sam",
  },
  {
    id: "UI-039",
    title: "Write the installation guide",
    status: "done",
    priority: "medium",
    assignee: "Mendy",
  },
  {
    id: "UI-038",
    title: "Fix chip hover contrast in dark mode",
    status: "todo",
    priority: "low",
    assignee: "Jordan",
  },
  {
    id: "UI-037",
    title: "Wrap filter chips on narrow screens",
    status: "in-progress",
    priority: "high",
    assignee: "Alex",
  },
  {
    id: "UI-036",
    title: "Label edit and remove buttons for screen readers",
    status: "done",
    priority: "high",
    assignee: "Sam",
  },
  {
    id: "UI-035",
    title: "Save text filters with Enter",
    status: "todo",
    priority: "medium",
    assignee: "Mendy",
  },
];
const statuses = [
  { value: "todo", label: "Todo" },
  { value: "in-progress", label: "In progress" },
  { value: "done", label: "Done" },
];
const priorities = [
  { value: "high", label: "High" },
  { value: "medium", label: "Medium" },
  { value: "low", label: "Low" },
];
const people = ["Mendy", "Alex", "Sam", "Jordan"].map((name) => ({ value: name, label: name }));
function StatusIcon({ status }: { status: string }) {
  if (status === "done")
    return (
      <CircleCheck className="size-3.5 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />
    );
  if (status === "in-progress")
    return <Clock3 className="size-3.5 text-amber-600 dark:text-amber-400" aria-hidden="true" />;
  return <Circle className="size-3.5 text-muted-foreground" aria-hidden="true" />;
}

const definitions = defineFilters({
  issueId: filter.tokens({
    label: "Issue ID",
    menu: false,
    recognize: (token) => (/^UI-\d+$/i.test(token) ? [token.toUpperCase()] : undefined),
  }),
  status: filter.select({
    label: "Status",
    searchable: false,
    options: statuses,
    suggestion: { value: "todo" },
  }),
  priority: filter.select({
    label: "Priority",
    searchable: false,
    icon: <Flag />,
    options: priorities,
  }),
  assignee: filter.multiSelect({
    label: "Assignee",
    icon: <Users />,
    options: people,
    searchLabel: "Search assignees",
    editorLabel: "Choose assignees",
    suggestion: { value: ["Mendy"] },
  }),
  title: filter.text({
    label: "Title",
    searchLabel: "Title contains",
    validate: (value) => (value ? undefined : "Enter a title to search for."),
  }),
});

const columns = defineColumns<(typeof issues)[number]>((column) => [
  column.accessor("id", { label: "ID", size: 110, grow: false, pin: "start" }),
  column.accessor("title", { label: "Issue", size: 380, grow: 2 }),
  column.accessor("status", {
    label: "Status",
    size: 150,
    render: ({ value }) => (
      <span className="flex items-center gap-2">
        <StatusIcon status={value} />
        {statuses.find((item) => item.value === value)?.label}
      </span>
    ),
    copy: (value) => statuses.find((item) => item.value === value)?.label ?? value,
  }),
  column.accessor("priority", {
    label: "Priority",
    size: 110,
    render: ({ value }) => <span className="capitalize">{value}</span>,
  }),
  column.accessor("assignee", { label: "Assignee", size: 130 }),
]);

export function FiltersDemo() {
  return (
    <NuqsAdapter>
      <IssueFilters />
    </NuqsAdapter>
  );
}

function IssueFilters() {
  const filters = useUrlFilters(definitions, { scope: "issues" });
  const { status, priority, assignee, title, issueId } = filters.values;
  const visible = issues.filter(
    (issue) =>
      `${issue.id} ${issue.title}`.toLowerCase().includes(filters.search.trim().toLowerCase()) &&
      (!issueId?.length || issueId.includes(issue.id)) &&
      (!status || issue.status === status) &&
      (!priority || issue.priority === priority) &&
      (!assignee?.length || assignee.includes(issue.assignee)) &&
      (!title || issue.title.toLowerCase().includes(title.toLowerCase())),
  );
  const table = useDataTable({
    rows: visible,
    columns,
    getRowId: (row) => row.id,
    enableRowSelection: true,
  });
  return (
    <section aria-label="Interactive issue filters" className="min-w-0">
      <DataTable
        table={table}
        filters={filters}
        label="Issues"
        height={397}
        filterBar={{
          searchLabel: "Search issues",
          searchPlaceholder: "Search or paste an issue ID",
        }}
        footer={
          <div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
            <span role="status">
              {visible.length} of {issues.length} issues
            </span>
            <span>Drag to select cells. Copy with ⌘C or Ctrl+C.</span>
          </div>
        }
      />
    </section>
  );
}

Custom, required filter

Required filter. Editable, without a remove button.

This editor uses the package's shadcn checkbox. It updates immediately, and it has no remove action. Passing editor enables editing independently of onRemove.

When placing form fields in a dropdown, stop typing and cursor keys from reaching menu typeahead. Leave Escape available to dismiss the menu. Keep Tab native inside form editors so it moves between controls.

View source
"use client";
import { useId, useState } from "react";
import { Checkbox } from "@mendylanda/ui/primitives/checkbox";
import { Label } from "@mendylanda/ui/primitives/label";
import { AppliedFilter } from "@mendylanda/ui/filters";

export function CustomFilterDemo() {
  const [archived, setArchived] = useState(false);
  const id = useId();
  return (
    <div className="flex flex-wrap items-center gap-3 rounded-lg border p-6">
      <AppliedFilter
        label="Visibility"
        editor={
          <div className="w-64 p-4">
            <Label htmlFor={id} className="flex cursor-pointer items-center gap-3 text-sm">
              <Checkbox
                className="rounded-sm"
                id={id}
                checked={archived}
                onCheckedChange={(checked) => setArchived(checked === true)}
                onKeyDown={(event) => {
                  if (event.key !== "Escape" && event.key !== "Tab") event.stopPropagation();
                }}
              />
              Include archived items
            </Label>
          </div>
        }
      >
        Visibility <span className="text-foreground">{archived ? "All items" : "Active only"}</span>
      </AppliedFilter>
      <p className="text-xs text-muted-foreground">
        Required filter. Editable, without a remove button.
      </p>
    </div>
  );
}

Low-level composition

import {
  FilterChip,
  FilterEditor,
  FilterEditorContent,
  FilterEditorTrigger,
  FilterRemove,
} from "@mendylanda/ui/filters";
 
<FilterEditor open={open} onOpenChange={setOpen}>
  <FilterChip>
    <FilterEditorTrigger aria-label="Edit status filter">Status: {status}</FilterEditorTrigger>
    <FilterRemove aria-label="Remove status filter" onClick={clearStatus} />
  </FilterChip>
  <FilterEditorContent aria-label="Status filter">{editor}</FilterEditorContent>
</FilterEditor>;

Here open, setOpen, status, clearStatus, and editor come from your application. Use the complete examples above for a runnable starting point.