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.
↓Scroll to step through it
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.
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:
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.
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.
- 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
<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>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.
- 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
<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>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.
opacity + translateX / scale · shared spring · no width tween
<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>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:
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.
- ENG-241Token refresh races on slow networks
- ENG-225Mega menu panel height jump
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:
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
opacityPlus / summary / clear / popover fadescalePlus exit, clear pop-in, popover springtranslateXSummary slides in beside the facet label