Data Display

Filter Bar

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.

Install

npx shadcn@latest add @paragon/filter-bar

Also installs: dropdown-menu, digit-roll

filter-bar.tsx

"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>
  );
}