niceui

Filter & Sort Bar New

Saved views, a step-by-step filter builder, sort chips and search over a list that filters live, with a save-view state.

ReactTailwind CSSTypeScriptCRMMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/crm-filter-bar.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/crm/FilterBar.tsx
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react';
import { ArrowDown, ArrowUp, ArrowUpDown, Check, ChevronDown, CircleDollarSign, Layers, ListFilter, MapPin, Plus, Search, UserRound, X } from 'lucide-react';
import { cn } from '@/lib/cn';

type FieldKey = 'stage' | 'owner' | 'city' | 'arr';
type Op = 'is' | 'isNot' | 'gte' | 'lte';
export type Filter = { id: string; field: FieldKey; op: Op; value: string };
type SortKey = 'name' | 'company' | 'arr';
export type Sort = { key: SortKey; dir: 'asc' | 'desc' };
export type SavedView = { id: string; name: string; filters: Filter[]; sort: Sort | null };
export type FilterPerson = { id: string; name: string; company: string; stage: string; owner: string; city: string; arr: number };

const STAGES = ['Lead', 'Qualified', 'Customer', 'Churned'];
const OWNERS = ['Ana Ruiz', 'Leo Park', 'Mira Shah'];
const CITIES = ['Berlin', 'Lisbon', 'London', 'New York', 'Tokyo'];
const FIELDS: Record<FieldKey, { label: string; icon: typeof Layers; options?: string[] }> = {
  stage: { label: 'Stage', icon: Layers, options: STAGES },
  owner: { label: 'Owner', icon: UserRound, options: OWNERS },
  city: { label: 'City', icon: MapPin, options: CITIES },
  arr: { label: 'ARR', icon: CircleDollarSign },
};
const SORTS: { key: SortKey; label: string }[] = [
  { key: 'name', label: 'Name' },
  { key: 'company', label: 'Company' },
  { key: 'arr', label: 'ARR' },
];
const OP_LABEL: Record<Op, string> = { is: 'is', isNot: 'is not', gte: '≥', lte: '≤' };
const STAGE_DOT: Record<string, string> = { Lead: 'bg-sky-400', Qualified: 'bg-violet-400', Customer: 'bg-emerald-400', Churned: 'bg-rose-400' };
const k = (n: number) => (n >= 1000 ? `$${Math.round(n / 1000)}k` : `$${n}`);
const uid = () => Math.random().toString(36).slice(2, 8);

export const DEFAULT_VIEWS: SavedView[] = [
  { id: 'all', name: 'All people', filters: [], sort: { key: 'name', dir: 'asc' } },
  { id: 'mine', name: 'My leads', filters: [{ id: 'f1', field: 'owner', op: 'is', value: 'Ana Ruiz' }, { id: 'f2', field: 'stage', op: 'is', value: 'Lead' }], sort: { key: 'arr', dir: 'desc' } },
  { id: 'hot', name: 'Hot deals', filters: [{ id: 'f3', field: 'stage', op: 'is', value: 'Qualified' }, { id: 'f4', field: 'arr', op: 'gte', value: '40000' }], sort: { key: 'arr', dir: 'desc' } },
];

function matches(p: FilterPerson, f: Filter) {
  if (f.field === 'arr') return f.op === 'gte' ? p.arr >= Number(f.value) : p.arr <= Number(f.value);
  const v = p[f.field];
  return f.op === 'is' ? v === f.value : v !== f.value;
}

function useDismiss(open: boolean, close: () => void, refs: RefObject<HTMLElement>[]) {
  useEffect(() => {
    if (!open) return;
    const down = (e: MouseEvent) => {
      if (!refs.some((r) => r.current?.contains(e.target as Node))) close();
    };
    const key = (e: KeyboardEvent) => e.key === 'Escape' && close();
    document.addEventListener('mousedown', down);
    document.addEventListener('keydown', key);
    return () => {
      document.removeEventListener('mousedown', down);
      document.removeEventListener('keydown', key);
    };
  }, [open]); // eslint-disable-line react-hooks/exhaustive-deps
}

function Pop({ children, className }: { children: ReactNode; className?: string }) {
  return <div className={cn('absolute left-0 top-[calc(100%+6px)] z-40 w-60 max-w-[calc(100vw-56px)] rounded-xl bg-ink-800 p-1.5 shadow-pop animate-scale-in', className)}>{children}</div>;
}

export type FilterBarProps = {
  people: FilterPerson[];
  views?: SavedView[];
  initialView?: string;
  className?: string;
};

