Skip to content
Mendy UI

Examples

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

Text with Apply

Applied: design, frontend

The parent owns the applied tags and editor open state. Apply parses the draft, stores it, and closes the editor. 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 "@/components/ui/filters";
import { FilterTextEditor } from "@/components/ui/filter-text-editor";
import { parseFilterValues } from "@/components/ui/filter-utils";
import { Button } from "@/components/ui/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

Use the Status filter for a single choice. The Assignee filter supports search and several selections. Each selection immediately changes the matching rows.

Issues matching the selected filters
IssueStatusPriorityAssignee
UI-042Restore focus to the trigger after EscapeIn progresshighMendy
UI-041Show an empty state when no rows matchTodomediumAlex
UI-040Add keyboard navigation to the option listIn progresshighSam
UI-039Write the installation guideDonemediumMendy
UI-038Fix chip hover contrast in dark modeTodolowJordan
UI-037Wrap filter chips on narrow screensIn progresshighAlex
UI-036Label edit and remove buttons for screen readersDonehighSam
UI-035Hold text drafts locally until ApplyTodomediumMendy
8 of 8 issues
View source
"use client";

import { useRef, useState } from "react";
import { Circle, CircleCheck, Clock3, ListFilter, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AppliedFilter } from "@/components/ui/filters";
import { FilterTextEditor } from "@/components/ui/filter-text-editor";
import {
  FilterSelectEditor,
  FilterMultiSelectEditor,
} from "@/components/ui/filter-select-editor";

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: "Hold text drafts locally until Apply",
    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 }));
type FilterKey = "status" | "priority" | "assignee" | "title";
const filterNames: Record<FilterKey, string> = {
  status: "Status",
  priority: "Priority",
  assignee: "Assignee",
  title: "Title",
};

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" />;
}

