Skip to main content

ns-ui

Filter Facet Mesh

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.

Usage

registry/core/filter-facet-mesh/demo.tsx
"use client";

import { useEffect, useRef } from "react";
import { SieveFacets } from "./component";

// Self-driving: the facets are real uncontrolled toggle buttons, so the
// script dispatches real clicks on the rendered chips on a timer — toggling
// one facet off (opens a gap, particles sift through, count widens) then
// back on, with no pointer or keyboard input required to see every state.
const SCRIPT = [
  { index: 1, delay: 1000 },
  { index: 1, delay: 3000 },
];

export default function SieveFacetsDemo() {
  const wrapRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const timers = SCRIPT.map(({ index, delay }) =>
      setTimeout(() => {
        const chips = wrapRef.current?.querySelectorAll<HTMLButtonElement>("button[aria-pressed]");
        chips?.[index]?.click();
      }, delay)
    );
    return () => timers.forEach(clearTimeout);
  }, []);

  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-8 bg-background px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / filter-facet-mesh
      </p>
      <div ref={wrapRef} className="w-full max-w-md">
        <SieveFacets />
      </div>
      <p className="max-w-xs text-center text-xs text-ns-muted">
        Toggle a facet — the mesh gains or loses a thread and the result
        count sifts to its new value.
      </p>
    </main>
  );
}

Component

registry/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 = "",
}: {
  /** facet ids checked at mount */
  defaultActive?: string[];
  /** extra classes merged onto the rendered root element */
  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-ns-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-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
                isActive
                  ? "border-foreground/50 bg-foreground/[0.08] text-foreground"
                  : "border-border text-ns-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-ns-muted opacity-30"}`}
                style={reducedRef.current ? undefined : { transitionDelay: `${i * 22}ms` }}
              />
            );
          })}
        </div>
        <p className="font-mono text-xs text-ns-muted">{count} results</p>
      </div>

      <div ref={liveRef} aria-live="polite" aria-atomic="true" className="sr-only" />
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/filter-facet-mesh.tsx, so point the import there.

Use when 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.

Install

npx shadcn add https://design.helpmarq.com/r/filter-facet-mesh.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

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.

Props

PropTypeDefaultDescription
defaultActive?string[]["in-stock", "bestseller"]facet ids checked at mount
className?string—extra classes merged onto the rendered root element