Skip to content

Filter chip

A 28px chip for an active filter, with an indigo tint and a remove button.

Status
Beta
Category
Selection
Adoption
Not used yet
import { FilterChip } from "@oration/canon/components/filter-chip";
packages/canon/src/components/filter-chip.tsx
StatusisOverdueSupplieris any ofNorthwind Freight, Halcyon
3 of 5 invoices
  • 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

An active filter is live state with a text label, one of the few places indigo is spent: a 6% tint and a 22% inset ring. The text stays ink and Slate Meta.

Chips are controls, tags are values

Tags show what a record is, in the option hues, and never take a remove button. Filter chips show what the list is narrowed to, always in the indigo tint.

Anatomy#

Supplieris any ofNorthwind Freight, Halcyon
  1. Chip. 28px, 8px corners, 8px left and 2px right padding, a 6% indigo fill and a 22% inset indigo ring. 13px text.
  2. Icon. The field's icon at 14px in Slate Meta, passed through icon.
  3. Field and operator. The field name and is or is any of, both Slate Meta.
  4. Values. valuesLabel in medium ink, truncated at 192px.
  5. 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.

StatusisOverdueOwneris any ofPriya Raman, Aisha Bello
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.

Supplieris any ofNorthwind Freight, Halcyon, Orchard Street, Brightline Freight, Keystone Software
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

ScheduledisNext 7 days
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#

RestStatusisOverdue
Remove hoverStatusisOverdue
Remove focusStatusisOverdue
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>    );}
States
StateTreatment
RestIndigo tint and ring, with Slate Meta field and operator.
Remove hoverThe remove button fills Well Gray and the X turns ink.
Remove focus visibleA 3px Focus Indigo ring at 40% around the remove button.
TruncatedValues longer than 192px end in an ellipsis. There's no tooltip with the full list.
EmptyWith 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.
  • multiple switches the operator text from is to is any of. Pass values.length > 1.
  • valuesLabel is a ready string: join the labels yourself, such as labels.join(", ").
  • Only the remove button is interactive. To change a filter's values, open the filter menu beside the chips.
  • FilterChipRow is 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#

StatusisOverdue
Do. Show active filters as filter chips with the field, the operator and the values.
Overdue
Don't. Show a filter as a colored tag. It reads as the record's status, not as a filter on the list.

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.
Keyboard interactions
KeysAction
TabMoves focus to each chip's remove button in turn.
EnterRemoves the filter.
SpaceRemoves the filter.

Design tokens#

Design tokens
TokenUsed for
--primary6% fill and 22% inset ring
--foregroundValues
--muted-foregroundIcon, field, operator and X
--mutedRemove button hover
--ring3px focus ring at 40%
--radius-md8px chip corners

API reference#

FilterChip

One active filter. It takes no className or other props.

Props of FilterChip
PropTypeDefaultDescription
iconRequiredReactNodeNo defaultThe field's icon, usually 14px in Slate Meta with aria-hidden.
fieldRequiredstringNo defaultThe field name. Also names the remove button.
valuesLabelRequiredstringNo defaultThe chosen values as one string.
multipleRequiredbooleanNo defaultShows is any of instead of is.
onRemoveRequired() => voidNo defaultCalled 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.