Skip to content
Star on GitHub
CollaborationFilter menu
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Filter menu

Add filters from a menu, see them as chips, and watch the count change. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add filter-menu
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add filter-menu

React

$npx designme add filter-menu --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Filter menu is the filter bar above a list. A Filter button opens a menu in two steps, a field and then its value, and each filter in use shows as a chip that reads like a sentence, "Status is Open". A chip's operator flips with a click, its × takes it off, and the count of matches and Clear all sit under the chips.

  • When a list can be narrowed by a few known fields, like status, owner or date.
  • When people should see at a glance which filters are on, and take one off without opening a menu.
  • When the number of matches matters, so people know how far a filter narrowed the list before they read it.
  • When a filter sometimes needs to read the other way, like "is not" or "older than".
03

Usage

Import it and drive it from your own state.

Import

import { FilterMenu } from '@/components/designme/filter-menu/FilterMenu';

Example

The demo above as a React component, 59 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { FilterMenu, type FilterMenuField, type FilterMenuFilter } from './FilterMenu';
import { avatarInk } from '../lib/motion';
import { useStory } from '../lib/useScript';

/* ---------- Filter menu: filter to open issues, then to Maya's, then take the status filter off ---------- */
const OWNERS: Record<string, [string, string]> = { MK: ['Maya Kim', 'var(--dm-av-l)'], LP: ['Leo Park', 'var(--dm-av-b)'], AR: ['Ana Ruiz', 'var(--dm-av-m)'], SC: ['Sam Cole', 'var(--dm-av-p)'] };
const STATUS: Record<string, [string, string]> = { open: ['Open', 'var(--dm-flm-open)'], prog: ['In progress', 'var(--dm-flm-prog)'], done: ['Done', 'var(--dm-flm-done)'] };
// title, status, owner and days since the last update, most recent first
const ISSUES = ([
  ['Pricing toggle hides the yearly saving', 'open', 'MK', 0], ['Plan cards wrap at 768px', 'open', 'MK', 1], ['Checkout button loses its focus ring', 'open', 'MK', 3],
  ['Invoice email shows the wrong currency', 'open', 'MK', 9], ['Seat slider skips 25', 'open', 'LP', 0], ['Coupon field accepts expired codes', 'open', 'LP', 2],
  ['Dark logo has a white edge', 'open', 'AR', 1], ['FAQ accordion jumps on open', 'open', 'AR', 5], ['Trial banner covers the nav', 'open', 'SC', 0],
  ['EU tax line rounds down', 'open', 'SC', 12], ['Annual plan link goes to a 404', 'open', 'LP', 20], ['Move pricing copy into the CMS', 'prog', 'MK', 0],
  ['Comparison table for Team', 'prog', 'MK', 2], ['Prices in yen for Japan', 'prog', 'LP', 4], ['New hero illustration', 'prog', 'AR', 1],
  ['Track plan clicks', 'prog', 'SC', 6], ['Enterprise contact form', 'prog', 'MK', 15], ['Seat limits on the Free plan', 'prog', 'AR', 3],
  ['Ship the new plan names', 'done', 'MK', 1], ['Price flicker on load', 'done', 'MK', 8], ['Remove the Starter plan', 'done', 'LP', 3],
  ['Badge for the yearly discount', 'done', 'AR', 2], ['Retry failed Stripe webhooks', 'done', 'SC', 25], ['Refund policy link', 'done', 'SC', 10]
] as [string, string, string, number][]).map(([title, status, owner, age]) => ({ title, status, owner, age })).sort((a, b) => a.age - b.age);
type Issue = (typeof ISSUES)[number];

const FIELDS: FilterMenuField[] = [
  { key: 'status', label: 'Status', icon: 'target', options: Object.entries(STATUS).map(([value, [label, color]]) => ({ value, label, glyph: <span className="dm-dot" style={{ color }} /> })) },
  { key: 'owner', label: 'Owner', icon: 'user', options: Object.entries(OWNERS).map(([value, [label, color]]) => ({ value, label, glyph: <span className="dm-av" style={{ background: color, color: avatarInk(color) }}>{value}</span> })) },
  { key: 'date', label: 'Updated', icon: 'cal', operators: ['within', 'older than'], options: [{ value: '1', label: 'Past day', chip: '1 day' }, { value: '7', label: 'Past 7 days', chip: '7 days' }, { value: '30', label: 'Past 30 days', chip: '30 days' }] }
];
const test = (t: Issue, f: FilterMenuFilter) => (f.field === 'status' ? t.status === f.value : f.field === 'owner' ? t.owner === f.value : t.age < +f.value) !== !!f.negate;