export function FilterBar({ people, views: initialViews = DEFAULT_VIEWS, initialView = 'hot', className }: FilterBarProps) {
  const [views, setViews] = useState(initialViews);
  const [viewId, setViewId] = useState(initialView);
  const view = views.find((v) => v.id === viewId) ?? views[0];
  const [filters, setFilters] = useState<Filter[]>(view.filters);
  const [sort, setSort] = useState<Sort | null>(view.sort);
  const [query, setQuery] = useState('');
  const [menu, setMenu] = useState<'views' | 'filter' | 'sort' | null>(null);
  const [draft, setDraft] = useState<{ field: FieldKey | null; op: Op; custom: string }>({ field: null, op: 'is', custom: '' });
  const [saved, setSaved] = useState(false);
  const viewsRef = useRef<HTMLDivElement>(null);
  const filterRef = useRef<HTMLDivElement>(null);
  const sortRef = useRef<HTMLDivElement>(null);
  useDismiss(menu !== null, () => setMenu(null), [viewsRef, filterRef, sortRef]);

  useEffect(() => {
    if (!saved) return;
    const id = window.setTimeout(() => setSaved(false), 1800);
    return () => clearTimeout(id);
  }, [saved]);

  const dirty = JSON.stringify(filters.map(({ field, op, value }) => [field, op, value])) + JSON.stringify(sort) !== JSON.stringify(view.filters.map(({ field, op, value }) => [field, op, value])) + JSON.stringify(view.sort);

  const result = useMemo(() => {
    const q = query.trim().toLowerCase();
    const list = people.filter((p) => filters.every((f) => matches(p, f)) && (!q || `${p.name} ${p.company} ${p.city}`.toLowerCase().includes(q)));
    if (!sort) return list;
    const dir = sort.dir === 'asc' ? 1 : -1;
    return [...list].sort((a, b) => (sort.key === 'arr' ? a.arr - b.arr : a[sort.key].localeCompare(b[sort.key])) * dir);
  }, [people, filters, sort, query]);

  const pickView = (id: string) => {
    const v = views.find((x) => x.id === id)!;
    setViewId(id);
    setFilters(v.filters);
    setSort(v.sort);
    setMenu(null);
  };
  const addFilter = (field: FieldKey, op: Op, value: string) => {
    setFilters((fs) => [...fs, { id: uid(), field, op, value }]);
    setDraft({ field: null, op: 'is', custom: '' });
    setMenu(null);
  };
  const saveView = () => {
    setViews((vs) => vs.map((v) => (v.id === view.id ? { ...v, filters, sort } : v)));
    setSaved(true);
  };

  return (
    <div className={cn('w-full rounded-2xl bg-ink-900 shadow-card', className)}>
      <div className="space-y-2.5 border-b border-white/[0.06] p-3">
        <div className="flex flex-wrap items-center gap-2">
          <div ref={viewsRef} className="relative">
            <button onClick={() => setMenu(menu === 'views' ? null : 'views')} aria-haspopup="menu" aria-expanded={menu === 'views'} className="focus-ring flex h-8 items-center gap-2 rounded-lg bg-white/[0.04] px-2.5 text-[13px] font-medium text-ink-100 ring-1 ring-white/[0.07] transition-colors hover:bg-white/[0.07]">
              <ListFilter className="h-3.5 w-3.5 text-brand-300" />
              {view.name}
              <ChevronDown className={cn('h-3.5 w-3.5 text-ink-500 transition-transform', menu === 'views' && 'rotate-180')} />
            </button>
            {menu === 'views' && (
              <Pop>
                <p className="px-2 pb-1 pt-1 text-[10.5px] font-medium uppercase tracking-wider text-ink-500">Saved views</p>
                {views.map((v) => (
                  <button key={v.id} role="menuitemradio" aria-checked={v.id === view.id} onClick={() => pickView(v.id)} className="focus-ring flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left text-[13px] text-ink-200 transition-colors hover:bg-white/5">
                    <span>{v.name}</span>
                    <span className="flex items-center gap-2">
                      <span className="font-mono text-[11px] text-ink-500">{v.filters.length}</span>
                      {v.id === view.id && <Check className="h-3.5 w-3.5 text-brand-300" />}
                    </span>
                  </button>
                ))}
              </Pop>
            )}
          </div>
          <label className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-lg bg-ink-950/60 px-2.5 ring-1 ring-white/[0.06] focus-within:ring-brand-400/50 sm:max-w-[260px]">
            <Search className="h-3.5 w-3.5 shrink-0 text-ink-500" />
            <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search people…" aria-label="Search people" className="min-w-0 flex-1 bg-transparent text-[13px] text-ink-100 outline-none placeholder:text-ink-600" />
            {query && (
              <button onClick={() => setQuery('')} aria-label="Clear search" className="text-ink-500 hover:text-ink-200">
                <X className="h-3.5 w-3.5" />
              </button>
            )}
          </label>
          <span className="ml-auto font-mono text-[11.5px] tabular-nums text-ink-400" aria-live="polite">
            <span className="text-ink-100">{result.length}</span> of {people.length} people
          </span>
        </div>

        <div className="flex flex-wrap items-center gap-1.5">
          {filters.map((f) => {
            const F = FIELDS[f.field];
            return (
              <span key={f.id} className="inline-flex h-7 items-center gap-1.5 rounded-lg bg-brand-500/10 pl-2 pr-1 text-[12px] text-brand-200 ring-1 ring-brand-400/20 animate-scale-in">
                <F.icon className="h-3 w-3 text-brand-300" />
                <span className="text-brand-300/80">{F.label}</span>
                <span className="text-brand-300/60">{OP_LABEL[f.op]}</span>
                <span className="font-medium text-ink-50">{f.field === 'arr' ? k(Number(f.value)) : f.value}</span>
                <button onClick={() => setFilters((fs) => fs.filter((x) => x.id !== f.id))} aria-label={`Remove ${F.label} filter`} className="focus-ring grid h-5 w-5 place-items-center rounded-md text-brand-300/70 transition-colors hover:bg-brand-400/15 hover:text-ink-50">
                  <X className="h-3 w-3" />
                </button>
              </span>
            );
          })}
          <div ref={filterRef} className="relative">
            <button onClick={() => { setMenu(menu === 'filter' ? null : 'filter'); setDraft({ field: null, op: 'is', custom: '' }); }} aria-expanded={menu === 'filter'} className="focus-ring flex h-7 items-center gap-1 rounded-lg px-2 border border-dashed border-white/15 text-[12px] text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-100">
              <Plus className="h-3 w-3" /> Filter
            </button>
            {menu === 'filter' && (
              <Pop>
                {!draft.field ? (
                  <>
                    <p className="px-2 pb-1 pt-1 text-[10.5px] font-medium uppercase tracking-wider text-ink-500">Filter by</p>
                    {(Object.keys(FIELDS) as FieldKey[]).map((key) => {
                      const F = FIELDS[key];
                      return (
                        <button key={key} onClick={() => setDraft({ field: key, op: key === 'arr' ? 'gte' : 'is', custom: '' })} className="focus-ring flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] text-ink-200 transition-colors hover:bg-white/5">
                          <F.icon className="h-3.5 w-3.5 text-ink-400" /> {F.label}
                        </button>
                      );
                    })}
                  </>
                ) : (
                  <div className="space-y-1.5 p-1">
                    <div className="flex items-center justify-between">
                      <button onClick={() => setDraft({ field: null, op: 'is', custom: '' })} className="text-[11.5px] text-ink-400 hover:text-ink-100">
                        ← {FIELDS[draft.field].label}
                      </button>
                      <div className="flex rounded-md bg-ink-950/60 p-0.5">
                        {(draft.field === 'arr' ? (['gte', 'lte'] as Op[]) : (['is', 'isNot'] as Op[])).map((op) => (
                          <button key={op} onClick={() => setDraft((d) => ({ ...d, op }))} className={cn('rounded px-2 py-0.5 text-[11.5px] transition-colors', draft.op === op ? 'bg-ink-700 text-ink-50' : 'text-ink-400 hover:text-ink-100')}>
                            {OP_LABEL[op]}
                          </button>
                        ))}
                      </div>
                    </div>
                    {draft.field === 'arr' ? (
                      <>
                        <div className="grid grid-cols-4 gap-1">
                          {[10000, 25000, 50000, 100000].map((n) => (
                            <button key={n} onClick={() => addFilter('arr', draft.op, String(n))} className="rounded-md bg-white/[0.04] py-1.5 font-mono text-[11.5px] text-ink-200 transition-colors hover:bg-brand-500/15 hover:text-ink-50">
                              {k(n)}
                            </button>
                          ))}
                        </div>
                        <form
                          onSubmit={(e) => {
                            e.preventDefault();
                            const n = Number(draft.custom.replace(/[^\d]/g, ''));
                            if (n) addFilter('arr', draft.op, String(n));
                          }}
                          className="flex gap-1"
                        >
                          <input autoFocus value={draft.custom} onChange={(e) => setDraft((d) => ({ ...d, custom: e.target.value }))} placeholder="Custom amount" inputMode="numeric" aria-label="Custom ARR amount" className="h-7 min-w-0 flex-1 rounded-md bg-ink-950 px-2 text-[12.5px] text-ink-50 outline-none ring-1 ring-white/10 focus:ring-brand-400/50" />
                          <button className="rounded-md bg-brand-500 px-2 text-[12px] font-medium text-white">Apply</button>
                        </form>
                      </>
                    ) : (
                      FIELDS[draft.field].options!.map((o) => (
                        <button key={o} onClick={() => addFilter(draft.field!, draft.op, o)} className="focus-ring flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] text-ink-200 transition-colors hover:bg-white/5">
                          {draft.field === 'stage' && <span className={cn('h-2 w-2 rounded-full', STAGE_DOT[o])} />}
                          {o}
                        </button>
                      ))
                    )}
                  </div>
                )}
              </Pop>
            )}
          </div>
          <span className="mx-1 h-4 w-px bg-white/10" />
          {sort && (
            <span className="inline-flex h-7 items-center gap-1.5 rounded-lg bg-white/[0.04] pl-2 pr-1 text-[12px] text-ink-200 ring-1 ring-white/[0.07] animate-scale-in">
              <button onClick={() => setSort({ ...sort, dir: sort.dir === 'asc' ? 'desc' : 'asc' })} aria-label="Flip sort direction" className="flex items-center gap-1.5 hover:text-ink-50">
                {sort.dir === 'asc' ? <ArrowUp className="h-3 w-3 text-brand-300" /> : <ArrowDown className="h-3 w-3 text-brand-300" />}
                {SORTS.find((s) => s.key === sort.key)?.label}
              </button>
              <button onClick={() => setSort(null)} aria-label="Remove sort" className="focus-ring grid h-5 w-5 place-items-center rounded-md text-ink-500 transition-colors hover:bg-white/10 hover:text-ink-50">
                <X className="h-3 w-3" />
              </button>
            </span>
          )}
          <div ref={sortRef} className="relative">
            <button onClick={() => setMenu(menu === 'sort' ? null : 'sort')} aria-expanded={menu === 'sort'} className="focus-ring flex h-7 items-center gap-1 rounded-lg px-2 text-[12px] text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-100">
              <ArrowUpDown className="h-3 w-3" /> Sort
            </button>
            {menu === 'sort' && (
              <Pop className="w-52">
                {SORTS.map((s) => (
                  <div key={s.key} className="flex items-center justify-between rounded-lg px-2 py-1 text-[13px] text-ink-200 hover:bg-white/5">
                    {s.label}
                    <span className="flex gap-0.5">
                      {(['asc', 'desc'] as const).map((dir) => (
                        <button
                          key={dir}
                          onClick={() => {
                            setSort({ key: s.key, dir });
                            setMenu(null);
                          }}
                          aria-label={`Sort by ${s.label} ${dir === 'asc' ? 'ascending' : 'descending'}`}
                          className={cn('grid h-6 w-6 place-items-center rounded-md transition-colors', sort?.key === s.key && sort.dir === dir ? 'bg-brand-500/20 text-brand-200' : 'text-ink-500 hover:bg-white/10 hover:text-ink-100')}
                        >
                          {dir === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />}
                        </button>
                      ))}
                    </span>
                  </div>
                ))}
              </Pop>
            )}
          </div>
          <span className="ml-auto flex items-center gap-1.5">
            {saved ? (
              <span className="flex items-center gap-1 text-[12px] text-emerald-300 animate-fade-in">
                <Check className="h-3.5 w-3.5" /> View saved
              </span>
            ) : (
              dirty && (
                <span className="flex items-center gap-1.5 animate-fade-in">
                  <button onClick={() => pickView(view.id)} className="focus-ring rounded-lg px-2 py-1 text-[12px] text-ink-400 transition-colors hover:text-ink-100">
                    Reset
                  </button>
                  <button onClick={saveView} className="focus-ring rounded-lg bg-brand-500 px-2.5 py-1 text-[12px] font-medium text-white transition-colors hover:bg-brand-400">
                    Save view
                  </button>
                </span>
              )
            )}
          </span>
        </div>
      </div>

      <ul className="max-h-[300px] divide-y divide-white/[0.04] overflow-y-auto [scrollbar-width:thin]">
        {result.map((p) => (
          <li key={p.id} className="flex items-center gap-3 px-4 py-2.5 text-[13px] animate-fade-in">
            <span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-white/[0.06] text-[10.5px] font-semibold text-ink-200">
              {p.name
                .split(' ')
                .map((w) => w[0])
                .join('')}
            </span>
            <span className="min-w-0 flex-1">
              <span className="block truncate font-medium text-ink-50">{p.name}</span>
              <span className="block truncate text-[12px] text-ink-500">
                {p.company} · {p.city}
              </span>
            </span>
            <span className="hidden items-center gap-1.5 text-[12px] text-ink-300 sm:flex">
              <span className={cn('h-1.5 w-1.5 rounded-full', STAGE_DOT[p.stage])} />
              {p.stage}
            </span>
            <span className="hidden w-24 truncate text-[12px] text-ink-400 md:block">{p.owner}</span>
            <span className="w-16 text-right font-mono text-[12.5px] tabular-nums text-ink-100">{k(p.arr)}</span>
          </li>
        ))}
        {result.length === 0 && (
          <li className="px-4 py-10 text-center text-[13px] text-ink-500">
            No people match these filters.{' '}
            <button onClick={() => { setFilters([]); setQuery(''); }} className="text-brand-300 hover:underline">
              Clear filters
            </button>
          </li>
        )}
      </ul>
    </div>
  );
}

