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.
| Issue | Status | Priority | Assignee |
|---|---|---|---|
| UI-042Restore focus to the trigger after Escape | In progress | high | Mendy |
| UI-041Show an empty state when no rows match | Todo | medium | Alex |
| UI-040Add keyboard navigation to the option list | In progress | high | Sam |
| UI-039Write the installation guide | Done | medium | Mendy |
| UI-038Fix chip hover contrast in dark mode | Todo | low | Jordan |
| UI-037Wrap filter chips on narrow screens | In progress | high | Alex |
| UI-036Label edit and remove buttons for screen readers | Done | high | Sam |
| UI-035Hold text drafts locally until Apply | Todo | medium | Mendy |
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.