GodUIGodUI
129Follow on X

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.

Every facet pill is the same markup in one of two looks. Empty, it's a dashed ring with a plus glyph and a muted label, which reads as "add a filter". Active, the ring turns solid, 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, but that reflow is a single layout pass with no tween. The motion that shows 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 on the same spring) 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);
};

When a facet's list empties, the key is deleted instead of left as { status: [] }. That 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 goes in and a FilterValue record comes out. Every pill is a button and a popover, the summary slides in on the compositor, and a count check decides whether "Clear all" shows.

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 but doesn't slide or scale into place.

Motion Score

Filter BarCC: Paint-triggering
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 →