export const DEMO_FILTER_PEOPLE: FilterPerson[] = [
  { id: '1', name: 'Amara Okafor', company: 'Lumenpay', stage: 'Customer', owner: 'Ana Ruiz', city: 'London', arr: 84000 },
  { id: '2', name: 'Jonas Weber', company: 'Kestrel Labs', stage: 'Qualified', owner: 'Leo Park', city: 'Berlin', arr: 42000 },
  { id: '3', name: 'Priya Raman', company: 'Northbeam', stage: 'Lead', owner: 'Ana Ruiz', city: 'London', arr: 18000 },
  { id: '4', name: 'Mateo Silva', company: 'Harborline', stage: 'Qualified', owner: 'Mira Shah', city: 'Lisbon', arr: 56000 },
  { id: '5', name: 'Hana Kobayashi', company: 'Sorabase', stage: 'Customer', owner: 'Leo Park', city: 'Tokyo', arr: 120000 },
  { id: '6', name: 'Elliot Grant', company: 'Quillstack', stage: 'Churned', owner: 'Ana Ruiz', city: 'New York', arr: 9000 },
  { id: '7', name: 'Sofia Marin', company: 'Verdant OS', stage: 'Lead', owner: 'Ana Ruiz', city: 'Lisbon', arr: 24000 },
  { id: '8', name: 'Kwame Mensah', company: 'Tidewater AI', stage: 'Qualified', owner: 'Leo Park', city: 'London', arr: 67000 },
  { id: '9', name: 'Lena Fischer', company: 'Brightmill', stage: 'Customer', owner: 'Mira Shah', city: 'Berlin', arr: 38000 },
  { id: '10', name: 'Ravi Menon', company: 'Atlas Freight', stage: 'Lead', owner: 'Ana Ruiz', city: 'Tokyo', arr: 15000 },
  { id: '11', name: 'Noor Haddad', company: 'Pellucid', stage: 'Qualified', owner: 'Ana Ruiz', city: 'New York', arr: 91000 },
  { id: '12', name: 'Tomás Lindqvist', company: 'Fieldframe', stage: 'Lead', owner: 'Mira Shah', city: 'Berlin', arr: 12000 },
  { id: '13', name: 'Grace Liu', company: 'Kinetik', stage: 'Customer', owner: 'Leo Park', city: 'New York', arr: 73000 },
  { id: '14', name: 'Olu Adebayo', company: 'Sundial', stage: 'Qualified', owner: 'Mira Shah', city: 'London', arr: 48000 },
  { id: '15', name: 'Ines Duarte', company: 'Oddly', stage: 'Lead', owner: 'Ana Ruiz', city: 'Lisbon', arr: 31000 },
  { id: '16', name: 'Felix Brandt', company: 'Northform', stage: 'Churned', owner: 'Leo Park', city: 'Berlin', arr: 22000 },
  { id: '17', name: 'Yuki Tanaka', company: 'Lumen TV', stage: 'Qualified', owner: 'Leo Park', city: 'Tokyo', arr: 35000 },
  { id: '18', name: 'Clara Novak', company: 'Apex Media', stage: 'Customer', owner: 'Mira Shah', city: 'New York', arr: 58000 },
];

export function FilterBarDemo() {
  return (
    <div className="w-full max-w-[760px]">
      <FilterBar people={DEMO_FILTER_PEOPLE} initialView="hot" />
    </div>
  );
}