export function FiltersDemo() {
  const addFilterRef = useRef<HTMLButtonElement>(null);
  const searchRef = useRef<HTMLInputElement>(null);
  const [query, setQuery] = useState("");
  const [active, setActive] = useState<FilterKey[]>(["status", "assignee"]);
  const [status, setStatus] = useState("");
  const [priority, setPriority] = useState("");
  const [assignees, setAssignees] = useState<string[]>([]);
  const [title, setTitle] = useState("");
  const [titleOpen, setTitleOpen] = useState(false);
  const visible = issues.filter(
    (issue) =>
      `${issue.id} ${issue.title}`.toLowerCase().includes(query.trim().toLowerCase()) &&
      (!active.includes("status") || !status || issue.status === status) &&
      (!active.includes("priority") || !priority || issue.priority === priority) &&
      (!active.includes("assignee") || !assignees.length || assignees.includes(issue.assignee)) &&
      (!active.includes("title") || issue.title.toLowerCase().includes(title.toLowerCase())),
  );
  function remove(key: FilterKey) {
    setActive((current) => current.filter((item) => item !== key));
    if (key === "status") setStatus("");
    if (key === "priority") setPriority("");
    if (key === "assignee") setAssignees([]);
    if (key === "title") {
      setTitle("");
      setTitleOpen(false);
    }
    requestAnimationFrame(() => addFilterRef.current?.focus());
  }
  const remaining = (Object.keys(filterNames) as FilterKey[]).filter(
    (key) => !active.includes(key),
  );

  return (
    <section className="rounded-md border" aria-label="Interactive issue filters">
      <div className="flex flex-wrap items-center gap-2 border-b p-3">
        <DropdownMenu>
          <div className="relative w-full shrink-0 sm:w-[350px]">
            <Search
              className="pointer-events-none absolute left-3 top-1/2 size-[17px] -translate-y-1/2"
              aria-hidden="true"
            />
            <Input
              ref={searchRef}
              type="search"
              aria-label="Search issues"
              placeholder="Search or filter"
              className={`w-full rounded-none pl-9 text-sm [&::-webkit-search-cancel-button]:appearance-none ${query ? "pr-16" : "pr-9"}`}
              value={query}
              onChange={(event) => setQuery(event.target.value)}
              autoComplete="off"
              autoCapitalize="none"
              autoCorrect="off"
              spellCheck={false}
            />
            {query && (
              <button
                type="button"
                aria-label="Clear search"
                className="absolute right-8 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-sm opacity-50 transition-opacity duration-300 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
                onClick={() => {
                  setQuery("");
                  searchRef.current?.focus();
                }}
              >
                <X className="size-[15px]" aria-hidden="true" />
              </button>
            )}
            <DropdownMenuTrigger asChild>
              <button
                ref={addFilterRef}
                type="button"
                aria-label="Open filters"
                className={`absolute right-3 top-1/2 -translate-y-1/2 rounded-sm transition-opacity duration-300 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:opacity-100 ${active.length ? "opacity-100" : "opacity-50"}`}
              >
                <ListFilter className="size-[17px]" aria-hidden="true" />
              </button>
            </DropdownMenuTrigger>
          </div>
          <DropdownMenuContent align="end">
            {remaining.length ? (
              remaining.map((key) => (
                <DropdownMenuItem
                  key={key}
                  onSelect={() => setActive((current) => [...current, key])}
                >
                  {filterNames[key]}
                </DropdownMenuItem>
              ))
            ) : (
              <DropdownMenuItem disabled>All filters added</DropdownMenuItem>
            )}
          </DropdownMenuContent>
        </DropdownMenu>
        {active.includes("status") && (
          <AppliedFilter
            label="Status"
            onRemove={() => remove("status")}
            editor={
              <FilterSelectEditor
                label="Status"
                value={status}
                onValueChange={setStatus}
                options={[{ value: "", label: "Any status" }, ...statuses]}
              />
            }
          >
            <span>Status:</span>
            <span>{statuses.find((item) => item.value === status)?.label ?? "Any"}</span>
          </AppliedFilter>
        )}
        {active.includes("priority") && (
          <AppliedFilter
            label="Priority"
            onRemove={() => remove("priority")}
            editor={
              <FilterSelectEditor
                label="Priority"
                value={priority}
                onValueChange={setPriority}
                options={[{ value: "", label: "Any priority" }, ...priorities]}
              />
            }
          >
            <span>Priority:</span>
            <span>{priorities.find((item) => item.value === priority)?.label ?? "Any"}</span>
          </AppliedFilter>
        )}
        {active.includes("assignee") && (
          <AppliedFilter
            label="Assignee"
            onRemove={() => remove("assignee")}
            editor={
              <FilterMultiSelectEditor
                label="Search assignees"
                searchable
                values={assignees}
                onValuesChange={setAssignees}
                options={people}
              />
            }
          >
            <span>Assignee:</span>
            <span className="max-w-40 truncate">
              {assignees.length ? assignees.join(", ") : "Anyone"}
            </span>
          </AppliedFilter>
        )}
        {active.includes("title") && (
          <AppliedFilter
            label="Title"
            open={titleOpen}
            onOpenChange={setTitleOpen}
            onRemove={() => remove("title")}
            editor={
              <FilterTextEditor
                label="Title contains"
                defaultValue={title}
                onApply={(value) => {
                  setTitle(value.trim());
                  setTitleOpen(false);
                }}
              />
            }
          >
            <span>Title:</span>
            <span className="max-w-32 truncate">{title || "Any"}</span>
          </AppliedFilter>
        )}
        {(active.length > 0 || query) && (
          <Button
            variant="ghost"
            size="sm"
            className="h-9 rounded-none px-2 font-normal text-muted-foreground underline hover:bg-transparent"
            onClick={() => {
              setQuery("");
              setActive([]);
              setStatus("");
              setPriority("");
              setAssignees([]);
              setTitle("");
              setTitleOpen(false);
              requestAnimationFrame(() => addFilterRef.current?.focus());
            }}
          >
            Clear all
          </Button>
        )}
      </div>
      <div
        className="overflow-x-auto"
        tabIndex={0}
        role="region"
        aria-label="Scrollable issues table"
      >
        <table className="w-full min-w-[560px] text-left text-sm">
          <caption className="sr-only">Issues matching the selected filters</caption>
          <thead className="text-xs text-muted-foreground">
            <tr className="border-b">
              <th scope="col" className="px-3 py-2 font-normal">
                Issue
              </th>
              <th scope="col" className="px-3 py-2 font-normal">
                Status
              </th>
              <th scope="col" className="px-3 py-2 font-normal">
                Priority
              </th>
              <th scope="col" className="px-3 py-2 font-normal">
                Assignee
              </th>
            </tr>
          </thead>
          <tbody>
            {visible.map((issue) => (
              <tr key={issue.id} className="border-b last:border-b-0 hover:bg-muted">
                <td className="px-3 py-2">
                  <span className="mr-3 font-mono text-xs text-muted-foreground">{issue.id}</span>
                  {issue.title}
                </td>
                <td className="px-3 py-2">
                  <span className="flex items-center gap-1.5 whitespace-nowrap">
                    <StatusIcon status={issue.status} />
                    {statuses.find((item) => item.value === issue.status)?.label}
                  </span>
                </td>
                <td className="px-3 py-2 capitalize text-muted-foreground">{issue.priority}</td>
                <td className="px-3 py-2">{issue.assignee}</td>
              </tr>
            ))}
          </tbody>
        </table>
        {visible.length === 0 && (
          <div className="space-y-1 p-8 text-center">
            <p className="text-sm">No matching issues</p>
            <p className="text-xs text-muted-foreground">Change or remove a filter.</p>
          </div>
        )}
      </div>
      <div role="status" className="border-t px-3 py-2 text-xs text-muted-foreground">
        {visible.length} of {issues.length} issues
      </div>
    </section>
  );
}

Custom, required filter

Required filter. Editable, without a remove button.

This editor uses a native 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 and Tab available to the menu's dismissal and focus handling.

View source
"use client";
import { useId, useState } from "react";
import { AppliedFilter } from "@/components/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">
              <input
                id={id}
                type="checkbox"
                checked={archived}
                onChange={(event) => setArchived(event.target.checked)}
                onKeyDown={(event) => {
                  if (event.key !== "Escape" && event.key !== "Tab") event.stopPropagation();
                }}
                className="size-4 accent-emerald-700"
              />
              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 "@/components/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.