Skip to content

Search, filter and sort

Search fields, filter chips, saved views and the filtered-to-nothing state.

The problem#

Cedarline has thousands of invoices. People need to narrow them to the twelve that matter, see what they narrowed by, and come back to the same slice tomorrow.

Lists go wrong in quiet ways. A filter set in a menu is invisible once the menu closes, so someone wonders why Northwind's invoices are missing. A search that returns nothing looks exactly like an account with no invoices. A carefully built slice is lost on reload, and can't be sent to a colleague. And every list invents its own sort and filter controls.

The solution in Canon#

One toolbar shape for every list: view tabs on the left; search, Filter, Sort and Display on the right; active filters as chips beneath; the whole state in the URL.

Filters stay visible

Every active filter is a chip under the toolbar with its field, its operator, its values and a remove button.

Nothing says what hid it

When search or filters hide everything, the empty state names them and offers Clear filters.

The URL is the state

View, filters, sort and search are in the URL, so a link reproduces the slice and reload keeps it.

A filterable list of invoices

Switch views, search for north, filter by supplier or payment method, sort and change density. Filter On hold to Pinecrest Supply to reach the filtered-to-nothing state. The URL line shows what would be shared.

SupplierInvoiceDueMethodStatusAmount
Northwind FreightINV-20388Sep 21ACHOn hold$6,420.00
Orchard Street FoodsINV-20415Sep 22CheckPaid$2,143.88
Bluewater LogisticsINV-20376Sep 24ACHOverdue$22,100.75
Northwind FreightINV-20391Sep 26ACHOverdue$6,420.00
Kestrel Office SupplyINV-20402Sep 30Virtual cardPaid$389.12
Halcyon PackagingINV-20409Oct 1ACHOn hold$9,050.00
Northwind FreightINV-20417Oct 2ACHNeeds approval$18,240.00
Halcyon PackagingINV-20431Oct 5ACHOn hold$4,912.50
Orchard Street FoodsINV-20438Oct 6CheckScheduled$7,305.18
Pinecrest SupplyINV-20442Oct 9Virtual cardNeeds approval$1,264.00
Meridian Print Co.INV-20447Oct 12CheckScheduled$875.00
Northwind FreightINV-20449Oct 14ACHNeeds approval$11,850.00
12 of 12 invoicesTotal $90,970.43
URL/payables/invoices
import { Button } from "@oration/canon/components/button";import { EmptyState } from "@oration/canon/components/data-state";import {  DropdownMenu,  DropdownMenuCheckboxItem,  DropdownMenuContent,  DropdownMenuGroup,  DropdownMenuLabel,  DropdownMenuRadioGroup,  DropdownMenuRadioItem,  DropdownMenuSeparator,  DropdownMenuSub,  DropdownMenuSubContent,  DropdownMenuSubTrigger,  DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { StatusLabel, type StatusTone } from "@oration/canon/components/status-dot";import type { TagColor } from "@oration/canon/components/tag";import { ToolbarRow, ToolbarSearch } from "@oration/canon/components/toolbar";import { cn } from "@oration/canon/lib/utils";import {  ArrowUpDownIcon,  Building2Icon,  CreditCardIcon,  ListFilterIcon,  SlidersHorizontalIcon,} from "lucide-react";import { motion } from "motion/react";import * as React from "react";export function InvoiceList() {    type Status =        | "needs-approval"        | "on-hold"        | "overdue"        | "scheduled"        | "paid";    type Method = "ACH" | "Check" | "Virtual card";    type Invoice = {        id: string;        supplier: string;        color: TagColor;        cents: number;        due: string;        dueLabel: string;        method: Method;        status: Status;    };    type ViewId = "all" | "needs-approval" | "on-hold" | "overdue" | "paid";    type SortKey = "due" | "amount" | "supplier";    const invoices: Invoice[] = [        {            id: "INV-20417",            supplier: "Northwind Freight",            color: "blue",            cents: 1824000,            due: "2026-10-02",            dueLabel: "Oct 2",            method: "ACH",            status: "needs-approval",        },        {            id: "INV-20431",            supplier: "Halcyon Packaging",            color: "teal",            cents: 491250,            due: "2026-10-05",            dueLabel: "Oct 5",            method: "ACH",            status: "on-hold",        },        {            id: "INV-20438",            supplier: "Orchard Street Foods",            color: "orange",            cents: 730518,            due: "2026-10-06",            dueLabel: "Oct 6",            method: "Check",            status: "scheduled",        },        {            id: "INV-20442",            supplier: "Pinecrest Supply",            color: "violet",            cents: 126400,            due: "2026-10-09",            dueLabel: "Oct 9",            method: "Virtual card",            status: "needs-approval",        },        {            id: "INV-20376",            supplier: "Bluewater Logistics",            color: "green",            cents: 2210075,            due: "2026-09-24",            dueLabel: "Sep 24",            method: "ACH",            status: "overdue",        },        {            id: "INV-20391",            supplier: "Northwind Freight",            color: "blue",            cents: 642000,            due: "2026-09-26",            dueLabel: "Sep 26",            method: "ACH",            status: "overdue",        },        {            id: "INV-20402",            supplier: "Kestrel Office Supply",            color: "amber",            cents: 38912,            due: "2026-09-30",            dueLabel: "Sep 30",            method: "Virtual card",            status: "paid",        },        {            id: "INV-20409",            supplier: "Halcyon Packaging",            color: "teal",            cents: 905000,            due: "2026-10-01",            dueLabel: "Oct 1",            method: "ACH",            status: "on-hold",        },        {            id: "INV-20415",            supplier: "Orchard Street Foods",            color: "orange",            cents: 214388,            due: "2026-09-22",            dueLabel: "Sep 22",            method: "Check",            status: "paid",        },        {            id: "INV-20447",            supplier: "Meridian Print Co.",            color: "pink",            cents: 87500,            due: "2026-10-12",            dueLabel: "Oct 12",            method: "Check",            status: "scheduled",        },        {            id: "INV-20449",            supplier: "Northwind Freight",            color: "blue",            cents: 1185000,            due: "2026-10-14",            dueLabel: "Oct 14",            method: "ACH",            status: "needs-approval",        },        {            id: "INV-20388",            supplier: "Northwind Freight",            color: "blue",            cents: 642000,            due: "2026-09-21",            dueLabel: "Sep 21",            method: "ACH",            status: "on-hold",        },    ];    const statusMeta: Record<Status, { label: string; tone: StatusTone }> = {        "needs-approval": { label: "Needs approval", tone: "info" },        "on-hold": { label: "On hold", tone: "warning" },        overdue: { label: "Overdue", tone: "danger" },        scheduled: { label: "Scheduled", tone: "neutral" },        paid: { label: "Paid", tone: "success" },    };    const views: { id: ViewId; label: string }[] = [        { id: "all", label: "All invoices" },        { id: "needs-approval", label: "Needs approval" },        { id: "on-hold", label: "On hold" },        { id: "overdue", label: "Overdue" },        { id: "paid", label: "Paid" },    ];    const sorts: Record<SortKey, string> = {        due: "Due date, soonest first",        amount: "Amount, largest first",        supplier: "Supplier, A to Z",    };    const suppliers = [...new Set(invoices.map((row) => row.supplier))].sort();    const methods: Method[] = ["ACH", "Check", "Virtual card"];    const money = (cents: number) =>        (cents / 100).toLocaleString("en-US", {            style: "currency",            currency: "USD",        });    const slug = (text: string) =>        text            .toLowerCase()            .replace(/[^a-z0-9]+/g, "-")            .replace(/-$/, "");    const id = React.useId();    const [view, setView] = React.useState<ViewId>("all");    const [query, setQuery] = React.useState("");    const [supplierFilter, setSupplierFilter] = React.useState<string[]>([]);    const [methodFilter, setMethodFilter] = React.useState<Method[]>([]);    const [sort, setSort] = React.useState<SortKey>("due");    const [compact, setCompact] = React.useState(false);    const [showMethod, setShowMethod] = React.useState(true);    const toggle = <T,>(list: T[], value: T) =>        list.includes(value)            ? list.filter((item) => item !== value)            : [...list, value];    const needle = query.trim().toLowerCase();    const rows = invoices        .filter((row) => view === "all" || row.status === view)        .filter(            (row) =>                !supplierFilter.length || supplierFilter.includes(row.supplier),        )        .filter(            (row) => !methodFilter.length || methodFilter.includes(row.method),        )        .filter(            (row) =>                !needle ||                row.supplier.toLowerCase().includes(needle) ||                row.id.toLowerCase().includes(needle),        )        .sort((a, b) =>            sort === "amount"                ? b.cents - a.cents                : sort === "supplier"                  ? a.supplier.localeCompare(b.supplier)                  : a.due.localeCompare(b.due),        );    const total = rows.reduce((sum, row) => sum + row.cents, 0);    const filterCount =        (supplierFilter.length ? 1 : 0) + (methodFilter.length ? 1 : 0);    const clearFilters = () => {        setSupplierFilter([]);        setMethodFilter([]);        setQuery("");    };    const params = new URLSearchParams();    if (view !== "all") params.set("view", view);    for (const name of supplierFilter) params.append("supplier", slug(name));    for (const method of methodFilter) params.append("method", slug(method));    if (sort !== "due") params.set("sort", sort);    if (needle) params.set("q", needle);    const search = params.toString();    const url = `/payables/invoices${search ? `?${search}` : ""}`;    const hidden: string[] = [];    if (view !== "all")        hidden.push(            `the ${views.find((item) => item.id === view)?.label} view`,        );    if (supplierFilter.length)        hidden.push(`Supplier is ${supplierFilter.join(" or ")}`);    if (methodFilter.length)        hidden.push(`Payment method is ${methodFilter.join(" or ")}`);    if (needle) hidden.push(`a search for “${query.trim()}”`);    return (        <div className="flex w-full max-w-4xl flex-col overflow-hidden rounded-xl bg-background text-left shadow-border">            <ToolbarRow className="gap-x-3">                <div                    role="tablist"                    aria-label="Invoice views"                    className="no-scrollbar flex min-w-0 items-center gap-0.5 overflow-x-auto"                >                    {views.map((item) => {                        const selected = item.id === view;                        const count = invoices.filter(                            (row) =>                                item.id === "all" || row.status === item.id,                        ).length;                        return (                            <button                                key={item.id}                                type="button"                                role="tab"                                aria-selected={selected}                                aria-controls={`${id}-results`}                                onClick={() => setView(item.id)}                                className={cn(                                    "relative flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-13 whitespace-nowrap outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40",                                    selected                                        ? "font-medium text-foreground"                                        : "text-muted-foreground hover:text-foreground",                                )}                            >                                {selected ? (                                    <motion.span                                        layoutId={`${id}-view`}                                        className="absolute inset-0 rounded-md bg-muted"                                        transition={{                                            type: "spring",                                            duration: 0.3,                                            bounce: 0,                                        }}                                    />                                ) : null}                                <span className="relative">{item.label}</span>                                <span className="relative text-xs font-normal text-muted-foreground tabular-nums">                                    {count}                                </span>                            </button>                        );                    })}                </div>                <div className="ml-auto flex items-center gap-1 max-sm:w-full">                    <ToolbarSearch                        value={query}                        onChange={setQuery}                        placeholder="Search invoices"                        className="text-[13px] max-sm:flex-1"                    />                    <DropdownMenu>                        <DropdownMenuTrigger                            render={                                <Button                                    variant="ghost"                                    size="sm"                                    className={cn(                                        "text-muted-foreground",                                        filterCount && "text-foreground",                                    )}                                />                            }                        >                            <ListFilterIcon                                data-icon="inline-start"                                aria-hidden="true"                            />                            Filter                            {filterCount ? (                                <span className="text-xs text-primary tabular-nums">                                    {filterCount}                                </span>                            ) : null}                        </DropdownMenuTrigger>                        <DropdownMenuContent align="end" className="w-52">                            <DropdownMenuSub>                                <DropdownMenuSubTrigger>                                    <Building2Icon                                        aria-hidden="true"                                        className="text-muted-foreground"                                    />                                    Supplier                                </DropdownMenuSubTrigger>                                <DropdownMenuSubContent className="w-56">                                    {suppliers.map((name) => (                                        <DropdownMenuCheckboxItem                                            key={name}                                            checked={supplierFilter.includes(                                                name,                                            )}                                            onCheckedChange={() =>                                                setSupplierFilter((list) =>                                                    toggle(list, name),                                                )                                            }                                        >                                            {name}                                        </DropdownMenuCheckboxItem>                                    ))}                                </DropdownMenuSubContent>                            </DropdownMenuSub>                            <DropdownMenuSub>                                <DropdownMenuSubTrigger>                                    <CreditCardIcon                                        aria-hidden="true"                                        className="text-muted-foreground"                                    />                                    Payment method                                </DropdownMenuSubTrigger>                                <DropdownMenuSubContent className="w-44">                                    {methods.map((method) => (                                        <DropdownMenuCheckboxItem                                            key={method}                                            checked={methodFilter.includes(                                                method,                                            )}                                            onCheckedChange={() =>                                                setMethodFilter((list) =>                                                    toggle(list, method),                                                )                                            }                                        >                                            {method}                                        </DropdownMenuCheckboxItem>                                    ))}                                </DropdownMenuSubContent>                            </DropdownMenuSub>                        </DropdownMenuContent>                    </DropdownMenu>                    <DropdownMenu>                        <DropdownMenuTrigger                            render={                                <Button                                    variant="ghost"                                    size="sm"                                    className="text-muted-foreground"                                />                            }                        >                            <ArrowUpDownIcon                                data-icon="inline-start"                                aria-hidden="true"                            />                            Sort                        </DropdownMenuTrigger>                        <DropdownMenuContent align="end" className="w-56">                            <DropdownMenuGroup>                                <DropdownMenuLabel>Sort by</DropdownMenuLabel>                                <DropdownMenuRadioGroup                                    value={sort}                                    onValueChange={(value) =>                                        setSort(value as SortKey)                                    }                                >                                    {(Object.keys(sorts) as SortKey[]).map(                                        (key) => (                                            <DropdownMenuRadioItem                                                key={key}                                                value={key}                                            >                                                {sorts[key]}                                            </DropdownMenuRadioItem>                                        ),                                    )}                                </DropdownMenuRadioGroup>                            </DropdownMenuGroup>                        </DropdownMenuContent>                    </DropdownMenu>                    <DropdownMenu>                        <DropdownMenuTrigger                            render={                                <Button                                    variant="ghost"                                    size="icon-sm"                                    aria-label="Display options"                                    className="text-muted-foreground"                                />                            }                        >                            <SlidersHorizontalIcon aria-hidden="true" />                        </DropdownMenuTrigger>                        <DropdownMenuContent align="end" className="w-52">                            <DropdownMenuGroup>                                <DropdownMenuLabel>                                    Row height                                </DropdownMenuLabel>                                <DropdownMenuRadioGroup                                    value={compact ? "compact" : "default"}                                    onValueChange={(value) =>                                        setCompact(value === "compact")                                    }                                >                                    <DropdownMenuRadioItem value="compact">                                        Compact, 32px                                    </DropdownMenuRadioItem>                                    <DropdownMenuRadioItem value="default">                                        Default, 36px                                    </DropdownMenuRadioItem>                                </DropdownMenuRadioGroup>                            </DropdownMenuGroup>                            <DropdownMenuSeparator />                            <DropdownMenuGroup>                                <DropdownMenuLabel>Columns</DropdownMenuLabel>                                <DropdownMenuCheckboxItem                                    checked={showMethod}                                    onCheckedChange={setShowMethod}                                >                                    Payment method                                </DropdownMenuCheckboxItem>                            </DropdownMenuGroup>                        </DropdownMenuContent>                    </DropdownMenu>                </div>            </ToolbarRow>            {filterCount ? (                <FilterChipRow className="border-b border-border px-3 py-2">                    {supplierFilter.length ? (                        <FilterChip                            icon={                                <Building2Icon                                    aria-hidden="true"                                    className="size-3.5 text-muted-foreground"                                />                            }                            field="Supplier"                            valuesLabel={supplierFilter.join(", ")}                            multiple={supplierFilter.length > 1}                            onRemove={() => setSupplierFilter([])}                        />                    ) : null}                    {methodFilter.length ? (                        <FilterChip                            icon={                                <CreditCardIcon                                    aria-hidden="true"                                    className="size-3.5 text-muted-foreground"                                />                            }                            field="Payment method"                            valuesLabel={methodFilter.join(", ")}                            multiple={methodFilter.length > 1}                            onRemove={() => setMethodFilter([])}                        />                    ) : null}                    <Button                        variant="ghost"                        size="xs"                        className="text-muted-foreground"                        onClick={clearFilters}                    >                        Clear filters                    </Button>                </FilterChipRow>            ) : null}            <div                id={`${id}-results`}                role="tabpanel"                aria-label="Invoices"                className="min-h-72"            >                {rows.length ? (                    <div className="overflow-x-auto">                        <table className="w-full min-w-[40rem] border-collapse text-13">                            <thead>                                <tr className="h-8 border-b border-border text-left text-muted-foreground">                                    <th                                        scope="col"                                        className="px-3 font-medium"                                    >                                        Supplier                                    </th>                                    <th                                        scope="col"                                        className="px-3 font-medium"                                    >                                        Invoice                                    </th>                                    <th                                        scope="col"                                        className="px-3 font-medium"                                    >                                        Due                                    </th>                                    {showMethod ? (                                        <th                                            scope="col"                                            className="px-3 font-medium"                                        >                                            Method                                        </th>                                    ) : null}                                    <th                                        scope="col"                                        className="px-3 font-medium"                                    >                                        Status                                    </th>                                    <th                                        scope="col"                                        className="px-3 text-right font-medium"                                    >                                        Amount                                    </th>                                </tr>                            </thead>                            <tbody>                                {rows.map((row) => (                                    <tr                                        key={row.id}                                        className={cn(                                            "border-b border-border hover:bg-surface",                                            compact ? "h-8" : "h-9",                                        )}                                    >                                        <td className="px-3">                                            <span className="flex items-center gap-2">                                                <MonogramTile                                                    name={row.supplier}                                                    color={row.color}                                                    size="sm"                                                />                                                <span className="truncate font-medium">                                                    {row.supplier}                                                </span>                                            </span>                                        </td>                                        <td className="px-3 font-mono text-xs text-muted-foreground">                                            {row.id}                                        </td>                                        <td className="px-3 tabular-nums">                                            {row.dueLabel}                                        </td>                                        {showMethod ? (                                            <td className="px-3 text-muted-foreground">                                                {row.method}                                            </td>                                        ) : null}                                        <td className="px-3">                                            <StatusLabel                                                tone={                                                    statusMeta[row.status].tone                                                }                                            >                                                {statusMeta[row.status].label}                                            </StatusLabel>                                        </td>                                        <td className="px-3 text-right font-medium tabular-nums">                                            {money(row.cents)}                                        </td>                                    </tr>                                ))}                            </tbody>                        </table>                    </div>                ) : (                    <EmptyState                        illustration={filterCount ? "filters" : "search"}                        title={                            filterCount                                ? "No invoices match these filters"                                : "No invoices match this search"                        }                        description={`Hidden by ${hidden.join(", ")}.`}                        action={                            <Button                                variant="outline"                                size="sm"                                onClick={clearFilters}                            >                                {filterCount ? "Clear filters" : "Clear search"}                            </Button>                        }                    />                )}            </div>            <div className="flex h-9 items-center justify-between gap-3 border-t border-border px-3 text-xs text-muted-foreground tabular-nums">                <span aria-live="polite">                    {rows.length} of {invoices.length} invoices                </span>                <span>                    Total{" "}                    <span className="font-medium text-foreground">                        {money(total)}                    </span>                </span>            </div>            <div className="flex items-center gap-2 border-t border-border bg-muted/50 px-3 py-2">                <span className="shrink-0 text-xs text-muted-foreground">                    URL                </span>                <code className="min-w-0 truncate font-mono text-xs text-foreground">                    {url}                </code>            </div>        </div>    );}

Decision guide#

Five tools narrow or reshape a list. Each has one job.

Narrowing and reshaping a list
ToolJobLives inExample
SearchFind rows by text you remember: a name, an ID.28px field in the toolbarnorth, INV-20431
FilterKeep rows whose field has certain values.Filter menu, then chipsSupplier is any of Northwind Freight, Halcyon Packaging
ViewA saved filter and sort that people return to.View tabs with countsNeeds approval 12
SortOrder the rows. One key at a time.Sort menuDue date, soonest first
DisplayChange how rows look, not which rows show.Display menuCompact rows, hide Method

Filter chips#

Filters are chosen in a menu and shown as chips. A chip is a control, not a tag: 28px, a 6% indigo tint and a 22% inset indigo ring.

Supplieris any ofNorthwind Freight, Halcyon PackagingPayment methodisACH
import { Button } from "@oration/canon/components/button";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { Building2Icon, CreditCardIcon } from "lucide-react";import * as React from "react";export function FilterChipAnatomy() {    const [chips, setChips] = React.useState([        {            field: "Supplier",            values: ["Northwind Freight", "Halcyon Packaging"],            icon: "supplier",        },        { field: "Payment method", values: ["ACH"], icon: "method" },    ]);    return (        <div className="flex w-full max-w-xl flex-col gap-3 text-left">            <FilterChipRow>                {chips.map((chip) => (                    <FilterChip                        key={chip.field}                        icon={                            chip.icon === "supplier" ? (                                <Building2Icon                                    aria-hidden="true"                                    className="size-3.5 text-muted-foreground"                                />                            ) : (                                <CreditCardIcon                                    aria-hidden="true"                                    className="size-3.5 text-muted-foreground"                                />                            )                        }                        field={chip.field}                        valuesLabel={chip.values.join(", ")}                        multiple={chip.values.length > 1}                        onRemove={() =>                            setChips((list) =>                                list.filter(                                    (item) => item.field !== chip.field,                                ),                            )                        }                    />                ))}                {chips.length ? null : (                    <span className="text-13 text-muted-foreground">                        No filters                    </span>                )}            </FilterChipRow>            {chips.length < 2 ? (                <Button                    variant="ghost"                    size="sm"                    className="self-start"                    onClick={() =>                        setChips([                            {                                field: "Supplier",                                values: [                                    "Northwind Freight",                                    "Halcyon Packaging",                                ],                                icon: "supplier",                            },                            {                                field: "Payment method",                                values: ["ACH"],                                icon: "method",                            },                        ])                    }                >                    Reset example                </Button>            ) : null}        </div>    );}
Filter chip anatomy
PartSpec
Chip28px tall, 6px corners, bg-primary/6 with a 1px inset ring of primary at 22%, 13px text.
IconThe field's 14px icon in Slate Meta.
Field and operatorSlate Meta: Supplier is, or Supplier is any of with more than one value.
ValuesMedium Graphite Ink, joined with commas, truncated at 12rem.
RemoveA 24px button with a 14px X, labelled Remove Supplier filter.
RowChips wrap with 6px gaps under the toolbar, with a ghost Clear filters at the end.
Filter buttonGhost, 28px. With filters on, its label turns Graphite Ink and the count shows in indigo.

The chip body isn't a button, so a filter can't be edited from its chip, and the only operators are is and is any of. See Filter chip.

Sort, display and saved views#

Sort orders rows, Display changes how they look, and views save the combination people come back to.

Sort and display menus#

Both are ghost 28px triggers at the right of the toolbar, opening menus of radio and checkbox items.

Sort and display
MenuHoldsRule
SortOne radio group. Each option names its direction: Due date, soonest first.One key at a time. The default sort is left out of the URL.
DisplayRow height (compact 32px, default 36px, comfortable 44px) and column visibility.Display never hides rows. It is remembered per list.

Saved views#

A view is a named filter and sort. View tabs sit at the left of the toolbar: 28px pills with a sliding Well Gray fill and the count in 12px Slate Meta.

  • Name views for the job, not the filter: Needs approval, not Status = Pending.
  • Counts are the view's total, not the current search result, so they don't jump while typing.
  • Changing filters inside a view shows the chips; saving them updates the view or creates a new one.
  • The row scrolls with a 2rem fade on the clipped edge when views outgrow the toolbar.
  • See Navigation and wayfinding for how view tabs relate to other tabs.

Filtered to nothing#

When search or filters hide every row, say what hid them and how to get them back. Never reuse the first-use empty state.

No invoices match these filters

Hidden by the On hold view, Supplier is Pinecrest Supply.

Do. Name the filters and offer Clear filters.

No invoices yet

Invoices from your suppliers show up here.

Don't. Show the first-use state. It tells someone with 248 invoices that they have none.
  • Use the filters illustration when filters hid everything, search when only a search did.
  • List what's hiding the rows in the description, in words: Supplier is Pinecrest Supply.
  • The action clears filters and search but keeps the view. If the view itself is empty, use the view's own empty state.
  • Keep the toolbar and chips visible above it, so each filter can also be removed on its own.

URL state#

Whatever narrows the list goes in the query string, written with replaceState so Back leaves the page rather than undoing a keystroke.

Query parameters
ParameterHoldsOmitted when
viewThe saved view's id: on-hold.It's the default view.
supplier, methodOne entry per value: supplier=northwind-freight&supplier=halcyon-packaging.No values are selected.
sortThe sort key: amount.It's the default sort.
qThe search text.The search is empty.
Writing the state
// View, filters, sort and search live in the URL. replaceState keeps// Back meaning "the previous page", not "the previous keystroke".React.useEffect(() => {  const params = new URLSearchParams();  if (view !== "all") params.set("view", view);  for (const supplier of suppliers) params.append("supplier", supplier);  if (sort !== "due") params.set("sort", sort);  if (query) params.set("q", query);  const search = params.toString();  window.history.replaceState(    window.history.state,    "",    search ? `?${search}` : window.location.pathname,  );}, [view, suppliers, sort, query]);
  • Read the parameters on load so a shared link opens the same slice.
  • Display preferences (density, columns) are personal. They live in local storage, not the URL.
  • Opening a record from the list keeps the list's URL one Back away.

Accessibility#

Narrowing a list changes what's on screen without moving focus, so say so.

  • Every search field has a label, visible or aria-label, that matches its placeholder.
  • The result count (8 of 12 invoices) is a polite live region, so typing a search announces how many rows remain.
  • Filter, Sort and Display are menu buttons with visible labels; the icon-only Display button has an aria-label and a tooltip.
  • Each chip's remove button names the filter: Remove Supplier filter.
  • View tabs use tab roles with aria-selected; the list is their tab panel.
  • Inputs are 16px below 768px so focusing search doesn't zoom the page.
Keyboard interactions
KeysAction
EscClose an open filter, sort or display menu.
↑↓Move through menu items; → opens a filter's values.
SpaceToggle a value in a filter menu without closing it.

Components involved#

The parts this pattern is built from.

Components involved
ComponentRole here
ToolbarToolbarRow and ToolbarSearch, the 28px row above every list.
Search fieldPage and picker search.
Filter chipActive filters under the toolbar.
Dropdown menuFilter, Sort and Display menus with checkbox and radio items.
Data stateEmptyState for filtered to nothing.
Data gridThe record table these controls drive.
Status labelInvoice status with a label beside the dot.
Command menuSearch across the whole suite.