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.
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.
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:
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.
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.
- 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.
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.
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, 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:
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.
- ENG-241Token refresh races on slow networks
- ENG-225Mega menu panel height jump
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:
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
opacityPlus / summary / clear / popover fadescalePlus exit, clear pop-in, popover springtranslateXSummary slides in beside the facet label