Web InnoventixFreeCode

Search Filters Form

Original · free

faceted search filter panel

byWeb InnoventixReact + Tailwind
formsearchfiltersforms
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/form-search-filters.json
form-search-filters.tsx
"use client";

import { useId, useMemo, useState, type ChangeEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Product = {
  id: string;
  name: string;
  brand: string;
  category: string;
  price: number;
  rating: number;
  reviews: number;
  inStock: boolean;
  features: string[];
  tagline: string;
};

const PRODUCTS: Product[] = [
  { id: "kb-q1", name: "Keychron Q1 Pro", brand: "Keychron", category: "Keyboards", price: 199, rating: 4.7, reviews: 1284, inStock: true, features: ["Wireless", "Mechanical", "RGB Backlight", "USB-C"], tagline: "Gasket-mounted aluminum board with QMK/VIA remapping." },
  { id: "kb-mxk", name: "Logitech MX Keys S", brand: "Logitech", category: "Keyboards", price: 109, rating: 4.6, reviews: 3421, inStock: true, features: ["Wireless", "Bluetooth", "USB-C"], tagline: "Low-profile scissor keys with Smart Backlight and Flow." },
  { id: "kb-hv3", name: "Razer Huntsman V3", brand: "Razer", category: "Keyboards", price: 149, rating: 4.4, reviews: 876, inStock: true, features: ["Mechanical", "RGB Backlight", "USB-C"], tagline: "Analog optical switches with per-key actuation tuning." },
  { id: "kb-k2", name: "Keychron K2 HE", brand: "Keychron", category: "Keyboards", price: 129, rating: 4.5, reviews: 720, inStock: true, features: ["Wireless", "Mechanical", "RGB Backlight", "USB-C"], tagline: "Hall-effect switches with adjustable actuation depth." },
  { id: "ms-mx3", name: "Logitech MX Master 3S", brand: "Logitech", category: "Mice", price: 99, rating: 4.8, reviews: 5210, inStock: true, features: ["Wireless", "Bluetooth", "USB-C"], tagline: "8K DPI Darkfield sensor and quiet MagSpeed scrolling." },
  { id: "ms-bv3", name: "Razer Basilisk V3", brand: "Razer", category: "Mice", price: 69, rating: 4.5, reviews: 2044, inStock: true, features: ["RGB Backlight", "USB-C"], tagline: "Eleven programmable controls in a contoured chassis." },
  { id: "ms-lift", name: "Logitech Lift Vertical", brand: "Logitech", category: "Mice", price: 79, rating: 4.3, reviews: 1190, inStock: false, features: ["Wireless", "Bluetooth", "Adjustable"], tagline: "A 58-degree vertical angle that eases wrist pronation." },
  { id: "ms-anywhere", name: "Logitech MX Anywhere 3S", brand: "Logitech", category: "Mice", price: 79, rating: 4.6, reviews: 1340, inStock: true, features: ["Wireless", "Bluetooth", "USB-C"], tagline: "Compact 8K DPI mouse that even tracks on glass." },
  { id: "mn-u27", name: "Dell UltraSharp U2723QE", brand: "Dell", category: "Monitors", price: 379, rating: 4.7, reviews: 932, inStock: true, features: ["4K", "USB-C", "Adjustable"], tagline: "27-inch 4K IPS Black panel with a 90W USB-C hub." },
  { id: "mn-s27", name: "Dell S2725QC", brand: "Dell", category: "Monitors", price: 289, rating: 4.4, reviews: 410, inStock: true, features: ["4K", "USB-C"], tagline: "27-inch 4K panel with built-in USB-C and speakers." },
  { id: "hs-xm5", name: "Sony WH-1000XM5", brand: "Sony", category: "Headsets", price: 349, rating: 4.8, reviews: 8123, inStock: true, features: ["Wireless", "Bluetooth", "Noise Cancelling", "USB-C"], tagline: "Eight-mic adaptive noise cancelling, 30-hour battery." },
  { id: "hs-bs2", name: "Razer BlackShark V2 Pro", brand: "Razer", category: "Headsets", price: 179, rating: 4.5, reviews: 1502, inStock: true, features: ["Wireless", "Noise Cancelling"], tagline: "THX spatial audio with a detachable HyperClear mic." },
  { id: "hs-zv", name: "Logitech Zone Vibe 100", brand: "Logitech", category: "Headsets", price: 99, rating: 4.2, reviews: 640, inStock: false, features: ["Wireless", "Bluetooth"], tagline: "Open-ear comfort tuned for all-day video calls." },
  { id: "hs-h9", name: "Sony INZONE H9", brand: "Sony", category: "Headsets", price: 269, rating: 4.3, reviews: 388, inStock: false, features: ["Wireless", "Noise Cancelling"], tagline: "360 Spatial Sound for Gaming with a flip-up mic." },
  { id: "wc-fc2", name: "Elgato Facecam MK.2", brand: "Elgato", category: "Webcams", price: 149, rating: 4.6, reviews: 512, inStock: true, features: ["4K", "USB-C"], tagline: "Sony STARVIS 4K sensor with onboard image memory." },
  { id: "wc-brio", name: "Logitech Brio 500", brand: "Logitech", category: "Webcams", price: 129, rating: 4.4, reviews: 1780, inStock: true, features: ["USB-C", "Adjustable"], tagline: "Auto-framing 4K webcam with RightLight 5 in low light." },
  { id: "dk-ts4", name: "CalDigit TS4", brand: "CalDigit", category: "Docks", price: 399, rating: 4.7, reviews: 2210, inStock: true, features: ["USB-C", "Adjustable"], tagline: "18-port Thunderbolt 4 dock with 98W charging." },
  { id: "dk-wd22", name: "Dell WD22TB4", brand: "Dell", category: "Docks", price: 259, rating: 4.3, reviews: 305, inStock: false, features: ["USB-C"], tagline: "Dual Thunderbolt 4 dock for hybrid desk setups." },
];

const CATEGORIES = ["Keyboards", "Mice", "Monitors", "Headsets", "Webcams", "Docks"];
const BRANDS = Array.from(new Set(PRODUCTS.map((p) => p.brand))).sort();
const FEATURES = Array.from(new Set(PRODUCTS.flatMap((p) => p.features))).sort();

const PRICE_MIN = 0;
const PRICE_MAX = 400;
const PRICE_STEP = 5;

const RATING_OPTIONS = [
  { value: 4.7, label: "4.7 & up" },
  { value: 4.5, label: "4.5 & up" },
  { value: 4.0, label: "4.0 & up" },
];

type SortKey = "relevance" | "price-asc" | "price-desc" | "rating" | "name";

const SORT_OPTIONS: { value: SortKey; label: string }[] = [
  { value: "relevance", label: "Most relevant" },
  { value: "price-asc", label: "Price: low to high" },
  { value: "price-desc", label: "Price: high to low" },
  { value: "rating", label: "Top rated" },
  { value: "name", label: "Name: A to Z" },
];

type Filters = {
  query: string;
  categories: string[];
  brands: string[];
  features: string[];
  minPrice: number;
  maxPrice: number;
  minRating: number;
  inStockOnly: boolean;
};

const DEFAULT_FILTERS: Filters = {
  query: "",
  categories: [],
  brands: [],
  features: [],
  minPrice: PRICE_MIN,
  maxPrice: PRICE_MAX,
  minRating: 0,
  inStockOnly: false,
};

function toggle(list: string[], value: string): string[] {
  return list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
}

function productMatches(p: Product, f: Filters, ignore?: string): boolean {
  if (ignore !== "query" && f.query.trim()) {
    const q = f.query.trim().toLowerCase();
    if (!p.name.toLowerCase().includes(q) && !p.brand.toLowerCase().includes(q)) return false;
  }
  if (ignore !== "categories" && f.categories.length && !f.categories.includes(p.category)) return false;
  if (ignore !== "brands" && f.brands.length && !f.brands.includes(p.brand)) return false;
  if (ignore !== "features" && f.features.length && !f.features.every((x) => p.features.includes(x))) return false;
  if (ignore !== "price" && (p.price < f.minPrice || p.price > f.maxPrice)) return false;
  if (ignore !== "rating" && f.minRating > 0 && p.rating < f.minRating) return false;
  if (ignore !== "stock" && f.inStockOnly && !p.inStock) return false;
  return true;
}

function SearchIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <circle cx="11" cy="11" r="7" />
      <path d="m20 20-3.5-3.5" />
    </svg>
  );
}

function CloseIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>
  );
}

function ChevronIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="m6 9 6 6 6-6" />
    </svg>
  );
}

function StarIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <path d="M12 2.5l2.9 5.88 6.49.94-4.7 4.58 1.11 6.46L12 17.9l-5.8 3.05 1.1-6.46-4.69-4.58 6.49-.94L12 2.5z" />
    </svg>
  );
}

function SlidersIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M4 6h11M19 6h1M4 12h1M9 12h11M4 18h7M15 18h5" />
      <circle cx="17" cy="6" r="2" />
      <circle cx="7" cy="12" r="2" />
      <circle cx="13" cy="18" r="2" />
    </svg>
  );
}

function FacetSection({
  title,
  activeCount,
  defaultOpen = true,
  children,
}: {
  title: string;
  activeCount?: number;
  defaultOpen?: boolean;
  children: ReactNode;
}) {
  const [open, setOpen] = useState(defaultOpen);
  const reduce = useReducedMotion();
  const id = useId();
  return (
    <div className="border-b border-slate-200 py-4 dark:border-slate-800">
      <h3>
        <button
          type="button"
          onClick={() => setOpen((o) => !o)}
          aria-expanded={open}
          aria-controls={id}
          className="flex w-full items-center justify-between rounded-md text-left text-sm font-semibold tracking-tight text-slate-900 transition-colors hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-100 dark:hover:text-indigo-400"
        >
          <span className="flex items-center gap-2">
            {title}
            {activeCount ? (
              <span className="fsf-pop inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-indigo-600 px-1.5 text-[11px] font-bold text-white tabular-nums dark:bg-indigo-500">
                {activeCount}
              </span>
            ) : null}
          </span>
          <ChevronIcon className={`h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200 ${open ? "rotate-180" : ""}`} />
        </button>
      </h3>
      {open ? (
        <div id={id} className={`mt-3 ${reduce ? "" : "fsf-slide-down"}`}>
          {children}
        </div>
      ) : null}
    </div>
  );
}

