Filter menu
A pill that opens checkbox items with counts and shows how many are checked.
npx shadcn@latest add https://ui.goodtabs.co/r/filter-menu.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/filter-menu
A pricing filter with two of four checked, so the trigger reads Pricing · 2.
None selected
Nothing checked: the trigger shows only the label and there is no Clear item.
Many selected
Seven categories with five checked and a custom clear label.
Usage
import { FilterMenu } from "@/components/ui/filter-menu"
const [pricing, setPricing] = useState<string[]>([])
<FilterMenu
label="Pricing"
options={[{ value: "free", label: "Free", count: 128 }, { value: "paid", label: "Paid", count: 31 }]}
value={pricing}
onValueChange={setPricing}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | { value: string; label: string; count?: number }[] | The options, in menu order. A count shows in mono on the right. | |
| value | string[] | The checked option values. The component is controlled. | |
| onValueChange | (value: string[]) => void | Called with the new list of checked values, or an empty list on Clear. | |
| label | string | "Filter" | Trigger text. |
| clearLabel | string | "Clear" | Text of the item at the bottom that unchecks everything. |
| align | "start" | "end" | "start" | Which side of the trigger the menu aligns to. |
| size | Button size | "sm" | Trigger height: sm is 28px, default is 32px. Any other Button prop applies too. |
Source
filter-menu/filter-menu.tsx
"use client";
import * as React from "react";
import { ListFilterIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export type FilterOption = {
/** The key reported in onValueChange. */
value: string;
/** Menu item text. */
label: string;
/** Optional count shown in mono on the right. */
count?: number;
};
type FilterMenuProps = Omit<React.ComponentProps<typeof Button>, "value" | "onChange" | "children"> & {
/** The options, in menu order. */
options: FilterOption[];
/** The checked option values. */
value: string[];
/** Called with the new list of checked values. */
onValueChange: (value: string[]) => void;
/** Trigger text. */
label?: string;
/** Text of the item at the bottom that unchecks everything. */
clearLabel?: string;
/** Which side of the trigger the menu aligns to. */
align?: "start" | "end";
};
/**
* A filter control: an outline pill that opens a menu of checkbox items with counts.
* The trigger shows how many are checked and a Clear item appears when any is.
*/
export function FilterMenu({
options,
value,
onValueChange,
label = "Filter",
clearLabel = "Clear",
align = "start",
variant = "outline",
size = "sm",
className,
...props
}: FilterMenuProps) {
const active = value.length;
const toggle = (option: string, checked: boolean) => {
onValueChange(checked ? [...value, option] : value.filter((v) => v !== option));
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
data-active={active > 0 || undefined}
className={cn("rounded-full", className)}
{...props}
>
<ListFilterIcon data-icon="inline-start" aria-hidden="true" />
<span>{label}</span>
{active > 0 && (
<>
<span aria-hidden="true" className="text-muted-foreground">
·
</span>
<span className="font-mono text-xs">
{active}
<span className="sr-only"> selected</span>
</span>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="min-w-48 rounded-md">
{options.map((option) => {
const checked = value.includes(option.value);
return (
<DropdownMenuCheckboxItem
key={option.value}
checked={checked}
onCheckedChange={(next) => toggle(option.value, next === true)}
onSelect={(event) => event.preventDefault()}
className="h-8 rounded-sm focus:bg-muted focus:text-foreground"
>
<span className="truncate">{option.label}</span>
{option.count !== undefined && (
<span className="ml-auto font-mono text-xs text-muted-foreground">{option.count}</span>
)}
</DropdownMenuCheckboxItem>
);
})}
{active > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => onValueChange([])}
className="h-8 rounded-sm text-muted-foreground focus:bg-muted focus:text-foreground"
>
{clearLabel}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
For AI agents
Hand your agent this page, the markdown at /components/filter-menu/prompt.md, or the Copy AI prompt button above.
When to use
- To narrow a list or grid by one facet with a handful of values
- In a toolbar next to search and sort, one menu per facet
- When counts help people see what each choice would leave
When not to use
- For a single on or off facet, use a filter chip or a switch
- For dozens of values that need search, use a combobox
- For choosing exactly one value, use a select or a sort menu
Agent notes
- Controlled only: keep the checked values in state and pass value and onValueChange, so the parent must be a client component
- The menu stays open while people check items; Clear closes it and calls onValueChange with an empty list
- The trigger shows the count after a middle dot and sets data-active when anything is checked
- It extends shadcn's Button, so variant and size apply; the default is an outline sm pill