Says how many items active filters hide and which filter hides what, so people don't think data is missing or bulk-act on a partial set.
StopsThought data was missing behind a filter
"use client"
import * as React from "react"
import { FilterNotice, hiddenByEach } from "@/components/ui/filter-notice"
type Invoice = { id: string; client: string; status: "Paid" | "Overdue" | "Draft"; owner: "Me" | "Priya" }
const invoices: Invoice[] = [
{ id: "INV-101", client: "Acme", status: "Overdue", owner: "Me" },
{ id: "INV-102", client: "Globex", status: "Paid", owner: "Me" },
{ id: "INV-103", client: "Acme", status: "Overdue", owner: "Priya" },
{ id: "INV-104", client: "Initech", status: "Draft", owner: "Me" },
{ id: "INV-105", client: "Acme", status: "Paid", owner: "Priya" },
{ id: "INV-106", client: "Umbrella", status: "Overdue", owner: "Priya" },
{ id: "INV-107", client: "Globex", status: "Overdue", owner: "Me" },
{ id: "INV-108", client: "Hooli", status: "Paid", owner: "Priya" },
{ id: "INV-109", client: "Acme", status: "Draft", owner: "Priya" },
{ id: "INV-110", client: "Initech", status: "Overdue", owner: "Priya" },
]
const allFilters = [
{ id: "status", label: "Status: Overdue", test: (invoice: Invoice) => invoice.status === "Overdue" },
{ id: "owner", label: "Owner: Me", test: (invoice: Invoice) => invoice.owner === "Me" },
]
export function Demo() {
const [active, setActive] = React.useState(["status", "owner"])
const [selected, setSelected] = React.useState(["INV-101", "INV-103", "INV-106"])
const filters = allFilters.filter((filter) => active.includes(filter.id))
const matches = (invoice: Invoice, skip?: string) => filters.every((filter) => filter.id === skip || filter.test(invoice))
const visible = invoices.filter((invoice) => matches(invoice))
const hides = hiddenByEach(invoices, active, matches)
const hiddenSelected = selected.filter((id) => !visible.some((invoice) => invoice.id === id)).length
const toggle = (id: string) => setSelected((list) => (list.includes(id) ? list.filter((item) => item !== id) : [...list, id]))
return (
<div className="w-full max-w-md space-y-3">
<FilterNotice
total={invoices.length}
shown={visible.length}
filters={filters.map((filter) => ({ id: filter.id, label: filter.label, hides: hides[filter.id] }))}
hiddenSelected={hiddenSelected}
formatCount={(count) => `${count} ${count === 1 ? "invoice" : "invoices"}`}
onRemoveFilter={(id) => setActive((list) => list.filter((item) => item !== id))}
onClearAll={() => setActive([])}
/>
<div className="rounded-xl border bg-background text-sm">
<div className="flex items-center justify-between gap-2 border-b px-4 py-2 text-xs text-muted-foreground">
<span>{selected.length} selected</span>
{active.length < 2 && (
<button
type="button"
onClick={() => setActive(["status", "owner"])}
className="rounded-md border px-2 py-1 hover:bg-accent outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Reset demo
</button>
)}
</div>
<ul className="max-h-64 overflow-auto px-4 py-1">
{visible.map((invoice) => (
<li key={invoice.id} className="flex h-9 items-center gap-2.5">
<input
id={`filter-demo-${invoice.id}`}
type="checkbox"
checked={selected.includes(invoice.id)}
onChange={() => toggle(invoice.id)}
className="size-4 accent-primary"
/>
<label htmlFor={`filter-demo-${invoice.id}`} className="flex min-w-0 flex-1 justify-between gap-2">
<span className="truncate">
{invoice.id} · {invoice.client}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{invoice.status} · {invoice.owner}
</span>
</label>
</li>
))}
</ul>
</div>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uifilter-notice.tsx171 lines"use client" // Bollard UI — filter-notice · MIT licence · https://bollard-ui.vercel.app/components/filter-notice import * as React from "react" import { ListFilter, SearchX, X } from "lucide-react" import { cn } from "@/lib/utils" /** Banner layout shared by every Bollard UI banner: one height, padding, icon size and action style. */ const bar = "flex min-h-11 flex-wrap items-center gap-x-3 gap-y-1.5 px-3 py-1.5 text-sm" const action = "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-60" /** Banner tones, identical across Bollard UI banners. */ const tones = { warning: { surface: "border-amber-500/30 bg-background bg-linear-to-r from-amber-500/10 to-amber-500/10 text-amber-950 dark:text-amber-50", icon: "text-amber-600 dark:text-amber-400", muted: "text-amber-900/75 dark:text-amber-100/75", solid: "bg-amber-700 text-white hover:bg-amber-800 dark:bg-amber-400 dark:text-amber-950 dark:hover:bg-amber-300", outline: "border border-amber-700/25 hover:bg-amber-500/15 dark:border-amber-300/25", }, info: { surface: "border-sky-500/30 bg-background bg-linear-to-r from-sky-500/10 to-sky-500/10 text-sky-950 dark:text-sky-50", icon: "text-sky-600 dark:text-sky-400", muted: "text-sky-900/75 dark:text-sky-100/75", solid: "bg-sky-700 text-white hover:bg-sky-800 dark:bg-sky-400 dark:text-sky-950 dark:hover:bg-sky-300", outline: "border border-sky-700/25 hover:bg-sky-500/15 dark:border-sky-300/25", }, } as const export interface ActiveFilter { /** Stable id passed back to `onRemoveFilter`. */ id: string /** What the chip says, e.g. "Status: Open" or `Search: "acme"`. */ label: string /** Items that would come back if only this filter were removed. Shown on the chip; the biggest is highlighted. */ hides?: number } /** * Items each filter hides on its own: how many more would show if that one * filter were removed. `matches(item, skip)` should test every filter except * `skip`; pass `skip` as undefined for the full set. */ export function hiddenByEach<T>(items: T[], filterIds: string[], matches: (item: T, skip?: string) => boolean) { const shown = items.filter((item) => matches(item)).length return Object.fromEntries(filterIds.map((id) => [id, items.filter((item) => matches(item, id)).length - shown])) } const fill = (template: string, values: Record<string, string | number>) => template.replace(/\{(\w+)\}/g, (match, key: string) => String(values[key] ?? match)) export interface FilterNoticeProps extends Omit<React.ComponentProps<"div">, "children"> { /** Items before any filter or search. */ total: number /** Items visible now. */ shown: number /** Active filters and search terms, one chip each. */ filters: ActiveFilter[] /** Remove one filter. */ onRemoveFilter: (id: string) => void /** Remove every filter and the search. */ onClearAll: () => void /** Selected items that the filters now hide, so a bulk action wouldn't visibly include them. */ hiddenSelected?: number /** "23 items". Override to name your rows or translate. */ formatCount?: (count: number) => string /** Templates: `{hidden}`, `{shown}`, `{total}` (formatted counts) and `{filters}` ("2 filters") are filled in. */ message?: string /** Shown when filters hide every item. */ emptyText?: string /** `{count}` is the number of hidden selected items. */ hiddenSelectedText?: string /** Text for the clear-all button. */ clearAllLabel?: string /** Accessible label prefix for remove buttons. */ removeLabel?: string } const defaultCount = (count: number) => `${count} ${count === 1 ? "item" : "items"}` /** * Stops people concluding data is missing, or bulk-acting on a partial set, * because a filter they forgot about is still on. It says how many items are * hidden and by how many filters, lists each filter as a removable chip that * shows how many items it hides on its own (so the culprit stands out), and * warns when selected items are among the hidden ones. */ export function FilterNotice({ total, shown, filters, onRemoveFilter, onClearAll, hiddenSelected = 0, formatCount = defaultCount, message = "{hidden} hidden by {filters} · showing {shown} of {total}", emptyText = "Nothing matches. {hidden} hidden by {filters}.", hiddenSelectedText = "{count} selected {verb} hidden by these filters and not visible in the list.", clearAllLabel = "Clear all", removeLabel = "Remove filter", className, ...props }: FilterNoticeProps) { const hidden = Math.max(0, total - shown) if (hidden === 0 || filters.length === 0) return null const values = { hidden: formatCount(hidden), shown: shown.toLocaleString(), total: total.toLocaleString(), filters: `${filters.length} ${filters.length === 1 ? "filter" : "filters"}`, } const warn = shown === 0 || hiddenSelected > 0 const tone = warn ? tones.warning : tones.info const Icon = shown === 0 ? SearchX : ListFilter const biggest = Math.max(0, ...filters.map((filter) => filter.hides ?? 0)) return ( <div data-slot="filter-notice" data-state={shown === 0 ? "empty" : warn ? "hidden-selection" : "filtered"} role="region" aria-label="Active filters" className={cn("overflow-hidden rounded-lg border transition-colors", tone.surface, className)} {...props} > <div className={bar}> <div className="flex min-w-0 flex-1 basis-48 items-start gap-3"> <Icon aria-hidden className={cn("mt-0.5 size-4 shrink-0", tone.icon)} /> <p className="min-w-0 flex-1 font-medium tabular-nums" aria-live="polite"> {fill(shown === 0 ? emptyText : message, values)} </p> </div> <button type="button" onClick={onClearAll} className={cn(action, shown === 0 ? tone.solid : tone.outline)}> {clearAllLabel} </button> </div> <ul className="flex flex-wrap gap-1.5 px-3 pb-2" aria-label="Filters"> {filters.map((filter) => { const culprit = filters.length > 1 && biggest > 0 && filter.hides === biggest const extra = filter.hides ? ` (shows ${formatCount(filter.hides)})` : "" return ( <li key={filter.id} className={cn( "inline-flex h-7 max-w-full items-center gap-1 rounded-md border bg-background/70 pr-0.5 pl-2 text-xs", culprit ? "border-current/40 font-medium" : "border-current/15", )} > <span className="truncate">{filter.label}</span> {filter.hides ? <span className={cn("tabular-nums", tone.muted)}>+{filter.hides}</span> : null} <button type="button" aria-label={`${removeLabel} ${filter.label}${extra}`} onClick={() => onRemoveFilter(filter.id)} className="inline-flex size-6 shrink-0 items-center justify-center rounded-sm outline-none hover:bg-current/10 focus-visible:ring-[3px] focus-visible:ring-ring/50" > <X aria-hidden className="size-3.5" /> </button> </li> ) })} </ul> <p aria-live="polite" className={cn("border-t border-inherit px-3 py-2 text-xs empty:hidden", tone.muted)}> {hiddenSelected > 0 && fill(hiddenSelectedText, { count: hiddenSelected, verb: hiddenSelected === 1 ? "item is" : "items are" })} </p> </div> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { FilterNotice, hiddenByEach } from "@/components/ui/filter-notice"
<FilterNotice
total={invoices.length}
shown={visible.length}
filters={filters.map((filter) => ({ id: filter.id, label: filter.label, hides: hides[filter.id] }))}
hiddenSelected={hiddenSelected}
formatCount={(count) => `${count} ${count === 1 ? "invoice" : "invoices"}`}
onRemoveFilter={(id) => setActive((list) => list.filter((item) => item !== id))}
onClearAll={() => setActive([])}
/>What it catches
- Items hidden by active filters, counted
- Which filter hides the most, per chip
- Selected items that are currently hidden
Props
| Prop | Type | Default |
|---|---|---|
total* (required)Items before any filter or search. | number | — |
shown* (required)Items visible now. | number | — |
filters* (required)Active filters and search terms, one chip each. | ActiveFilter[] | — |
onRemoveFilter* (required)Remove one filter. | (id: string) => void | — |
onClearAll* (required)Remove every filter and the search. | () => void | — |
hiddenSelectedSelected items that the filters now hide, so a bulk action wouldn't visibly include them. | number | 0 |
formatCount"23 items". Override to name your rows or translate. | (count: number) => string | defaultCount |
messageTemplates: `{hidden}`, `{shown}`, `{total}` (formatted counts) and `{filters}` ("2 filters") are filled in. | string | "{hidden} hidden by {filters} · showing {shown} of {total}" |
emptyTextShown when filters hide every item. | string | "Nothing matches. {hidden} hidden by {filters}." |
hiddenSelectedText`{count}` is the number of hidden selected items. | string | "{count} selected {verb} hidden by these filters and not … |
clearAllLabelText for the clear-all button. | string | "Clear all" |
removeLabelAccessible label prefix for remove buttons. | string | "Remove filter" |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- A role="region" labelled "Active filters"
- Counts and warnings are aria-live="polite"
- Filter chips are a labelled list; remove buttons name the filter
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Counts come from your props; it doesn't compute them
- Per-filter counts are what each filter hides on its own, so they can add up to more than the total hidden
What it stops
Thought data was missing behind a filter
How people describe it
Also known as
filters · filter chips · data table · bulk action · search results · clear filters · hidden rows · admin table