A query filter bar where added filters become removable chips and the result count rolls on an odometer as the chip set changes; includes clear-all.
npx shadcn@latest add @paragon/filter-barAlso installs: dropdown-menu, digit-roll
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ListFilter, Plus, X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";
import { cn } from "@/lib/utils";
export interface FilterOption {
value: string;
label: string;
}
export interface FilterField {
id: string;
label: string;
options: FilterOption[];
}
export interface ActiveFilter {
fieldId: string;
fieldLabel: string;
value: string;
valueLabel: string;
}
export interface FilterBarProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
fields?: FilterField[];
/** Uncontrolled initial filters. */
defaultFilters?: ActiveFilter[];
/**
* Resolves the result count for a set of active filters. Deterministic by
* default so the count never depends on Date.now / random.
*/
countFor?: (filters: ActiveFilter[]) => number;
onChange?: (filters: ActiveFilter[]) => void;
}
const DEFAULT_FIELDS: FilterField[] = [
{
id: "status",
label: "Status",
options: [
{ value: "active", label: "Active" },
{ value: "trialing", label: "Trialing" },
{ value: "past_due", label: "Past due" },
{ value: "canceled", label: "Canceled" },
],
},
{
id: "plan",
label: "Plan",
options: [
{ value: "free", label: "Free" },
{ value: "pro", label: "Pro" },
{ value: "enterprise", label: "Enterprise" },
],
},
{
id: "region",
label: "Region",
options: [
{ value: "us", label: "US" },
{ value: "eu", label: "EU" },
{ value: "apac", label: "APAC" },
],
},
];
// Deterministic pseudo-count: start high, each filter narrows the set by a
// fixed, stable factor derived from its labels — no randomness.
function defaultCountFor(filters: ActiveFilter[]): number {
let count = 24_318;
for (const f of filters) {
const seed = (f.fieldId.length * 7 + f.value.length * 3) % 5;
count = Math.round(count * (0.28 + seed * 0.11));
}
return count;
}
/**
* A query filter bar. Adding a filter from the menu appends a removable chip
* that enters with the house language and exits on removal; the result count
* rolls on an odometer whenever the chip set changes, so you feel the query
* tightening. Clear-all wipes the chips and rolls the count back to the
* unfiltered total. Chips are keyed by field+value so AnimatePresence tracks
* them individually.
*/
export function FilterBar({
fields = DEFAULT_FIELDS,
defaultFilters = [],
countFor = defaultCountFor,
onChange,
className,
...props
}: FilterBarProps) {
const reduced = useReducedMotion();
const [filters, setFilters] =
React.useState<ActiveFilter[]>(defaultFilters);
const count = countFor(filters);
const commit = (next: ActiveFilter[]) => {
setFilters(next);
onChange?.(next);
};
const addFilter = (field: FilterField, option: FilterOption) => {
// Replace any existing filter for the same field+value pair (no dupes).
if (
filters.some((f) => f.fieldId === field.id && f.value === option.value)
)
return;
commit([
...filters,
{
fieldId: field.id,
fieldLabel: field.label,
value: option.value,
valueLabel: option.label,
},
]);
};
const removeFilter = (fieldId: string, value: string) =>
commit(filters.filter((f) => !(f.fieldId === fieldId && f.value === value)));
const chip = (f: ActiveFilter) => (
<span className="inline-flex items-center gap-1 rounded-md bg-secondary py-1 pr-1 pl-2 text-xs font-medium text-secondary-foreground">
<span className="text-muted-foreground">{f.fieldLabel}:</span>
{f.valueLabel}
<button
type="button"
aria-label={`Remove ${f.fieldLabel} ${f.valueLabel} filter`}
onClick={() => removeFilter(f.fieldId, f.value)}
className={cn(
"pressable flex size-4 items-center justify-center rounded-sm text-muted-foreground",
"transition-colors duration-(--duration-fast) hover:bg-background/60 hover:text-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
"relative after:absolute after:top-1/2 after:left-1/2 after:size-9 after:-translate-1/2",
)}
>
<X className="size-3" />
</button>
</span>
);
return (
<div
data-slot="filter-bar"
className={cn(
"flex flex-wrap items-center gap-2 rounded-xl bg-card p-3 text-card-foreground shadow-border",
className,
)}
{...props}
>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"pressable inline-flex h-8 items-center gap-1.5 rounded-lg bg-card px-2.5 text-[13px] font-medium shadow-border",
"transition-[box-shadow] duration-(--duration-fast) hover:shadow-border-hover",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<ListFilter className="size-3.5 text-muted-foreground" />
Filter
<Plus className="size-3.5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Add filter</DropdownMenuLabel>
<DropdownMenuSeparator />
{fields.map((field) => (
<DropdownMenuSub key={field.id}>
<DropdownMenuSubTrigger>{field.label}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{field.options.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() => addFilter(field, option)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Chips. */}
<div className="flex flex-1 flex-wrap items-center gap-2">
{reduced ? (
filters.map((f) => (
<React.Fragment key={`${f.fieldId}:${f.value}`}>
{chip(f)}
</React.Fragment>
))
) : (
<AnimatePresence mode="popLayout" initial={false}>
{filters.map((f) => (
<motion.span
key={`${f.fieldId}:${f.value}`}
layout
initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.9, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="inline-flex"
>
{chip(f)}
</motion.span>
))}
</AnimatePresence>
)}
</div>
<div className="ml-auto flex items-center gap-3 pl-1">
<span className="text-xs text-muted-foreground">
<DigitRoll
value={count}
className="text-sm font-semibold text-foreground"
/>{" "}
results
</span>
{filters.length > 0 && (
<button
type="button"
onClick={() => commit([])}
className={cn(
"text-xs font-medium text-muted-foreground",
"transition-colors duration-(--duration-fast) hover:text-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-sm",
)}
>
Clear all
</button>
)}
</div>
</div>
);
}