Filter chip
Toggle pills with a label and a count, in a row that scrolls instead of wrapping.
npx shadcn@latest add https://ui.goodtabs.co/r/filter-chip.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/filter-chip
Five categories with counts, controlled from the parent.
Multiple select
Each chip toggles on its own; two start selected.
Without counts
Labels only, as a sort switch.
Usage
import { FilterChip, FilterChipGroup } from "@/components/ui/filter-chip"
<FilterChipGroup aria-label="Category" value={category} onValueChange={setCategory}>
<FilterChip value="all" count={212}>All</FilterChip>
<FilterChip value="components" count={48}>Components</FilterChip>
</FilterChipGroup>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | On FilterChip: identifies it inside a FilterChipGroup. | |
| count | number | string | On FilterChip: mono count after the label, 70% opacity when selected. | |
| selected, defaultSelected, onSelectedChange | boolean, boolean, (selected: boolean) => void | On a FilterChip used alone: controlled or uncontrolled state. | |
| type | "single" | "multiple" | "single" | On FilterChipGroup: one chip at a time, or any number. |
| value, defaultValue, onValueChange | string or string[] by type | On FilterChipGroup: controlled or uncontrolled selection. |
Source
filter-chip/filter-chip.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type GroupContextValue = {
selected: string[];
toggle: (value: string) => void;
};
const GroupContext = React.createContext<GroupContextValue | null>(null);
type FilterChipProps = Omit<React.ComponentProps<"button">, "value"> & {
/** Identifies the chip inside a FilterChipGroup. */
value?: string;
/** Mono count after the label. */
count?: number | string;
/** Controlled selected state when used on its own. */
selected?: boolean;
/** Selected on first render when used on its own and uncontrolled. */
defaultSelected?: boolean;
/** Called with the next selected state when used on its own. */
onSelectedChange?: (selected: boolean) => void;
};
/**
* A toggle pill with a label and an optional count.
* Inside a FilterChipGroup the group owns the state; alone it manages its own.
*/
function FilterChip({
value,
count,
selected,
defaultSelected = false,
onSelectedChange,
className,
children,
onClick,
...props
}: FilterChipProps) {
const group = React.useContext(GroupContext);
const [internal, setInternal] = React.useState(defaultSelected);
const isSelected = group && value !== undefined ? group.selected.includes(value) : (selected ?? internal);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (group && value !== undefined) {
group.toggle(value);
return;
}
const next = !isSelected;
if (selected === undefined) setInternal(next);
onSelectedChange?.(next);
};
return (
<button
type="button"
aria-pressed={isSelected}
data-state={isSelected ? "on" : "off"}
onClick={handleClick}
className={cn(
"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full px-3 text-sm font-medium whitespace-nowrap transition-colors duration-100 ease-out select-none disabled:pointer-events-none disabled:opacity-50",
isSelected
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
className,
)}
{...props}
>
{children}
{count !== undefined && (
<span className={cn("font-mono text-xs font-normal tabular-nums", isSelected && "opacity-70")}>{count}</span>
)}
</button>
);
}
type GroupBaseProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange">;
type FilterChipGroupProps =
| (GroupBaseProps & {
/** One chip selected at a time. Clicking the selected chip keeps it selected. */
type?: "single";
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
})
| (GroupBaseProps & {
/** Any number of chips selected, each one toggling on its own. */
type: "multiple";
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
});
const toArray = (value: string | string[] | undefined) =>
value === undefined ? [] : Array.isArray(value) ? value : [value];
/**
* A row of chips that scrolls sideways instead of wrapping.
* Controlled with value and onValueChange, or uncontrolled with defaultValue.
*/
function FilterChipGroup(allProps: FilterChipGroupProps) {
const { type = "single", value, defaultValue, onValueChange, className, children, ...props } = allProps;
const [internal, setInternal] = React.useState<string[]>(() => toArray(defaultValue));
const selected = value !== undefined ? toArray(value) : internal;
const notify = onValueChange as ((next: string | string[]) => void) | undefined;
const toggle = React.useCallback(
(chip: string) => {
const isOn = selected.includes(chip);
let next: string[];
if (type === "single") {
if (isOn) return;
next = [chip];
} else {
next = isOn ? selected.filter((v) => v !== chip) : [...selected, chip];
}
if (value === undefined) setInternal(next);
notify?.(type === "single" ? next[0] : next);
},
[selected, type, value, notify],
);
const context = React.useMemo(() => ({ selected, toggle }), [selected, toggle]);
return (
<GroupContext.Provider value={context}>
<div
role="group"
data-slot="filter-chip-group"
className={cn("-m-1 flex max-w-full items-center gap-1 overflow-x-auto p-1", className)}
{...props}
>
{children}
</div>
</GroupContext.Provider>
);
}
export { FilterChip, FilterChipGroup };
For AI agents
Hand your agent this page, the markdown at /components/filter-chip/prompt.md, or the Copy AI prompt button above.
When to use
- Above a grid or list to narrow it by category, pricing or tag
- When the counts help people choose and the set fits in one row
- For one or many selections that apply at once, with no submit step
When not to use
- For switching views of the same thing, use segmented-tabs
- For more than about ten options, use a select or a combobox
- For navigation between pages, use links
Agent notes
- Give FilterChipGroup an aria-label; it renders role group and the chips carry aria-pressed
- Single select keeps one chip selected: clicking the selected chip does nothing, so include an All chip for the empty filter
- The row scrolls sideways with no wrap; put it in a container with a width or it grows with its parent
- A demo or parent that passes onValueChange must be a client component