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.
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.