Filter chip
A 28px chip for an active filter, with an indigo tint and a remove button.
- INV-20418Northwind FreightPriya Raman
- INV-20411HalcyonAisha Bello
- INV-20392HalcyonPriya Raman
import { Button } from "@oration/canon/components/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { BuildingIcon, CircleDotIcon, ListFilterIcon, UserRoundIcon } from "lucide-react";import * as React from "react";export function Hero() { type Field = "status" | "supplier" | "owner"; const fields = { status: { label: "Status", icon: CircleDotIcon, values: ["Overdue"] }, supplier: { label: "Supplier", icon: BuildingIcon, values: ["Northwind Freight", "Halcyon"], }, owner: { label: "Owner", icon: UserRoundIcon, values: ["Priya Raman"] }, } as const; const invoices = [ { id: "INV-20418", supplier: "Northwind Freight", status: "Overdue", owner: "Priya Raman", }, { id: "INV-20411", supplier: "Halcyon", status: "Overdue", owner: "Aisha Bello", }, { id: "INV-20407", supplier: "Orchard Street", status: "Open", owner: "Priya Raman", }, { id: "INV-20399", supplier: "Northwind Freight", status: "Open", owner: "Jordan Lee", }, { id: "INV-20392", supplier: "Halcyon", status: "Overdue", owner: "Priya Raman", }, ]; const [active, setActive] = React.useState<Field[]>(["status", "supplier"]); const available = (Object.keys(fields) as Field[]).filter( (field) => !active.includes(field), ); const rows = invoices.filter((invoice) => active.every((field) => { const values: readonly string[] = fields[field].values; const key = field === "status" ? invoice.status : field === "supplier" ? invoice.supplier : invoice.owner; return values.includes(key); }), ); return ( <div className="w-full max-w-2xl overflow-hidden rounded-xl bg-card text-left shadow-border"> <div className="flex flex-wrap items-center gap-1.5 border-b border-border px-3 py-2"> <DropdownMenu> <DropdownMenuTrigger render={ <Button type="button" variant="ghost" size="sm" disabled={available.length === 0} /> } > <ListFilterIcon data-icon="inline-start" aria-hidden="true" /> Filter </DropdownMenuTrigger> <DropdownMenuContent align="start" className="w-48"> <DropdownMenuGroup> <DropdownMenuLabel>Add a filter</DropdownMenuLabel> {available.map((field) => { const Icon = fields[field].icon; return ( <DropdownMenuItem key={field} onClick={() => setActive((list) => [ ...list, field, ]) } > <Icon aria-hidden="true" /> {fields[field].label} </DropdownMenuItem> ); })} </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> <FilterChipRow> {active.map((field) => { const Icon = fields[field].icon; const values = fields[field].values; return ( <FilterChip key={field} icon={ <Icon className="size-3.5 text-muted-foreground" aria-hidden="true" /> } field={fields[field].label} valuesLabel={values.join(", ")} multiple={values.length > 1} onRemove={() => setActive((list) => list.filter((f) => f !== field), ) } /> ); })} {active.length ? ( <Button type="button" variant="ghost" size="sm" className="text-muted-foreground" onClick={() => setActive([])} > Clear all </Button> ) : null} </FilterChipRow> <span className="ml-auto text-xs text-muted-foreground tabular-nums"> {rows.length} of {invoices.length} invoices </span> </div> <ul className="divide-y divide-border"> {rows.map((invoice) => ( <li key={invoice.id} className="flex h-9 items-center gap-3 px-3 text-13" > <span className="w-20 font-mono text-xs text-muted-foreground"> {invoice.id} </span> <span className="flex-1 truncate"> {invoice.supplier} </span> <span className="text-muted-foreground"> {invoice.owner} </span> </li> ))} </ul> </div> );}Usage#
Filter chip shows one active filter above a list: the field's icon, the field, the operator, the values and a remove button, in a 28px chip with a 6% indigo tint and a 22% inset indigo ring. Chips sit in a FilterChipRow after the list's filter menu, followed by Clear all, as in the tickets and records toolbars. It is a control, not a value. The common mistake is reaching for a tag to show a filter; tags show a record's values and never take the indigo tint.
When to use
- To show each active filter on a list or table so people can see why rows are missing.
- To let people remove one filter without opening the filter menu.
- In a toolbar row with a filter menu before it and Clear all or Save as view after it.
When not to use
- To show a record's stage, tier or other option value. Use Tag
- For a quick filter that is always visible and has two to five values. Use Segmented control
- For filters that combine and toggle on and off in place, such as channels. Use Toggle group
- For values people type into a field, such as email domains. Use Tag input
- For a status that needs a label beside a colored dot. Use Status label
The Quiet Indigo Rule
Chips are controls, tags are values
Anatomy#
- Chip. 28px, 8px corners, 8px left and 2px right padding, a 6% indigo fill and a 22% inset indigo ring. 13px text.
- Icon. The field's icon at 14px in Slate Meta, passed through
icon. - Field and operator. The field name and is or is any of, both Slate Meta.
- Values.
valuesLabelin medium ink, truncated at 192px. - Remove. A 24px button with a 14px X, labelled Remove {field} filter.
Examples#
One value or several
multiple switches the operator from "is" to "is any of". Pass values.length > 1 and join the labels into valuesLabel yourself.
import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { toast } from "@oration/canon/components/toast";import { CircleDotIcon, UserRoundIcon } from "lucide-react";import * as React from "react";export function SingleAndMultiple() { const [filters, setFilters] = React.useState([ { field: "Status", values: ["Overdue"], icon: CircleDotIcon }, { field: "Owner", values: ["Priya Raman", "Aisha Bello"], icon: UserRoundIcon, }, ]); return ( <FilterChipRow> {filters.map((filter) => ( <FilterChip key={filter.field} icon={ <filter.icon className="size-3.5 text-muted-foreground" aria-hidden="true" /> } field={filter.field} valuesLabel={filter.values.join(", ")} multiple={filter.values.length > 1} onRemove={() => { setFilters((list) => list.filter((f) => f.field !== filter.field), ); toast.add({ title: `${filter.field} filter removed` }); }} /> ))} {filters.length === 0 ? ( <span className="text-13 text-muted-foreground"> No filters. Showing all 212 invoices. </span> ) : null} </FilterChipRow> );}Long values
The values truncate at 192px, so the row stays on one line for most filters. The full list isn't shown anywhere else, so keep the source of truth one click away.
import { Button } from "@oration/canon/components/button";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { BuildingIcon } from "lucide-react";import * as React from "react";export function LongValues() { const [shown, setShown] = React.useState(true); const suppliers = [ "Northwind Freight", "Halcyon", "Orchard Street", "Brightline Freight", "Keystone Software", ]; return ( <FilterChipRow> {shown ? ( <FilterChip icon={ <BuildingIcon className="size-3.5 text-muted-foreground" aria-hidden="true" /> } field="Supplier" valuesLabel={suppliers.join(", ")} multiple onRemove={() => setShown(false)} /> ) : ( <Button type="button" variant="outline" size="sm" onClick={() => setShown(true)} > Restore the supplier filter </Button> )} </FilterChipRow> );}Beside its menu
A date range on payment runs. The chip shows the active range; the menu beside it changes it, because the chip body isn't a button.
Payment runs
import { Button } from "@oration/canon/components/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { CalendarIcon } from "lucide-react";import * as React from "react";export function DateFilter() { const [range, setRange] = React.useState<string | null>("Next 7 days"); return ( <div className="flex w-full max-w-md flex-col gap-2 text-left"> <p className="text-13 text-muted-foreground">Payment runs</p> <FilterChipRow> {range ? ( <FilterChip icon={ <CalendarIcon className="size-3.5 text-muted-foreground" aria-hidden="true" /> } field="Scheduled" valuesLabel={range} multiple={false} onRemove={() => setRange(null)} /> ) : null} <DropdownMenu> <DropdownMenuTrigger render={ <Button type="button" variant="ghost" size="sm" /> } > {range ? "Change range" : "Filter by date"} </DropdownMenuTrigger> <DropdownMenuContent align="start" className="w-44"> {[ "Today", "Next 7 days", "Next 30 days", "This quarter", ].map((option) => ( <DropdownMenuItem key={option} onClick={() => setRange(option)} > {option} </DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> </FilterChipRow> </div> );}States#
import { FilterChip } from "@oration/canon/components/filter-chip";import { cn } from "@oration/canon/lib/utils";import { CircleDotIcon } from "lucide-react";export function StatesRow() { const states = [ { name: "Rest", className: "" }, { name: "Remove hover", className: "[&_button]:bg-muted [&_button]:text-foreground", }, { name: "Remove focus", className: "[&_button]:ring-3 [&_button]:ring-ring/40", }, ]; return ( <div className="grid w-full gap-4 sm:grid-cols-3" inert> {states.map((state) => ( <div key={state.name} className={cn( "flex flex-col items-start gap-2", state.className, )} > <span className="text-xs text-muted-foreground"> {state.name} </span> <FilterChip icon={ <CircleDotIcon className="size-3.5 text-muted-foreground" aria-hidden="true" /> } field="Status" valuesLabel="Overdue" multiple={false} onRemove={() => undefined} /> </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | Indigo tint and ring, with Slate Meta field and operator. |
| Remove hover | The remove button fills Well Gray and the X turns ink. |
| Remove focus visible | A 3px Focus Indigo ring at 40% around the remove button. |
| Truncated | Values longer than 192px end in an ellipsis. There's no tooltip with the full list. |
| Empty | With no filters, render no chips. Say what the list shows instead, such as Showing all 212 invoices, or let the count carry it. |
Behavior#
- The chip holds no state. Keep the filters with the list, render one chip per filter and remove it in
onRemove. multipleswitches the operator text from is to is any of. Passvalues.length > 1.valuesLabelis a ready string: join the labels yourself, such aslabels.join(", ").- Only the remove button is interactive. To change a filter's values, open the filter menu beside the chips.
FilterChipRowis a wrapping flex row with a 6px gap; it accepts any<div>props.- Removing a chip unmounts its button, so move focus to the next chip's remove button or to the filter menu trigger.
Do and don't#
Content#
- The field is the column's name as it appears in the table header: Supplier, Status, Owner.
- Values use the same labels as the cells, joined with a comma and a space: Northwind Freight, Halcyon.
- Date values are plain ranges: Next 7 days, This quarter, not a pair of ISO dates.
- Follow the row with Clear all, and Save as view when views exist, both as small ghost buttons.
Accessibility#
- The chip is a
<span>; its text reads as a phrase: Supplier is any of Northwind Freight, Halcyon. - The remove button is named Remove {field} filter from
field, so each one is distinct. - After a removal, move focus somewhere useful; otherwise it falls to the page.
- Announce the new result count near the list, for example in a polite live region, so screen reader users learn what changed.
- The remove button is 24px, the minimum target size.
| Keys | Action |
|---|---|
| Tab | Moves focus to each chip's remove button in turn. |
| Enter | Removes the filter. |
| Space | Removes the filter. |
Design tokens#
| Token | Used for |
|---|---|
--primary | 6% fill and 22% inset ring |
--foreground | Values |
--muted-foreground | Icon, field, operator and X |
--muted | Remove button hover |
--ring | 3px focus ring at 40% |
--radius-md | 8px chip corners |
API reference#
FilterChip
One active filter. It takes no className or other props.
| Prop | Type | Default | Description |
|---|---|---|---|
iconRequired | ReactNode | No default | The field's icon, usually 14px in Slate Meta with aria-hidden. |
fieldRequired | string | No default | The field name. Also names the remove button. |
valuesLabelRequired | string | No default | The chosen values as one string. |
multipleRequired | boolean | No default | Shows is any of instead of is. |
onRemoveRequired | () => void | No default | Called when the remove button is pressed. |
FilterChipRow
A wrapping row with a 6px gap.
Other props spread onto <div>.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The chip body isn't a button, so there's no way to edit a filter from its chip. The tickets and records toolbars reopen the full filter menu instead.
The operator is fixed to is and is any of. There's no is not, before or after, so date and negative filters can't be shown honestly.
Truncated values have no tooltip or title, so a long supplier list can't be read without opening the menu.
FilterChip accepts no className or other props, so it can't take a ref, a test id or a different width.