MynduiMyndui
0

Anatomy of the Filter Bar

How a row of facet pills stays a set of plain buttons and a popover — with a compositor-only summary slide-in, and a spring-in clear button.

01/03Two pill states
Empty facet
dashed ring + plus — click to open its options
Active facet
solid fill, inline summary, and a clear button
Clear all
appears once any facet holds a selection
tsx
<div className={hasSelection ? "border-foreground/15 bg-accent" : "border-dashed border-border"}><button onClick={() => setOpen(isOpen ? null : facet.id)}>  {!hasSelection && <motion.span>{PlusIcon}</motion.span>}  <span>{facet.label}</span>  {hasSelection && <motion.span>{summarize(facet, selected)}</motion.span>}</button>{hasSelection && <motion.button onClick={() => clearFacet(facet.id)}>{CloseIcon}</motion.button>}</div>
01

Two pill states

A filter bar reads like a form — checkboxes, a submit, a results count — but there's no <select multiple> and no schema anywhere in it. FilterBar takes a facets array and renders each one as a pill: a button that opens a popover of options, and, once you've picked something, an inline summary of what's selected. That's the entire vocabulary.

Every facet pill is the same markup wearing one of two looks. Empty, it's a dashed ring with a plus glyph and a muted label — an obvious "add a filter" affordance. Active, the ring solidifies, a summary of the selection appears inline, and a clear button shows up on its trailing edge:

summarize() collapses a multi-select down to "<first label> +<n-1>", so the pill never grows unbounded — pick five options in one facet and the label still reads as one short phrase.

02/03Presence, not width

opacity + translateX / scale · shared spring · no width tween

tsx
<motion.spaninitial={{ opacity: 0, x: -6 }}animate={{ opacity: 1, x: 0 }}exit={{ opacity: 0, x: -6 }}transition={{ type: "spring", stiffness: 520, damping: 32 }}><span className="h-3.5 w-px bg-border" /><span>{summarize(facet, selected)}</span></motion.span>
02

Presence, not width

The pill's width still changes when a selection appears — content is content — but that reflow is a single layout pass, not a tween. The motion that sells the change stays on the compositor: the plus scales out, the summary slides in on x + opacity, and the clear button pops on scale, all on the same spring (stiffness: 520 / damping: 32):

There's no width: 0 → "auto" and no Framer layout on the row. Animating width to fit unknown text reflows every sibling every frame; snapping the pill and animating only presence reads cleaner and stays GPU-composited.

Clicking a pill opens a role="listbox" popover — spring-scaled in from 0.96, y: -4, same spring again — with an optional search input and a list of checkbox-style options. Toggling one doesn't close the popover; multi-select facets stay open so you can keep picking:

tsx
 
const toggleOption = (facetId: string, optValue: string) => {
  const current = value[facetId] ?? [];
  const has = current.includes(optValue);
  const nextList = has ? current.filter((v) => v !== optValue) : [...current, optValue];
  const next = { ...value, [facetId]: nextList };
  if (nextList.length === 0) delete next[facetId];
  commit(next);
};

Deleting the key entirely when a facet's list empties out — rather than leaving { status: [] } around — is what makes activeCount and "Clear all" visibility a simple Object.values(value).length check downstream, with no empty-array edge case to filter out first.

03/03Result
Issues2 of 5
  • ENG-241Token refresh races on slow networks
  • ENG-225Mega menu panel height jump
03

The result

A facets array in, a FilterValue record out — every pill is a button and a popover, the summary slides in on the compositor, and "Clear all" is nothing more than a count check away from disappearing again.

Accessibility

Each pill button carries aria-haspopup="listbox" and aria-expanded; each option row is role="option" with aria-selected mirroring the checkbox fill. Two listeners — mousedown outside and keydown for Escape — close whichever popover is open, and both are bound only while open is non-null:

tsx
 
React.useEffect(() => {
  if (!open) return;
  const onDown = (e: MouseEvent) => {
    if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(null);
  };
  const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(null); };
  document.addEventListener("mousedown", onDown);
  document.addEventListener("keydown", onKey);
  return () => {
    document.removeEventListener("mousedown", onDown);
    document.removeEventListener("keydown", onKey);
  };
}, [open]);

useReducedMotion flattens every spring down to opacity-only — the summary still appears, it just doesn't slide or scale into place.

Motion Score

Filter BarSSCompositor-only
SopacityPlus / summary / clear / popover fade
SscalePlus exit, clear pop-in, popover spring
StranslateXSummary slides in beside the facet label
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →