{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-facet-mesh",
  "title": "Filter Facet Mesh",
  "description": "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.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/filter-facet-mesh/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\n\n// Faceted filter chips as a sieve. A hairline mesh fans from a hub above the\n// chip row down to every chip; active facets read as taut, bright threads,\n// inactive ones as faint background lattice. Hovering a chip tightens its\n// own thread (sag reduces toward a straight line via a CSS transition on the\n// SVG path's `d` — same-topology path transitions animate natively in\n// Chromium, no rAF needed for a discrete hover response). Deselecting a\n// facet widens the results and shakes a few particles through the gap left\n// at that chip; the result count re-renders as a staggered dot bar, never a\n// tweened number. Chips are real toggle buttons (aria-pressed, visible\n// accessible names); the count changes announce through an sr-only region.\n\ntype Facet = { key: string; label: string; factor: number };\n\nconst FACETS: Facet[] = [\n  { key: \"in-stock\", label: \"In stock\", factor: 0.72 },\n  { key: \"new\", label: \"New\", factor: 0.42 },\n  { key: \"on-sale\", label: \"On sale\", factor: 0.58 },\n  { key: \"bestseller\", label: \"Bestseller\", factor: 0.34 },\n];\n\nconst POOL = 240;\nconst DOT_MAX = 16;\nconst HUB_HEIGHT = 40;\n\ntype Particle = { id: number; x: number };\nlet seq = 0;\n\nexport function SieveFacets({\n  defaultActive = [\"in-stock\", \"bestseller\"],\n  className = \"\",\n}: {\n  /** facet ids checked at mount */\n  defaultActive?: string[];\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const [active, setActive] = useState<Set<string>>(() => new Set(defaultActive));\n  const [hovered, setHovered] = useState<string | null>(null);\n  const [particles, setParticles] = useState<Particle[]>([]);\n  const [anchors, setAnchors] = useState<Record<string, number>>({});\n  const [rowWidth, setRowWidth] = useState(0);\n  const rowRef = useRef<HTMLDivElement>(null);\n  const chipRefs = useRef<Record<string, HTMLButtonElement | null>>({});\n  const liveRef = useRef<HTMLDivElement>(null);\n  const reducedRef = useRef(false);\n\n  useEffect(() => {\n    reducedRef.current = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  }, []);\n\n  const measure = useCallback(() => {\n    const row = rowRef.current;\n    if (!row) return;\n    const rowRect = row.getBoundingClientRect();\n    const next: Record<string, number> = {};\n    for (const f of FACETS) {\n      const el = chipRefs.current[f.key];\n      if (el) {\n        const r = el.getBoundingClientRect();\n        next[f.key] = r.left + r.width / 2 - rowRect.left;\n      }\n    }\n    setAnchors(next);\n    setRowWidth(rowRect.width);\n  }, []);\n\n  useLayoutEffect(() => {\n    measure();\n    const row = rowRef.current;\n    const ro = row ? new ResizeObserver(measure) : undefined;\n    if (row && ro) ro.observe(row);\n    window.addEventListener(\"resize\", measure);\n    return () => {\n      ro?.disconnect();\n      window.removeEventListener(\"resize\", measure);\n    };\n  }, [measure]);\n\n  const count = useMemo(() => {\n    let c = POOL;\n    for (const f of FACETS) if (active.has(f.key)) c *= f.factor;\n    return Math.max(1, Math.round(c));\n  }, [active]);\n\n  const filledDots = Math.min(DOT_MAX, Math.max(0, Math.round((count / POOL) * DOT_MAX)));\n\n  const toggle = (key: string) => {\n    const wasActive = active.has(key);\n    const next = new Set(active);\n    if (wasActive) next.delete(key);\n    else next.add(key);\n    setActive(next);\n\n    if (wasActive) {\n      // excluded results shake back through the gap this facet leaves\n      const x = anchors[key] ?? rowWidth / 2;\n      const spawned: Particle[] = reducedRef.current\n        ? []\n        : Array.from({ length: 4 }, () => ({ id: ++seq, x: x + (Math.random() - 0.5) * 24 }));\n      if (spawned.length) setParticles((p) => [...p, ...spawned]);\n    }\n\n    let nextCount = POOL;\n    for (const f of FACETS) if (next.has(f.key)) nextCount *= f.factor;\n    nextCount = Math.max(1, Math.round(nextCount));\n    if (liveRef.current) liveRef.current.textContent = `${nextCount} results`;\n  };\n\n  const removeParticle = (id: number) => setParticles((p) => p.filter((x) => x.id !== id));\n\n  const allClear = active.size === 0;\n\n  return (\n    <div className={`w-full max-w-md ${className}`}>\n      <style>{`\n        @keyframes nsui-sieve-fall {\n          0% { transform: translateY(0) scale(1); opacity: 0.9; }\n          100% { transform: translateY(56px) scale(0.5); opacity: 0; }\n        }\n        .nsui-sieve-line { transition: d 220ms ease-out, opacity 220ms ease-out, stroke-width 220ms ease-out; }\n        .nsui-sieve-mesh { transition: opacity 300ms ease-out, transform 300ms ease-out; }\n        .nsui-sieve-dot { transition: opacity 260ms ease-out, transform 260ms ease-out; }\n      `}</style>\n\n      {/* mesh: hairline threads from a hub down to each chip */}\n      <svg\n        aria-hidden=\"true\"\n        className={`nsui-sieve-mesh block w-full ${allClear ? \"scale-95 opacity-0\" : \"scale-100 opacity-100\"}`}\n        style={{ height: HUB_HEIGHT }}\n        viewBox={`0 0 ${Math.max(1, rowWidth)} ${HUB_HEIGHT}`}\n        preserveAspectRatio=\"none\"\n      >\n        <circle cx={rowWidth / 2} cy={4} r={2} className=\"fill-ns-muted\" />\n        {FACETS.map((f) => {\n          const x = anchors[f.key];\n          if (x === undefined) return null;\n          const isActive = active.has(f.key);\n          const isHover = hovered === f.key;\n          const hubX = rowWidth / 2;\n          const sag = isHover ? 3 : 13;\n          const midX = (hubX + x) / 2;\n          const midY = 4 + (HUB_HEIGHT - 4) * 0.5 + sag;\n          return (\n            <path\n              key={f.key}\n              className=\"nsui-sieve-line\"\n              d={`M ${hubX} 4 Q ${midX} ${midY} ${x} ${HUB_HEIGHT}`}\n              fill=\"none\"\n              stroke={isActive ? \"var(--foreground)\" : \"var(--border)\"}\n              strokeOpacity={isActive ? (isHover ? 0.85 : 0.55) : isHover ? 0.65 : 0.35}\n              strokeWidth={isActive ? (isHover ? 1.75 : 1.25) : 1}\n            />\n          );\n        })}\n      </svg>\n\n      {/* particle overlay */}\n      <div aria-hidden=\"true\" className=\"pointer-events-none relative h-0 overflow-visible\">\n        {particles.map((p) => (\n          <span\n            key={p.id}\n            onAnimationEnd={() => removeParticle(p.id)}\n            className=\"absolute top-0 h-1.5 w-1.5 rounded-full bg-foreground/70\"\n            style={{ left: p.x, animation: \"nsui-sieve-fall 520ms ease-in forwards\" }}\n          />\n        ))}\n      </div>\n\n      <div ref={rowRef} className=\"mt-2 flex flex-wrap justify-center gap-2\">\n        {FACETS.map((f) => {\n          const isActive = active.has(f.key);\n          return (\n            <button\n              key={f.key}\n              ref={(el) => {\n                chipRefs.current[f.key] = el;\n              }}\n              type=\"button\"\n              aria-pressed={isActive}\n              onClick={() => toggle(f.key)}\n              onMouseEnter={() => setHovered(f.key)}\n              onMouseLeave={() => setHovered((h) => (h === f.key ? null : h))}\n              onFocus={() => setHovered(f.key)}\n              onBlur={() => setHovered((h) => (h === f.key ? null : h))}\n              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-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background ${\n                isActive\n                  ? \"border-foreground/50 bg-foreground/[0.08] text-foreground\"\n                  : \"border-border text-ns-muted hover:border-foreground/30 hover:text-foreground\"\n              }`}\n            >\n              {f.label}\n            </button>\n          );\n        })}\n      </div>\n\n      <div className=\"mt-6 flex flex-col items-center gap-2\">\n        <div className=\"flex items-center gap-1\" aria-hidden=\"true\">\n          {Array.from({ length: DOT_MAX }, (_, i) => {\n            const filled = i < filledDots;\n            return (\n              <span\n                key={i}\n                className={`nsui-sieve-dot h-1.5 w-1.5 rounded-full ${filled ? \"scale-100 bg-foreground opacity-90\" : \"scale-75 bg-ns-muted opacity-30\"}`}\n                style={reducedRef.current ? undefined : { transitionDelay: `${i * 22}ms` }}\n              />\n            );\n          })}\n        </div>\n        <p className=\"font-mono text-xs text-ns-muted\">{count} results</p>\n      </div>\n\n      <div ref={liveRef} aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/filter-facet-mesh.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "filter",
      "facets",
      "chips",
      "toggle",
      "mesh",
      "set-membership",
      "svg"
    ],
    "instruction": "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."
  },
  "type": "registry:ui"
}