export default function FilterMenuDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [filters, setFilters] = useState<FilterMenuFilter[]>([]);
  const hits = ISSUES.filter(t => filters.every(f => test(t, f)));

  // the preview's story: filter to open issues, then to Maya's, and watch the count drop; then take the status filter off
  useStory(ref, async ({ wait, press }) => {
    await wait(1500); press('.dm-flm-tr');
    await wait(1200); press('.dm-mi[data-k="status"]');
    await wait(1200); press('.dm-flm-lv:not([hidden]) .dm-mi[role="menuitemradio"]');
    await wait(2500); press('.dm-flm-tr');
    await wait(1200); press('.dm-mi[data-k="owner"]');
    await wait(1200); press('.dm-flm-lv:not([hidden]) .dm-mi[role="menuitemradio"]');
    await wait(2500); press('.dm-flm-x');
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <FilterMenu fields={FIELDS} filters={filters} onFiltersChange={setFilters} total={ISSUES.length} count={hits.length}
        // each value says how many issues picking it would leave, with the other filters kept
        countFor={(field, value) => ISSUES.filter(t => filters.every(f => f.field === field || test(t, f)) && test(t, { field, value })).length}
        results={hits.slice(0, 4).map(t => ({
          id: t.title, title: t.title, meta: t.age ? t.age + 'd' : 'Today',
          dot: { color: STATUS[t.status][1], label: STATUS[t.status][0] }, avatar: { initials: t.owner, color: OWNERS[t.owner][1], label: OWNERS[t.owner][0] }
        }))} />
    </div>
  );
}
04

Props

Everything FilterMenu takes.

PropTypeWhat it does
fieldsrequiredFilterMenuField[]
filtersFilterMenuFilter[]The filters in use, in the order they were added; one per field. Leave it out to let the menu keep them
defaultFiltersFilterMenuFilter[]
onFiltersChange(filters: FilterMenuFilter[]) => void
totalrequirednumberHow many items there are, and how many match the filters
countrequirednumber
nounstringThe items' name, like "issues"
countFor(field: string, value: string) => numberThe number beside a value in the menu: how many items picking it would leave. Left out, no numbers show
resultsFilterMenuResult[]The first few matches, shown under the count
emptyLabelstringShown when nothing matches. Defaults to "No issues match these filters" with your noun
labelstringDefaults to "Filter" and "Clear all"
clearLabelstring
openbooleanWhether the menu is open. Leave it out to let the Filter button, Escape and a click outside open and close it
defaultOpenboolean
onOpenChange(open: boolean) => void
classNamestring
05

Code

The full source, as the CLI installs it.