function CountBadge({ count }: { count: number }) {
  return (
    <span
      className={`ml-auto text-xs tabular-nums ${count === 0 ? "text-slate-300 dark:text-slate-700" : "text-slate-400 dark:text-slate-500"}`}
    >
      {count}
    </span>
  );
}

export default function FormSearchFilters() {
  const reduce = useReducedMotion();
  const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
  const [sort, setSort] = useState<SortKey>("relevance");
  const [panelOpen, setPanelOpen] = useState(false);
  const liveId = useId();

  const set = <K extends keyof Filters>(key: K, value: Filters[K]) =>
    setFilters((f) => ({ ...f, [key]: value }));

  const counts = useMemo(() => {
    const category: Record<string, number> = {};
    for (const c of CATEGORIES)
      category[c] = PRODUCTS.filter((p) => p.category === c && productMatches(p, filters, "categories")).length;
    const brand: Record<string, number> = {};
    for (const b of BRANDS)
      brand[b] = PRODUCTS.filter((p) => p.brand === b && productMatches(p, filters, "brands")).length;
    const feature: Record<string, number> = {};
    for (const ft of FEATURES)
      feature[ft] = PRODUCTS.filter((p) => p.features.includes(ft) && productMatches(p, filters, "features")).length;
    const rating: Record<string, number> = {};
    for (const r of RATING_OPTIONS)
      rating[r.value] = PRODUCTS.filter((p) => p.rating >= r.value && productMatches(p, filters, "rating")).length;
    const inStock = PRODUCTS.filter((p) => p.inStock && productMatches(p, filters, "stock")).length;
    return { category, brand, feature, rating, inStock };
  }, [filters]);

  const results = useMemo(() => {
    const list = PRODUCTS.filter((p) => productMatches(p, filters));
    const sorted = [...list];
    switch (sort) {
      case "price-asc":
        sorted.sort((a, b) => a.price - b.price);
        break;
      case "price-desc":
        sorted.sort((a, b) => b.price - a.price);
        break;
      case "rating":
        sorted.sort((a, b) => b.rating - a.rating || b.reviews - a.reviews);
        break;
      case "name":
        sorted.sort((a, b) => a.name.localeCompare(b.name));
        break;
      default:
        break;
    }
    return sorted;
  }, [filters, sort]);

  const priceActive = filters.minPrice !== PRICE_MIN || filters.maxPrice !== PRICE_MAX;

  const chips = useMemo(() => {
    const out: { key: string; label: string; onRemove: () => void }[] = [];
    if (filters.query.trim())
      out.push({ key: "query", label: `"${filters.query.trim()}"`, onRemove: () => set("query", "") });
    for (const c of filters.categories)
      out.push({ key: `cat-${c}`, label: c, onRemove: () => set("categories", toggle(filters.categories, c)) });
    for (const b of filters.brands)
      out.push({ key: `brand-${b}`, label: b, onRemove: () => set("brands", toggle(filters.brands, b)) });
    for (const ft of filters.features)
      out.push({ key: `feat-${ft}`, label: ft, onRemove: () => set("features", toggle(filters.features, ft)) });
    if (priceActive)
      out.push({
        key: "price",
        label: `$${filters.minPrice} – $${filters.maxPrice}`,
        onRemove: () => setFilters((f) => ({ ...f, minPrice: PRICE_MIN, maxPrice: PRICE_MAX })),
      });
    if (filters.minRating > 0)
      out.push({ key: "rating", label: `${filters.minRating} & up`, onRemove: () => set("minRating", 0) });
    if (filters.inStockOnly)
      out.push({ key: "stock", label: "In stock", onRemove: () => set("inStockOnly", false) });
    return out;
  }, [filters, priceActive]);

  const hasActive = chips.length > 0;
  const clearAll = () => setFilters(DEFAULT_FILTERS);

  const leftPct = ((filters.minPrice - PRICE_MIN) / (PRICE_MAX - PRICE_MIN)) * 100;
  const widthPct = ((filters.maxPrice - filters.minPrice) / (PRICE_MAX - PRICE_MIN)) * 100;

  const onMinPrice = (e: ChangeEvent<HTMLInputElement>) =>
    set("minPrice", Math.min(Number(e.target.value), filters.maxPrice));
  const onMaxPrice = (e: ChangeEvent<HTMLInputElement>) =>
    set("maxPrice", Math.max(Number(e.target.value), filters.minPrice));

  const cardMotion = reduce
    ? {}
    : {
        initial: { opacity: 0, y: 10 },
        animate: { opacity: 1, y: 0 },
        exit: { opacity: 0, y: -8, transition: { duration: 0.14 } },
        transition: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const },
      };

  const chipMotion = reduce
    ? {}
    : {
        initial: { opacity: 0, scale: 0.8 },
        animate: { opacity: 1, scale: 1 },
        exit: { opacity: 0, scale: 0.8 },
        transition: { duration: 0.16 },
      };

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 dark:bg-slate-950 dark:text-slate-100">
      <div className="mx-auto max-w-6xl">
        <header className="fsf-fade-up mb-8 max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <SlidersIcon className="h-3.5 w-3.5 text-indigo-500" />
            Desk &amp; Peripherals
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">Find your workspace gear</h2>
          <p className="mt-2 text-base leading-relaxed text-slate-500 dark:text-slate-400">
            Narrow {PRODUCTS.length} keyboards, mice, displays and audio picks by the facets that
            matter. Counts update live as you refine.
          </p>
        </header>

        <div className="grid grid-cols-1 gap-8 lg:grid-cols-[280px_minmax(0,1fr)]">
          {/* Facet panel */}
          <aside className="lg:sticky lg:top-6 lg:self-start">
            <button
              type="button"
              onClick={() => setPanelOpen((o) => !o)}
              aria-expanded={panelOpen}
              aria-controls="fsf-facet-panel"
              className="mb-4 flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold shadow-sm transition-colors hover:border-indigo-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 lg:hidden dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-700"
            >
              <span className="flex items-center gap-2">
                <SlidersIcon className="h-4 w-4 text-indigo-500" />
                Filters
                {hasActive ? (
                  <span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-indigo-600 px-1.5 text-[11px] font-bold text-white dark:bg-indigo-500">
                    {chips.length}
                  </span>
                ) : null}
              </span>
              <ChevronIcon className={`h-4 w-4 text-slate-400 transition-transform ${panelOpen ? "rotate-180" : ""}`} />
            </button>

            <div
              id="fsf-facet-panel"
              className={`${panelOpen ? "block" : "hidden"} rounded-2xl border border-slate-200 bg-white p-5 shadow-sm lg:block dark:border-slate-800 dark:bg-slate-900`}
            >
              <div className="flex items-center justify-between pb-1">
                <h2 className="text-sm font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">Filters</h2>
                <button
                  type="button"
                  onClick={clearAll}
                  disabled={!hasActive}
                  className="rounded-md text-xs font-semibold text-rose-500 transition-colors hover:text-rose-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 disabled:cursor-not-allowed disabled:text-slate-300 dark:disabled:text-slate-700"
                >
                  Clear all
                </button>
              </div>

              <FacetSection title="Category" activeCount={filters.categories.length}>
                <ul className="space-y-1.5">
                  {CATEGORIES.map((c) => {
                    const disabled = counts.category[c] === 0 && !filters.categories.includes(c);
                    return (
                      <li key={c}>
                        <label className={`flex cursor-pointer items-center gap-2.5 rounded-md px-1 py-1 text-sm transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/60 ${disabled ? "cursor-not-allowed opacity-45" : ""}`}>
                          <input
                            type="checkbox"
                            checked={filters.categories.includes(c)}
                            disabled={disabled}
                            onChange={() => set("categories", toggle(filters.categories, c))}
                            className="h-4 w-4 rounded border-slate-300 accent-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-600"
                          />
                          <span className="text-slate-700 dark:text-slate-200">{c}</span>
                          <CountBadge count={counts.category[c]} />
                        </label>
                      </li>
                    );
                  })}
                </ul>
              </FacetSection>

              <FacetSection title="Price" activeCount={priceActive ? 1 : 0}>
                <div role="group" aria-label="Price range" className="px-1">
                  <div className="mb-3 flex items-center justify-between text-sm font-semibold tabular-nums text-slate-700 dark:text-slate-200">
                    <span>${filters.minPrice}</span>
                    <span className="text-slate-400 dark:text-slate-500">to</span>
                    <span>${filters.maxPrice}{filters.maxPrice === PRICE_MAX ? "+" : ""}</span>
                  </div>
                  <div className="relative h-6">
                    <div className="absolute left-0 right-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-slate-200 dark:bg-slate-700" />
                    <div
                      className="absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-indigo-500"
                      style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
                    />
                    <input
                      type="range"
                      className="fsf-range"
                      min={PRICE_MIN}
                      max={PRICE_MAX}
                      step={PRICE_STEP}
                      value={filters.minPrice}
                      onChange={onMinPrice}
                      aria-label="Minimum price"
                      aria-valuetext={`$${filters.minPrice}`}
                    />
                    <input
                      type="range"
                      className="fsf-range"
                      min={PRICE_MIN}
                      max={PRICE_MAX}
                      step={PRICE_STEP}
                      value={filters.maxPrice}
                      onChange={onMaxPrice}
                      aria-label="Maximum price"
                      aria-valuetext={`$${filters.maxPrice}`}
                    />
                  </div>
                </div>
              </FacetSection>

              <FacetSection title="Brand" activeCount={filters.brands.length}>
                <ul className="space-y-1.5">
                  {BRANDS.map((b) => {
                    const disabled = counts.brand[b] === 0 && !filters.brands.includes(b);
                    return (
                      <li key={b}>
                        <label className={`flex cursor-pointer items-center gap-2.5 rounded-md px-1 py-1 text-sm transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/60 ${disabled ? "cursor-not-allowed opacity-45" : ""}`}>
                          <input
                            type="checkbox"
                            checked={filters.brands.includes(b)}
                            disabled={disabled}
                            onChange={() => set("brands", toggle(filters.brands, b))}
                            className="h-4 w-4 rounded border-slate-300 accent-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-600"
                          />
                          <span className="text-slate-700 dark:text-slate-200">{b}</span>
                          <CountBadge count={counts.brand[b]} />
                        </label>
                      </li>
                    );
                  })}
                </ul>
              </FacetSection>

              <FacetSection title="Rating" activeCount={filters.minRating > 0 ? 1 : 0}>
                <ul className="space-y-1.5" role="radiogroup" aria-label="Minimum rating">
                  {[{ value: 0, label: "Any rating" }, ...RATING_OPTIONS].map((r) => (
                    <li key={r.value}>
                      <label className="flex cursor-pointer items-center gap-2.5 rounded-md px-1 py-1 text-sm transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/60">
                        <input
                          type="radio"
                          name="fsf-rating"
                          checked={filters.minRating === r.value}
                          onChange={() => set("minRating", r.value)}
                          className="h-4 w-4 border-slate-300 accent-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-600"
                        />
                        <span className="flex items-center gap-1 text-slate-700 dark:text-slate-200">
                          {r.value > 0 ? <StarIcon className="h-3.5 w-3.5 text-amber-400" /> : null}
                          {r.label}
                        </span>
                        {r.value > 0 ? <CountBadge count={counts.rating[r.value]} /> : null}
                      </label>
                    </li>
                  ))}
                </ul>
              </FacetSection>

              <FacetSection title="Features" activeCount={filters.features.length} defaultOpen={false}>
                <ul className="space-y-1.5">
                  {FEATURES.map((ft) => {
                    const disabled = counts.feature[ft] === 0 && !filters.features.includes(ft);
                    return (
                      <li key={ft}>
                        <label className={`flex cursor-pointer items-center gap-2.5 rounded-md px-1 py-1 text-sm transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/60 ${disabled ? "cursor-not-allowed opacity-45" : ""}`}>
                          <input
                            type="checkbox"
                            checked={filters.features.includes(ft)}
                            disabled={disabled}
                            onChange={() => set("features", toggle(filters.features, ft))}
                            className="h-4 w-4 rounded border-slate-300 accent-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-600"
                          />
                          <span className="text-slate-700 dark:text-slate-200">{ft}</span>
                          <CountBadge count={counts.feature[ft]} />
                        </label>
                      </li>
                    );
                  })}
                </ul>
              </FacetSection>

              <div className="flex items-center justify-between pt-4">
                <span className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-200">
                  <span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
                  In stock only
                  <span className="text-xs text-slate-400 dark:text-slate-500">({counts.inStock})</span>
                </span>
                <button
                  type="button"
                  role="switch"
                  aria-checked={filters.inStockOnly}
                  aria-label="In stock only"
                  onClick={() => set("inStockOnly", !filters.inStockOnly)}
                  className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${filters.inStockOnly ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-700"}`}
                >
                  <span
                    className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${filters.inStockOnly ? "translate-x-5" : "translate-x-0.5"}`}
                  />
                </button>
              </div>
            </div>
          </aside>

          {/* Results */}
          <main>
            <div className="mb-5 flex flex-col gap-3">
              <div className="relative">
                <SearchIcon className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
                <label htmlFor="fsf-search" className="sr-only">
                  Search products
                </label>
                <input
                  id="fsf-search"
                  type="search"
                  value={filters.query}
                  onChange={(e) => set("query", e.target.value)}
                  placeholder="Search by product or brand…"
                  className="w-full rounded-xl border border-slate-200 bg-white py-3 pl-10 pr-10 text-sm shadow-sm outline-none transition-colors placeholder:text-slate-400 focus-visible:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-800 dark:bg-slate-900 dark:placeholder:text-slate-500"
                />
                {filters.query ? (
                  <button
                    type="button"
                    onClick={() => set("query", "")}
                    aria-label="Clear search"
                    className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-slate-800"
                  >
                    <CloseIcon className="h-4 w-4" />
                  </button>
                ) : null}
              </div>

              <div className="flex flex-wrap items-center justify-between gap-3">
                <p id={liveId} aria-live="polite" className="text-sm text-slate-500 dark:text-slate-400">
                  <span className="font-semibold text-slate-900 tabular-nums dark:text-slate-100">{results.length}</span>{" "}
                  {results.length === 1 ? "result" : "results"}
                  {hasActive ? " for your filters" : ""}
                </p>
                <div className="flex items-center gap-2">
                  <label htmlFor="fsf-sort" className="text-sm text-slate-500 dark:text-slate-400">
                    Sort
                  </label>
                  <div className="relative">
                    <select
                      id="fsf-sort"
                      value={sort}
                      onChange={(e) => setSort(e.target.value as SortKey)}
                      className="appearance-none rounded-lg border border-slate-200 bg-white py-2 pl-3 pr-8 text-sm font-medium shadow-sm outline-none transition-colors focus-visible:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-800 dark:bg-slate-900"
                    >
                      {SORT_OPTIONS.map((o) => (
                        <option key={o.value} value={o.value}>
                          {o.label}
                        </option>
                      ))}
                    </select>
                    <ChevronIcon className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
                  </div>
                </div>
              </div>

              {hasActive ? (
                <div className="flex flex-wrap items-center gap-2">
                  <AnimatePresence initial={false}>
                    {chips.map((chip) => (
                      <motion.span key={chip.key} layout={!reduce} {...chipMotion}>
                        <button
                          type="button"
                          onClick={chip.onRemove}
                          className="group inline-flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 py-1 pl-3 pr-2 text-xs font-medium text-indigo-700 transition-colors hover:border-indigo-300 hover:bg-indigo-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-indigo-500/30 dark:bg-indigo-500/15 dark:text-indigo-300 dark:hover:bg-indigo-500/25"
                        >
                          {chip.label}
                          <CloseIcon className="h-3.5 w-3.5 opacity-60 transition-opacity group-hover:opacity-100" />
                          <span className="sr-only">Remove filter</span>
                        </button>
                      </motion.span>
                    ))}
                  </AnimatePresence>
                  <button
                    type="button"
                    onClick={clearAll}
                    className="rounded-full px-2 py-1 text-xs font-semibold text-slate-500 underline-offset-2 transition-colors hover:text-rose-500 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 dark:text-slate-400"
                  >
                    Clear all
                  </button>
                </div>
              ) : null}
            </div>

            {results.length > 0 ? (
              <motion.ul layout={!reduce} className="grid grid-cols-1 gap-4 sm:grid-cols-2">
                <AnimatePresence initial={false} mode="popLayout">
                  {results.map((p) => (
                    <motion.li
                      key={p.id}
                      layout={!reduce}
                      {...cardMotion}
                      className="group flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all hover:-translate-y-0.5 hover:border-indigo-300 hover:shadow-md dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-700"
                    >
                      <div className="mb-3 flex items-center justify-between">
                        <span className="rounded-md bg-slate-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500 dark:bg-slate-800 dark:text-slate-400">
                          {p.category}
                        </span>
                        <span
                          className={`inline-flex items-center gap-1 text-xs font-semibold ${p.inStock ? "text-emerald-600 dark:text-emerald-400" : "text-slate-400 dark:text-slate-500"}`}
                        >
                          <span className={`h-1.5 w-1.5 rounded-full ${p.inStock ? "bg-emerald-500" : "bg-slate-400"}`} />
                          {p.inStock ? "In stock" : "Sold out"}
                        </span>
                      </div>

                      <p className="text-xs font-medium uppercase tracking-wide text-indigo-500 dark:text-indigo-400">{p.brand}</p>
                      <h3 className="mt-0.5 text-base font-bold tracking-tight text-slate-900 dark:text-slate-100">{p.name}</h3>
                      <p className="mt-1 text-sm leading-relaxed text-slate-500 dark:text-slate-400">{p.tagline}</p>

                      <div className="mt-3 flex flex-wrap gap-1.5">
                        {p.features.map((ft) => (
                          <span
                            key={ft}
                            className="rounded border border-slate-200 px-1.5 py-0.5 text-[11px] font-medium text-slate-500 dark:border-slate-700 dark:text-slate-400"
                          >
                            {ft}
                          </span>
                        ))}
                      </div>

                      <div className="mt-4 flex items-end justify-between border-t border-slate-100 pt-3 dark:border-slate-800">
                        <span className="inline-flex items-center gap-1 text-sm text-slate-600 dark:text-slate-300">
                          <StarIcon className="h-4 w-4 text-amber-400" />
                          <span className="font-semibold tabular-nums">{p.rating.toFixed(1)}</span>
                          <span className="text-xs text-slate-400 dark:text-slate-500">({p.reviews.toLocaleString()})</span>
                        </span>
                        <span className="text-lg font-bold tracking-tight text-slate-900 tabular-nums dark:text-slate-100">
                          ${p.price}
                        </span>
                      </div>
                    </motion.li>
                  ))}
                </AnimatePresence>
              </motion.ul>
            ) : (
              <div className="fsf-fade-up flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-300 bg-white px-6 py-16 text-center dark:border-slate-700 dark:bg-slate-900">
                <span className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-400 dark:bg-slate-800">
                  <SearchIcon className="h-6 w-6" />
                </span>
                <p className="mt-4 text-base font-semibold text-slate-900 dark:text-slate-100">No products match</p>
                <p className="mt-1 max-w-xs text-sm text-slate-500 dark:text-slate-400">
                  Try widening your price range or removing a facet to see more of the catalog.
                </p>
                <button
                  type="button"
                  onClick={clearAll}
                  className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
                >
                  Reset filters
                </button>
              </div>
            )}
          </main>
        </div>
      </div>

      <style>{`
        .fsf-range {
          -webkit-appearance: none;
          appearance: none;
          position: absolute;
          inset: 0;
          width: 100%;
          height: 100%;
          margin: 0;
          background: transparent;
          pointer-events: none;
        }
        .fsf-range::-webkit-slider-thumb {
          -webkit-appearance: none;
          appearance: none;
          pointer-events: auto;
          height: 18px;
          width: 18px;
          border-radius: 9999px;
          background: #ffffff;
          border: 2px solid #6366f1;
          box-shadow: 0 1px 3px rgba(15, 23, 42, 0.3);
          cursor: pointer;
        }
        .fsf-range::-moz-range-thumb {
          pointer-events: auto;
          height: 18px;
          width: 18px;
          border-radius: 9999px;
          background: #ffffff;
          border: 2px solid #6366f1;
          box-shadow: 0 1px 3px rgba(15, 23, 42, 0.3);
          cursor: pointer;
        }
        .fsf-range:focus-visible::-webkit-slider-thumb {
          outline: 2px solid #4f46e5;
          outline-offset: 2px;
        }
        .fsf-range:focus-visible::-moz-range-thumb {
          outline: 2px solid #4f46e5;
          outline-offset: 2px;
        }
        @keyframes fsf-slide-down {
          from { opacity: 0; transform: translateY(-6px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes fsf-fade-up {
          from { opacity: 0; transform: translateY(12px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes fsf-pop {
          0% { transform: scale(0.6); }
          60% { transform: scale(1.15); }
          100% { transform: scale(1); }
        }
        .fsf-slide-down { animation: fsf-slide-down 0.2s ease-out both; }
        .fsf-fade-up { animation: fsf-fade-up 0.4s ease-out both; }
        .fsf-pop { animation: fsf-pop 0.28s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .fsf-slide-down, .fsf-fade-up, .fsf-pop { animation: none !important; }
        }
      `}</style>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →