Faceted filter chips as a sieve — active facets read as taut, bright threads in a hairline mesh fanned from a hub above the chip row, hovering a chip visibly tightens its own thread, and deselecting one opens a gap that shakes a few result particles through as the count sifts to its new value.
npx shadcn add https://design.helpmarq.com /r/filter-facet-mesh.jsonregistry/core/filter-facet-mesh/component.tsx"use client";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
// Faceted filter chips as a sieve. A hairline mesh fans from a hub above the
// chip row down to every chip; active facets read as taut, bright threads,
// inactive ones as faint background lattice. Hovering a chip tightens its
// own thread (sag reduces toward a straight line via a CSS transition on the
// SVG path's `d` — same-topology path transitions animate natively in
// Chromium, no rAF needed for a discrete hover response). Deselecting a
// facet widens the results and shakes a few particles through the gap left
// at that chip; the result count re-renders as a staggered dot bar, never a
// tweened number. Chips are real toggle buttons (aria-pressed, visible
// accessible names); the count changes announce through an sr-only region.
type Facet = { key: string; label: string; factor: number };
const FACETS: Facet[] = [
{ key: "in-stock", label: "In stock", factor: 0.72 },
{ key: "new", label: "New", factor: 0.42 },
{ key: "on-sale", label: "On sale", factor: 0.58 },
{ key: "bestseller", label: "Bestseller", factor: 0.34 },
];
const POOL = 240;
const DOT_MAX = 16;
const HUB_HEIGHT = 40;
type Particle = { id: number; x: number };
let seq = 0;
export function SieveFacets({
defaultActive = ["in-stock", "bestseller"],
className = "",
}: {
defaultActive?: string[];
className?: string;
}) {
const [active, setActive] = useState<Set<string>>(() => new Set(defaultActive));
const [hovered, setHovered] = useState<string | null>(null);
const [particles, setParticles] = useState<Particle[]>([]);
const [anchors, setAnchors] = useState<Record<string, number>>({});
const [rowWidth, setRowWidth] = useState(0);
const rowRef = useRef<HTMLDivElement>(null);
const chipRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const liveRef = useRef<HTMLDivElement>(null);
const reducedRef = useRef(false);
useEffect(() => {
reducedRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}, []);
const measure = useCallback(() => {
const row = rowRef.current;
if (!row) return;
const rowRect = row.getBoundingClientRect();
const next: Record<string, number> = {};
for (const f of FACETS) {
const el = chipRefs.current[f.key];
if (el) {
const r = el.getBoundingClientRect();
next[f.key] = r.left + r.width / 2 - rowRect.left;
}
}
setAnchors(next);
setRowWidth(rowRect.width);
}, []);
useLayoutEffect(() => {
measure();
const row = rowRef.current;
const ro = row ? new ResizeObserver(measure) : undefined;
if (row && ro) ro.observe(row);
window.addEventListener("resize", measure);
return () => {
ro?.disconnect();
window.removeEventListener("resize", measure);
};
}, [measure]);
const count = useMemo(() => {
let c = POOL;
for (const f of FACETS) if (active.has(f.key)) c *= f.factor;
return Math.max(1, Math.round(c));
}, [active]);
const filledDots = Math.min(DOT_MAX, Math.max(0, Math.round((count / POOL) * DOT_MAX)));
const toggle = (key: string) => {
const wasActive = active.has(key);
const next = new Set(active);
if (wasActive) next.delete(key);
else next.add(key);
setActive(next);
if (wasActive) {
// excluded results shake back through the gap this facet leaves
const x = anchors[key] ?? rowWidth / 2;
const spawned: Particle[] = reducedRef.current
? []
: Array.from({ length: 4 }, () => ({ id: ++seq, x: x + (Math.random() - 0.5) * 24 }));
if (spawned.length) setParticles((p) => [...p, ...spawned]);
}
let nextCount = POOL;
for (const f of FACETS) if (next.has(f.key)) nextCount *= f.factor;
nextCount = Math.max(1, Math.round(nextCount));
if (liveRef.current) liveRef.current.textContent = `${nextCount} results`;
};
const removeParticle = (id: number) => setParticles((p) => p.filter((x) => x.id !== id));
const allClear = active.size === 0;
return (
<div className={`w-full max-w-md ${className}`}>
<style>{`
@keyframes nsui-sieve-fall {
0% { transform: translateY(0) scale(1); opacity: 0.9; }
100% { transform: translateY(56px) scale(0.5); opacity: 0; }
}
.nsui-sieve-line { transition: d 220ms ease-out, opacity 220ms ease-out, stroke-width 220ms ease-out; }
.nsui-sieve-mesh { transition: opacity 300ms ease-out, transform 300ms ease-out; }
.nsui-sieve-dot { transition: opacity 260ms ease-out, transform 260ms ease-out; }
`}</style>
{/* mesh: hairline threads from a hub down to each chip */}
<svg
aria-hidden="true"
className={`nsui-sieve-mesh block w-full ${allClear ? "scale-95 opacity-0" : "scale-100 opacity-100"}`}
style={{ height: HUB_HEIGHT }}
viewBox={`0 0 ${Math.max(1, rowWidth)} ${HUB_HEIGHT}`}
preserveAspectRatio="none"
>
<circle cx={rowWidth / 2} cy={4} r={2} className="fill-muted" />
{FACETS.map((f) => {
const x = anchors[f.key];
if (x === undefined) return null;
const isActive = active.has(f.key);
const isHover = hovered === f.key;
const hubX = rowWidth / 2;
const sag = isHover ? 3 : 13;
const midX = (hubX + x) / 2;
const midY = 4 + (HUB_HEIGHT - 4) * 0.5 + sag;
return (
<path
key={f.key}
className="nsui-sieve-line"
d={`M ${hubX} 4 Q ${midX} ${midY} ${x} ${HUB_HEIGHT}`}
fill="none"
stroke={isActive ? "var(--foreground)" : "var(--border)"}
strokeOpacity={isActive ? (isHover ? 0.85 : 0.55) : isHover ? 0.65 : 0.35}
strokeWidth={isActive ? (isHover ? 1.75 : 1.25) : 1}
/>
);
})}
</svg>
{/* particle overlay */}
<div aria-hidden="true" className="pointer-events-none relative h-0 overflow-visible">
{particles.map((p) => (
<span
key={p.id}
onAnimationEnd={() => removeParticle(p.id)}
className="absolute top-0 h-1.5 w-1.5 rounded-full bg-foreground/70"
style={{ left: p.x, animation: "nsui-sieve-fall 520ms ease-in forwards" }}
/>
))}
</div>
<div ref={rowRef} className="mt-2 flex flex-wrap justify-center gap-2">
{FACETS.map((f) => {
const isActive = active.has(f.key);
return (
<button
key={f.key}
ref={(el) => {
chipRefs.current[f.key] = el;
}}
type="button"
aria-pressed={isActive}
onClick={() => toggle(f.key)}
onMouseEnter={() => setHovered(f.key)}
onMouseLeave={() => setHovered((h) => (h === f.key ? null : h))}
onFocus={() => setHovered(f.key)}
onBlur={() => setHovered((h) => (h === f.key ? null : h))}
className={`rounded-full border px-3 py-1.5 font-mono text-xs outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
isActive
? "border-foreground/50 bg-foreground/[0.08] text-foreground"
: "border-border text-muted hover:border-foreground/30 hover:text-foreground"
}`}
>
{f.label}
</button>
);
})}
</div>
<div className="mt-6 flex flex-col items-center gap-2">
<div className="flex items-center gap-1" aria-hidden="true">
{Array.from({ length: DOT_MAX }, (_, i) => {
const filled = i < filledDots;
return (
<span
key={i}
className={`nsui-sieve-dot h-1.5 w-1.5 rounded-full ${filled ? "scale-100 bg-foreground opacity-90" : "scale-75 bg-muted opacity-30"}`}
style={reducedRef.current ? undefined : { transitionDelay: `${i * 22}ms` }}
/>
);
})}
</div>
<p className="font-mono text-xs text-muted">{count} results</p>
</div>
<div ref={liveRef} aria-live="polite" aria-atomic="true" className="sr-only" />
</div>
);
}
a faceted filter bar (product listings, search refinement) where the active facet set itself deserves a visible structure, not a plain row of pills; this is set-membership (which facets are ON), not an event counter — for tallying discrete events over time, use histogram-live-grain instead.
Build a faceted filter as a row of real toggle buttons (plain <button aria-pressed>, no role override needed — native button + aria-pressed is the correct toggle-button pattern) with an SVG hairline mesh above the row: a small hub dot centered above the chips, with one quadratic-bezier path per chip running from the hub down to that chip's top-center anchor (measured via getBoundingClientRect against the row's own rect in a useLayoutEffect + ResizeObserver, stored as plain x-offsets in state — this is a layout measurement done occasionally, not a per-frame simulation, so ordinary React state is correct here, not a rAF loop). Active facets render their thread bright (stroke var(--foreground), opacity ~0.55) and thicker; inactive facets render the same thread faint (stroke var(--border), opacity ~0.35) — the mesh is always structurally present, but only active facets read as the prominent net. Each path's control point sits below the midpoint of hub-and-chip by a 'sag' amount (~13px at rest); hovering (or focusing) a chip drops that specific path's sag to ~3px, and because the path's command structure (M...Q...) stays identical between states, transitioning the `d` attribute directly via CSS (`transition: d 220ms ease-out`) animates it as a native browser tween with zero JS on the hot path — this is what makes hovering visibly tighten the mesh line nearest the hovered chip. Clicking a chip toggles aria-pressed and recomputes a mocked result count (a fixed pool of 240 multiplied by each active facet's narrowing factor, rounded, minimum 1) rendered as a horizontal bar of up to 16 small dots — filled-vs-unfilled dot count reflecting the new total — where each dot's opacity/scale transition carries a staggered transition-delay keyed to its index (~22ms apart), so the count visibly sifts into its new shape dot-by-dot rather than a tweened number ever appearing anywhere. Deselecting a previously-active facet (widening the result set) additionally spawns 4 small aria-hidden particle dots at that chip's mesh-anchor x-position, each playing a one-shot CSS keyframe (translateY down ~56px, scale down, fade out over ~520ms) representing the newly-included results shaking through the gap the facet left in the sieve; removed from the DOM via onAnimationEnd, never a rAF loop. When zero facets are active, the whole mesh group fades and scales down slightly (an 'all-clear' dissolve) via a CSS transition on the SVG's own opacity/transform. An sr-only aria-live="polite" aria-atomic region announces "<count> results" on every toggle. Core restraint: hairline mesh only (var(--border)/var(--foreground)), monochrome particles and dots, zero color beyond the standard focus-visible ring (ring utility, not outline, paired with plain outline-none — never combine a base outline-none with a focus-visible:outline utility on the same element). Reduced motion: skip particle spawning entirely (deactivating a facet updates the count with no shake) and drop the dot bar's stagger (apply all dot opacity/scale transitions with zero transition-delay, a simultaneous crossfade instead of a sifting sequence) — the mesh hover-tighten and active/inactive thread brightness stay, since those are simple discrete state transitions, not continuous motion.