FilterMenu.tsxReact · 211 lines
'use client';
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { avatarInk, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './filter-menu.css';

/** One value a field can be filtered by */
export interface FilterMenuOption {
  value: string;
  /** How the menu names it, like "Past 7 days" */
  label: string;
  /** How a chip names it, like "7 days". Defaults to the label */
  chip?: string;
  /** A dot, avatar or icon before the label in the menu */
  glyph?: ReactNode;
}

/** A field the menu can filter on, like Status, Owner or Updated */
export interface FilterMenuField {
  key: string;
  label: string;
  icon?: IconName;
  /** The two ways a filter on it reads, the second when it's flipped. Defaults to "is" and "is not" */
  operators?: [string, string];
  options: FilterMenuOption[];
}

/** A filter in use. `negate` reads it the other way: "is not", or the field's second operator */
export interface FilterMenuFilter {
  field: string;
  value: string;
  negate?: boolean;
}

/** A row in the results under the filters */
export interface FilterMenuResult {
  id: string;
  title: string;
  /** The colour of the dot before it, like 'var(--dm-flm-open)', and what it means, like "Open" */
  dot?: { color: string; label: string };
  /** An avatar colour, like 'var(--dm-av-l)', brings its own ink for the initials */
  avatar?: { initials: string; color: string; label: string };
  /** The quiet text at the end, like "Today" or "3d" */
  meta?: string;
}

export interface FilterMenuProps {
  fields: FilterMenuField[];
  /** The filters in use, in the order they were added; one per field. Leave it out to let the menu keep them */
  filters?: FilterMenuFilter[];
  defaultFilters?: FilterMenuFilter[];
  onFiltersChange?: (filters: FilterMenuFilter[]) => void;
  /** How many items there are, and how many match the filters */
  total: number;
  count: number;
  /** The items' name, like "issues" */
  noun?: string;
  /** The number beside a value in the menu: how many items picking it would leave. Left out, no numbers show */
  countFor?: (field: string, value: string) => number;
  /** The first few matches, shown under the count */
  results?: FilterMenuResult[];
  /** Shown when nothing matches. Defaults to "No issues match these filters" with your noun */
  emptyLabel?: string;
  /** Defaults to "Filter" and "Clear all" */
  label?: string;
  clearLabel?: string;
  /** Whether the menu is open. Leave it out to let the Filter button, Escape and a click outside open and close it */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  className?: string;
}

/** A Filter button that adds filters from a two-step menu (a field, then its value), the filters as removable chips whose
    operator flips with a click, and the number of matches with Clear all. */
export function FilterMenu({
  fields, filters, defaultFilters = [], onFiltersChange, total, count, noun = 'issues', countFor, results = [], emptyLabel,
  label = 'Filter', clearLabel = 'Clear all', open, defaultOpen = false, onOpenChange, className
}: FilterMenuProps) {
  const [list, setList] = useControllable(filters, defaultFilters, onFiltersChange);
  const menu = usePopover({ side: 'down', open, defaultOpen, onOpenChange });
  const pop = menu.pop.ref, trigger = menu.trigger.ref;
  const root = useRef<HTMLDivElement>(null);
  // the field whose values the menu shows, or null for the list of fields
  const [field, setField] = useState<string | null>(null);
  const from = useRef<string | null>(null);
  const focusTo = useRef<'first' | 'last' | 'value' | 'field' | null>(null);
  // chips already on screen keep still when the list changes; only a new one pops in
  const seen = useRef(new Set<string>());
  useEffect(() => { seen.current = new Set(list.map(f => f.field)); }, [list]);

  const fieldOf = (k: string) => fields.find(f => f.key === k);
  const opsOf = (f?: FilterMenuField) => f?.operators ?? ['is', 'is not'];
  const optOf = (f: FilterMenuFilter) => fieldOf(f.field)?.options.find(o => o.value === f.value);
  const items = () => Array.from(pop.current?.querySelectorAll<HTMLElement>('[role="menu"]:not([hidden]) [role^="menuitem"]') ?? []);
  // the menu always opens on its list of fields
  useEffect(() => { if (!menu.open) setField(null); }, [menu.open]);
  // after opening, or moving between the two steps, the focus goes where the keys asked
  useEffect(() => {
    const to = focusTo.current; if (!menu.open || !to) return;
    focusTo.current = null;
    const all = items();
    const pick = to === 'last' ? all[all.length - 1] : to === 'value' ? (all.find(x => x.getAttribute('aria-checked') === 'true') ?? all[1]) : to === 'field' ? all.find(x => x.dataset.k === from.current) : all[0];
    (pick ?? all[0])?.focus();
  }); // eslint-disable-line react-hooks/exhaustive-deps

  const inside = () => !!pop.current?.contains(document.activeElement);
  const step = (k: string | null) => { if (inside()) focusTo.current = k ? 'value' : 'field'; from.current = field; setField(k); };
  const apply = (k: string, value: string) => {
    const had = inside();
    setList(list.some(f => f.field === k) ? list.map(f => (f.field === k ? { field: k, value } : f)) : [...list, { field: k, value }]);
    if (had) trigger.current?.focus();
    menu.closeSoon();
  };
  const remove = (k: string, e: MouseEvent<HTMLButtonElement>) => {
    const chip = e.currentTarget.closest('.dm-flm-ch'), next = (chip?.nextElementSibling ?? chip?.previousElementSibling)?.querySelector<HTMLElement>('.dm-flm-x');
    const had = chip?.contains(document.activeElement);
    setList(list.filter(f => f.field !== k));
    if (had) setTimeout(() => (next ?? trigger.current)?.focus());
  };
  const onTrigger = (e: MouseEvent) => { if (!menu.open && e.nativeEvent.isTrusted) focusTo.current = 'first'; menu.trigger.onClick(); };
  const onTriggerKey = (e: KeyboardEvent) => {
    if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
    e.preventDefault(); focusTo.current = e.key === 'ArrowUp' ? 'last' : 'first';
    if (menu.open) { const all = items(); (focusTo.current === 'last' ? all[all.length - 1] : all[0])?.focus(); focusTo.current = null; } else menu.setOpen(true);
  };
  // inside the menu: the arrow keys move and wrap, Home and End jump to the ends, the right arrow opens a field's values and
  // the left arrow goes back, Tab leaves it (Escape is the popover's)
  const onMenuKey = (e: KeyboardEvent) => {
    const all = items(), i = all.indexOf(document.activeElement as HTMLElement);
    const go = ({ ArrowDown: all[(i + 1) % all.length], ArrowUp: all[(i - 1 + all.length) % all.length], Home: all[0], End: all[all.length - 1] } as Record<string, HTMLElement | undefined>)[e.key];
    if (go) { e.preventDefault(); go.focus(); }
    else if (e.key === 'ArrowRight' && !field && all[i]?.dataset.k) { e.preventDefault(); step(all[i].dataset.k!); }
    else if (e.key === 'ArrowLeft' && field) { e.preventDefault(); step(null); }
    else if (e.key === 'Tab') setTimeout(() => menu.close());
  };

  const cur = field ? fieldOf(field) : undefined, on = list.find(f => f.field === field);
  return (
    <div data-dm="" ref={root} className={cx('dm-flm dm-panel', className)}>
      <div className="dm-flm-bar">
        <button {...menu.trigger} onClick={onTrigger} onKeyDown={onTriggerKey} type="button" className="dm-pill dm-line dm-sm dm-flm-tr"><Icon name="filter" />{label}</button>
        <div className="dm-flm-chips" role="list" aria-label="Filters">
          {list.map(f => {
            const fd = fieldOf(f.field), o = optOf(f); if (!fd || !o) return null;
            const ops = opsOf(fd), op = ops[+!!f.negate], other = ops[+!f.negate], text = `${fd.label} ${op} ${o.chip ?? o.label}`;
            return (
              <div key={f.field} className={cx('dm-flm-ch', seen.current.has(f.field) && 'dm-seen')} role="listitem">
                <span className="dm-k">{fd.label}</span>
                <Tip label={`Change to ${other}`}>
                  <button type="button" className="dm-op" aria-label={`${text}: change to ${other}`} onClick={() => setList(list.map(x => (x.field === f.field ? { ...x, negate: !x.negate } : x)))}>{op}</button>
                </Tip>
                <span className="dm-v">{o.chip ?? o.label}</span>
                <Tip label="Remove filter">
                  <button type="button" className="dm-x dm-flm-x" aria-label={`Remove filter ${text}`} onClick={e => remove(f.field, e)}><Icon name="x" size="xs" /></button>
                </Tip>
              </div>
            );
          })}
        </div>
      </div>
      <div {...menu.pop} className={cx(menu.pop.className, 'dm-flm-pop')} onKeyDown={onMenuKey}>
        <div className="dm-flm-lv" role="menu" aria-label={`${label} by`} hidden={!!field}>
          <div className="dm-ph" role="none">{label} by</div>
          {fields.map(fd => {
            const f = list.find(x => x.field === fd.key), o = f && optOf(f);
            return (
              <button key={fd.key} type="button" className="dm-mi" role="menuitem" aria-haspopup="menu" tabIndex={-1} data-k={fd.key} onClick={() => step(fd.key)}>
                {fd.icon && <Icon name={fd.icon} />}<span>{fd.label}</span>
                <span className="dm-r">{o ? (f.negate ? opsOf(fd)[1] + ' ' : '') + (o.chip ?? o.label) : ''}</span><Icon name="chev-r" size="xs" />
              </button>
            );
          })}
        </div>
        <div className="dm-flm-lv" role="menu" aria-label={cur?.label} hidden={!field}>
          {cur && <>
            <button type="button" className="dm-mi dm-flm-bk" role="menuitem" tabIndex={-1} aria-label="Back to all fields" onClick={() => step(null)}><Icon name="chev-l" size="xs" /><span>{cur.label}</span></button>
            <div className="dm-sep" role="separator" />
            {cur.options.map(o => (
              <button key={o.value} type="button" className="dm-mi" role="menuitemradio" aria-checked={on?.value === o.value} tabIndex={-1} onClick={() => apply(cur.key, o.value)}>
                {o.glyph}<span>{o.label}</span>{countFor && <span className="dm-r dm-num">{countFor(cur.key, o.value)}</span>}<span className="dm-ck"><Icon name="check" size="xs" /></span>
              </button>
            ))}
          </>}
        </div>
      </div>
      <div className="dm-flm-sum">
        <span aria-live="polite"><Swap className="dm-flm-ct dm-num">{list.length ? `${count} of ${total} ${noun}` : `${total} ${noun}`}</Swap></span>
        {list.length > 0 && <button type="button" className="dm-pill dm-ghost dm-sm dm-flm-clr" onClick={e => { const had = e.currentTarget === document.activeElement; setList([]); if (had) trigger.current?.focus(); }}>{clearLabel}</button>}
      </div>
      <div className="dm-flm-list">
        {count === 0 ? <div className="dm-flm-e">{emptyLabel ?? `No ${noun} match these filters`}</div> : <>
          {results.map((t, i) => (
            <div key={t.id} className="dm-flm-r dm-enter" style={{ animationDelay: i * 50 + 'ms' }}>
              {t.dot ? <span className="dm-dot" style={{ color: t.dot.color }} role="img" aria-label={t.dot.label} /> : <span />}
              <span className="dm-t">{t.title}</span>
              {t.avatar ? <span className="dm-av" style={{ background: t.avatar.color, color: avatarInk(t.avatar.color) }} role="img" aria-label={t.avatar.label}>{t.avatar.initials}</span> : <span />}
              <span className="dm-a">{t.meta}</span>
            </div>
          ))}
          {count > results.length && <div className="dm-flm-more">and {count - results.length} more</div>}
        </>}
      </div>
    </div>
  );
}
filter-menu.cssReact · 45 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Filter menu ---------- */
/* a list's filter bar: a Filter button that opens a two-step menu (a field, then its value), the filters as chips, and
   the issues that match under them */
.dm-flm{width:100%;max-width:440px;margin:0 auto;padding:var(--dm-s-3);display:flex;flex-direction:column;gap:var(--dm-s-2)}
.dm-flm-bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
.dm-flm-chips{display:contents}
.dm-flm-tr{padding:0 11px 0 9px}
.dm-flm-tr .dm-i{color:var(--dm-fg2)}
.dm-flm-tr[aria-expanded="true"]{background:var(--dm-chip)}
.dm-flm-ch{display:inline-flex;align-items:center;height:28px;padding-left:9px;border-radius:9px;background:var(--dm-chip);font-size:12.5px;white-space:nowrap;max-width:100%;animation:dm-popin .4s var(--dm-ease) backwards}
.dm-flm-ch.dm-seen{animation:none}
.dm-flm-ch .dm-k{color:var(--dm-fg2)}
.dm-flm-ch .dm-op{height:22px;padding:0 5px;margin:0 1px;border-radius:6px;color:var(--dm-muted);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-flm-ch .dm-op:hover{background:var(--dm-hover);color:var(--dm-fg)}}
.dm-flm-ch .dm-v{font-weight:500;overflow:hidden;text-overflow:ellipsis}
.dm-flm-ch .dm-x{width:26px;height:28px;display:grid;place-items:center;flex:none;color:var(--dm-muted);transition:color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-flm-ch .dm-x:hover{color:var(--dm-fg)}}
.dm-flm-ch.dm-gone{animation:dm-leave .3s var(--dm-ease) both}
.dm-flm-pop{width:232px}
.dm-flm-lv[hidden]{display:none}
.dm-flm-pop .dm-mi .dm-r{margin-left:auto}
.dm-flm-pop .dm-mi .dm-ck{margin-left:0}
.dm-flm-pop .dm-mi .dm-r+.dm-ck,.dm-flm-pop .dm-mi .dm-r+.dm-i{margin-left:0}
.dm-flm-pop .dm-mi>.dm-i:last-child{color:var(--dm-muted)}
.dm-flm-pop .dm-mi .dm-av{width:20px;height:20px;font-size:9px}
.dm-flm-pop .dm-mi .dm-dot{width:8px;height:8px;margin:0 3px}
.dm-flm-bk{color:var(--dm-fg2)}
.dm-flm-sum{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-2);min-height:36px;margin:2px -12px 0;padding:6px var(--dm-s-2) 0 var(--dm-s-4);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13px;color:var(--dm-fg2)}
.dm-flm-list{display:flex;flex-direction:column;margin:0 -4px;min-height:164px}
.dm-flm-r{display:grid;grid-template-columns:auto minmax(0,1fr) auto 44px;align-items:center;gap:10px;padding:var(--dm-s-2);border-radius:10px;font-size:13px;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-flm-r:hover{background:var(--dm-row)}}
.dm-flm-r .dm-dot{width:8px;height:8px}
.dm-flm-r .dm-t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-flm-r .dm-av{width:20px;height:20px;font-size:9px}
.dm-flm-r .dm-a{font-size:12px;color:var(--dm-muted);text-align:right}
.dm-flm-more{padding:6px var(--dm-s-2) 0;font-size:12.5px;color:var(--dm-muted)}
.dm-flm-e{padding:28px var(--dm-s-2);text-align:center;font-size:13px;color:var(--dm-muted)}
.dm-flm .dm-dot[data-s="open"]{color:var(--dm-flm-open)}.dm-flm .dm-dot[data-s="prog"]{color:var(--dm-flm-prog)}.dm-flm .dm-dot[data-s="done"]{color:var(--dm-flm-done)}
/* the status dots are vivid marks, 3:1 on the surface in every style and theme */
.dm-flm{--dm-flm-open:#2f8cff;--dm-flm-prog:#cd7a00;--dm-flm-done:#00a44b}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .dm-flm{--dm-flm-open:#5b9dff;--dm-flm-prog:#f59e0b;--dm-flm-done:#22c55e}}
:root[data-theme="dark"] .dm-flm{--dm-flm-open:#5b9dff;--dm-flm-prog:#f59e0b;--dm-flm-done:#22c55e}
.dm-flm .dm-av[data-o="MK"]{background:var(--dm-av-l);color:var(--dm-av-l-ink)}.dm-flm .dm-av[data-o="LP"]{background:var(--dm-av-b);color:var(--dm-av-b-ink)}.dm-flm .dm-av[data-o="AR"]{background:var(--dm-av-m);color:var(--dm-av-m-ink)}.dm-flm .dm-av[data-o="SC"]{background:var(--dm-av-p);color:var(--dm-av-p-ink)}
filter-menu.htmlHTML · 17 lines
<div class="card" data-c="filter-menu">
  <div class="stage-in">
    <div class="flm panel">
      <div class="flm-bar">
        <button class="pill line sm flm-tr" aria-haspopup="menu" aria-expanded="false" data-pop-pos="down"><svg class="i s"><use href="#i-filter"/></svg>Filter</button>
        <div class="flm-chips" role="list" aria-label="Filters"></div>
      </div>
      <div class="pop flm-pop">
        <div class="flm-lv flm-l1" role="menu" aria-label="Filter by"><div class="ph" role="none">Filter by</div></div>
        <div class="flm-lv flm-l2" role="menu" hidden></div>
      </div>
      <div class="flm-sum"><span class="flm-ct num" aria-live="polite"></span><button class="pill ghost sm flm-clr" hidden>Clear all</button></div>
      <div class="flm-list"></div>
    </div>
  </div>
</div>
filter-menu.cssHTML · 44 lines
/* ---------- Filter menu ---------- */
/* a list's filter bar: a Filter button that opens a two-step menu (a field, then its value), the filters as chips, and
   the issues that match under them */
.flm{width:100%;max-width:440px;margin:0 auto;padding:var(--s-3);display:flex;flex-direction:column;gap:var(--s-2)}
.flm-bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
.flm-chips{display:contents}
.flm-tr{padding:0 11px 0 9px}
.flm-tr .i{color:var(--fg2)}
.flm-tr[aria-expanded="true"]{background:var(--chip)}
.flm-ch{display:inline-flex;align-items:center;height:28px;padding-left:9px;border-radius:9px;background:var(--chip);font-size:12.5px;white-space:nowrap;max-width:100%;animation:popin .4s var(--ease) backwards}
.flm-ch.seen{animation:none}
.flm-ch .k{color:var(--fg2)}
.flm-ch .op{height:22px;padding:0 5px;margin:0 1px;border-radius:6px;color:var(--muted);transition:background var(--t-fast),color var(--t-fast)}
@media (hover:hover) and (pointer:fine){.flm-ch .op:hover{background:var(--hover);color:var(--fg)}}
.flm-ch .v{font-weight:500;overflow:hidden;text-overflow:ellipsis}
.flm-ch .x{width:26px;height:28px;display:grid;place-items:center;flex:none;color:var(--muted);transition:color var(--t-fast)}
@media (hover:hover) and (pointer:fine){.flm-ch .x:hover{color:var(--fg)}}
.flm-ch.gone{animation:leave .3s var(--ease) both}
.flm-pop{width:232px}
.flm-lv[hidden]{display:none}
.flm-pop .mi .r{margin-left:auto}
.flm-pop .mi .ck{margin-left:0}
.flm-pop .mi .r+.ck,.flm-pop .mi .r+.i{margin-left:0}
.flm-pop .mi>.i:last-child{color:var(--muted)}
.flm-pop .mi .av{width:20px;height:20px;font-size:9px}
.flm-pop .mi .dot{width:8px;height:8px;margin:0 3px}
.flm-bk{color:var(--fg2)}
.flm-sum{display:flex;align-items:center;justify-content:space-between;gap:var(--s-2);min-height:36px;margin:2px -12px 0;padding:6px var(--s-2) 0 var(--s-4);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:13px;color:var(--fg2)}
.flm-list{display:flex;flex-direction:column;margin:0 -4px;min-height:164px}
.flm-r{display:grid;grid-template-columns:auto minmax(0,1fr) auto 44px;align-items:center;gap:10px;padding:var(--s-2);border-radius:10px;font-size:13px;transition:background var(--t-fast)}
@media (hover:hover) and (pointer:fine){.flm-r:hover{background:var(--row)}}
.flm-r .dot{width:8px;height:8px}
.flm-r .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.flm-r .av{width:20px;height:20px;font-size:9px}
.flm-r .a{font-size:12px;color:var(--muted);text-align:right}
.flm-more{padding:6px var(--s-2) 0;font-size:12.5px;color:var(--muted)}
.flm-e{padding:28px var(--s-2);text-align:center;font-size:13px;color:var(--muted)}
.flm .dot[data-s="open"]{color:var(--flm-open)}.flm .dot[data-s="prog"]{color:var(--flm-prog)}.flm .dot[data-s="done"]{color:var(--flm-done)}
/* the status dots are vivid marks, 3:1 on the surface in every style and theme */
.flm{--flm-open:#2f8cff;--flm-prog:#cd7a00;--flm-done:#00a44b}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .flm{--flm-open:#5b9dff;--flm-prog:#f59e0b;--flm-done:#22c55e}}
:root[data-theme="dark"] .flm{--flm-open:#5b9dff;--flm-prog:#f59e0b;--flm-done:#22c55e}
.flm .av[data-o="MK"]{background:var(--av-l);color:var(--av-l-ink)}.flm .av[data-o="LP"]{background:var(--av-b);color:var(--av-b-ink)}.flm .av[data-o="AR"]{background:var(--av-m);color:var(--av-m-ink)}.flm .av[data-o="SC"]{background:var(--av-p);color:var(--av-p-ink)}
filter-menu.jsHTML · 125 lines
DM.def('filter-menu', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{filter}', I('filter'));
  const OWN = { MK:'Maya Kim', LP:'Leo Park', AR:'Ana Ruiz', SC:'Sam Cole' };
  const ST = { open:'Open', prog:'In progress', done:'Done' };
  // the issues: title, status, owner and days since the last update, most recent first
  const T = [
    ['Pricing toggle hides the yearly saving', 'open', 'MK', 0], ['Plan cards wrap at 768px', 'open', 'MK', 1], ['Checkout button loses its focus ring', 'open', 'MK', 3],
    ['Invoice email shows the wrong currency', 'open', 'MK', 9], ['Seat slider skips 25', 'open', 'LP', 0], ['Coupon field accepts expired codes', 'open', 'LP', 2],
    ['Dark logo has a white edge', 'open', 'AR', 1], ['FAQ accordion jumps on open', 'open', 'AR', 5], ['Trial banner covers the nav', 'open', 'SC', 0],
    ['EU tax line rounds down', 'open', 'SC', 12], ['Annual plan link goes to a 404', 'open', 'LP', 20], ['Move pricing copy into the CMS', 'prog', 'MK', 0],
    ['Comparison table for Team', 'prog', 'MK', 2], ['Prices in yen for Japan', 'prog', 'LP', 4], ['New hero illustration', 'prog', 'AR', 1],
    ['Track plan clicks', 'prog', 'SC', 6], ['Enterprise contact form', 'prog', 'MK', 15], ['Seat limits on the Free plan', 'prog', 'AR', 3],
    ['Ship the new plan names', 'done', 'MK', 1], ['Price flicker on load', 'done', 'MK', 8], ['Remove the Starter plan', 'done', 'LP', 3],
    ['Badge for the yearly discount', 'done', 'AR', 2], ['Retry failed Stripe webhooks', 'done', 'SC', 25], ['Refund policy link', 'done', 'SC', 10]
  ].map(([t, s, o, a]) => ({ t, s, o, a })).sort((x, y) => x.a - y.a);
  // the fields a filter can use, the two ways each one reads, and its values. A value's c is how a chip says it
  const F = {
    status:{ l:'Status', ic:'target', ops:['is', 'is not'], o:Object.entries(ST).map(([v, l]) => ({ v, l, g:`<span class="dot" data-s="${v}"></span>` })), test:(t, v) => t.s === v },
    owner:{ l:'Owner', ic:'user', ops:['is', 'is not'], o:Object.entries(OWN).map(([v, l]) => ({ v, l, g:`<span class="av" data-o="${v}">${v}</span>` })), test:(t, v) => t.o === v },
    date:{ l:'Updated', ic:'cal', ops:['within', 'older than'], o:[{ v:1, l:'Past day', c:'1 day' }, { v:7, l:'Past 7 days', c:'7 days' }, { v:30, l:'Past 30 days', c:'30 days' }], test:(t, v) => t.a < v }
  };
  const tr = $('.flm-tr', r), pop = $('.flm-pop', r), l1 = $('.flm-l1', r), l2 = $('.flm-l2', r), chips = $('.flm-chips', r), ct = $('.flm-ct', r), clr = $('.flm-clr', r), list = $('.flm-list', r);
  // the filters in the order they were added: field, value, and whether it reads the other way (is not, older than)
  let A = [];
  const val = (k, v) => F[k].o.find(o => o.v === v);
  const match = (t, skip) => A.every(f => f.k === skip || F[f.k].test(t, f.v) !== f.neg);
  const age = a => a ? a + 'd' : 'Today';
  l1.insertAdjacentHTML('beforeend', Object.entries(F).map(([k, f]) => `<button class="mi" role="menuitem" aria-haspopup="menu" tabindex="-1" data-k="${k}">${I(f.ic)}<span>${f.l}</span><span class="r"></span>${I('chev-r','xs')}</button>`).join(''));
  const fields = $$('.mi', l1);
  function paint(){
    // chips: one per field, a new one pops in and the ones already there keep still
    const have = new Set(A.map(f => f.k));
    $$('.flm-ch', chips).forEach(ch => { if (!have.has(ch.dataset.k)) ch.remove(); });
    A.forEach(f => {
      let ch = $(`.flm-ch[data-k="${f.k}"]`, chips);
      if (!ch){ ch = html(`<div class="flm-ch" role="listitem" data-k="${f.k}"><span class="k">${F[f.k].l}</span><button class="op"></button><span class="v"></span><button class="x" data-tip="Remove filter">${I('x','xs')}</button></div>`); chips.appendChild(ch); wire(ch); }
      else ch.classList.add('seen');
      const o = val(f.k, f.v), op = F[f.k].ops[+f.neg], text = `${F[f.k].l} ${op} ${o.c || o.l}`;
      $('.op', ch).textContent = op; $('.v', ch).textContent = o.c || o.l;
      $('.op', ch).setAttribute('aria-label', `${text}: change to ${F[f.k].ops[+!f.neg]}`); $('.op', ch).dataset.tip = `Change to ${F[f.k].ops[+!f.neg]}`;
      $('.x', ch).setAttribute('aria-label', `Remove filter ${text}`);
    });
    // the menu shows each field's value, if it has one
    fields.forEach(m => { const f = A.find(x => x.k === m.dataset.k), o = f && val(f.k, f.v); $('.r', m).textContent = o ? (f.neg ? F[f.k].ops[1] + ' ' : '') + (o.c || o.l) : ''; });
    const hit = T.filter(t => match(t)), n = hit.length;
    swap(ct, A.length ? `${n} of ${T.length} issues` : `${T.length} issues`); clr.hidden = !A.length;
    // the four most recently updated, and how many more
    const old = new Set($$('.flm-r', list).map(x => x.dataset.t));
    list.innerHTML = n ? hit.slice(0, 4).map(t => `<div class="flm-r" data-t="${t.t}"><span class="dot" data-s="${t.s}" aria-label="${ST[t.s]}" role="img"></span><span class="t">${t.t}</span><span class="av" data-o="${t.o}" aria-label="${OWN[t.o]}" role="img">${t.o}</span><span class="a">${age(t.a)}</span></div>`).join('') + (n > 4 ? `<div class="flm-more">and ${n - 4} more</div>` : '')
      : '<div class="flm-e">No issues match these filters</div>';
    $$('.flm-r', list).forEach((x, i) => { if (!old.has(x.dataset.t)) enter(x, i * 50); });
  }
  function wire(ch){
    const k = ch.dataset.k;
    $('.op', ch).onclick = () => { const f = A.find(x => x.k === k); f.neg = !f.neg; paint(); };
    $('.x', ch).onclick = () => {
      const had = ch.contains(document.activeElement), next = ch.nextElementSibling || ch.previousElementSibling;
      A = A.filter(x => x.k !== k); hideTip(); paint();
      if (had) ((next && $('.x', next)) || tr).focus({ preventScroll:true });
    };
  }
  clr.onclick = () => { const had = clr === document.activeElement; A = []; paint(); if (had) tr.focus({ preventScroll:true }); };
  // the menu has two steps: the fields, then the values of the one picked. Each value says how many issues it would leave.
  // It opens and closes on its own rather than through the page's shared popover, so a story playing in the card beside it
  // can't close it. Closing it while the focus is inside gives the focus back to the Filter button
  let field = null;
  const items = () => $$('.mi', field ? l2 : l1), isOpen = () => pop.classList.contains('open');
  // the focus is the one highlight: a row the demo's pointer left lit goes dark
  const focusItem = m => { if (!m) return; $$('.mi', pop).forEach(x => { x.tabIndex = x === m ? 0 : -1; x.classList.remove('hl'); }); m.focus({ preventScroll:true }); };
  function show(on, focus){
    if (on && !isOpen()){ step(null); hideTip(); placePop(tr, pop); }
    pop.classList.toggle('open', on); pop.inert = !on; tr.setAttribute('aria-expanded', on);
    if (on && focus){ const all = items(); focusItem(focus === 'last' ? all[all.length - 1] : all[0]); }
    if (!on && pop.contains(document.activeElement)) tr.focus({ preventScroll:true });
  }
  function step(k){
    const had = pop.contains(document.activeElement), from = field;
    field = k; l1.hidden = !!k; l2.hidden = !k;
    if (k){
      const f = F[k], on = A.find(x => x.k === k);
      l2.setAttribute('aria-label', f.l);
      l2.innerHTML = `<button class="mi flm-bk" role="menuitem" tabindex="-1" aria-label="Back to all fields">${I('chev-l','xs')}<span>${f.l}</span></button><div class="sep" role="separator"></div>`
        + f.o.map(o => `<button class="mi" role="menuitemradio" aria-checked="${!!on && on.v === o.v}" tabindex="-1" data-v="${o.v}">${o.g || ''}<span>${o.l}</span><span class="r num">${T.filter(t => match(t, k) && f.test(t, o.v)).length}</span><span class="ck">${I('check','xs')}</span></button>`).join('');
      $('.flm-bk', l2).onclick = () => step(null);
      $$('.mi[data-v]', l2).forEach(m => m.onclick = () => {
        const v = k === 'date' ? +m.dataset.v : m.dataset.v, f = A.find(x => x.k === k);
        if (f){ f.v = v; f.neg = false; } else A.push({ k, v, neg:false });
        $$('.mi[data-v]', l2).forEach(x => x.setAttribute('aria-checked', x === m)); paint();
        // the pick shows for a moment before the menu fades
        c.later(120, () => show(false));
      });
    }
    if (had) focusItem(k ? ($('.mi[aria-checked="true"]', l2) || $$('.mi', l2)[1]) : $(`.mi[data-k="${from}"]`, l1) || fields[0]);
  }
  fields.forEach(m => m.onclick = () => step(m.dataset.k));
  // a click, Enter, Space or the down arrow opens it with the focus on the first field, the up arrow on the last.
  // A story's tap opens it without moving the focus
  c.on(tr, 'click', e => show(!isOpen(), e.isTrusted && 'first'));
  c.on(tr, 'keydown', e => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){ e.preventDefault(); show(true, e.key === 'ArrowUp' ? 'last' : 'first'); } });
  c.on(document, 'pointerdown', e => { if (isOpen() && !pop.contains(e.target) && !tr.contains(e.target)) show(false); });
  c.on(window, 'resize', e => { if (e.isTrusted) show(false); });
  // inside the menu: the arrow keys move and wrap, Home and End jump to the ends, the right arrow opens a field's values
  // and the left arrow goes back, Escape closes it and Tab leaves it
  c.on(pop, 'keydown', e => {
    const all = items(), i = all.indexOf(document.activeElement);
    const go = { ArrowDown:all[(i + 1) % all.length], ArrowUp:all[(i - 1 + all.length) % all.length], Home:all[0], End:all[all.length - 1] }[e.key];
    if (go){ e.preventDefault(); focusItem(go); }
    else if (e.key === 'ArrowRight' && !field && all[i]){ e.preventDefault(); step(all[i].dataset.k); }
    else if (e.key === 'ArrowLeft' && field){ e.preventDefault(); step(null); }
    else if (e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); show(false); }
    else if (e.key === 'Tab') setTimeout(() => show(false));
  });
  show(false); paint();
  // the preview's story: filter to open issues, then to Maya's, and watch the count drop; then take the status filter off
  c.story(async s => {
    await s.beat(3); await s.tap(tr); await s.beat(2);
    await s.tap($('.mi[data-k="status"]', l1)); await s.beat(2);
    await s.tap($('.mi[data-v="open"]', l2)); s.away(); await s.beat(4);
    await s.tap(tr); await s.beat(2);
    await s.tap($('.mi[data-k="owner"]', l1)); await s.beat(2);
    await s.tap($('.mi[data-v="MK"]', l2)); s.away(); await s.beat(4);
    await s.tap($('.flm-ch[data-k="status"] .x', chips)); s.away(); await s.beat(4);
  });
});
06

Questions

What people ask about it.

Does it filter my data?
No. It keeps the filters and calls onFiltersChange. Filter your own data with them, and pass back count, total and the first few results.
Can a field take several values, like Status is Open or In progress?
Not as built: each field holds one value, and a new pick replaces it. For several, keep a list of values per field in your own state and show it in the chip's chip text.
Can I add my own fields?
Yes. Each field in fields has a key, a label, an optional icon, its two operators, and its options, each with an optional glyph like a dot or an avatar.
07

More in Collaboration

Threads, inboxes and boards for teams working alongside an agent.

CommentsA design thread where you can @mention the agent and it replies.
NotificationsAn inbox where agent updates and mentions arrive live.
BannerAn announcement on a tint of its tone, with its own line drawing.
Invite to collaborateAdd teammates by email as chips, set their role, send.
ReactionsQuick reactions on an agent update, with counts that pop.
Profile menuThe account menu behind an avatar: switch workspace, theme, shortcuts and sign out.
Context switcherThe organisation, project and environment you are in, each part a searchable switcher.
Ticket listSupport tickets the agent has triaged, with suggested replies.
All 10 in Collaboration →