Search Filters Form
Original · freefaceted search filter panel
byWeb InnoventixReact + Tailwind
formsearchfiltersforms
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.jsonform-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 & 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 quoteSimilar components
Browse all →
Login Form
Originalclean login form with social buttons

Signup Form
Originalsignup form with password strength

Contact Form
Originalcontact form with fields and message

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Profile Form
Originalprofile settings form

OTP Verify Form
OriginalOTP verification form

Upload Dropzone Form
Originaldrag-and-drop file upload with preview

Feedback Form
Originalfeedback form with emoji rating

Survey Form
Originalshort survey with radios and scale

Booking Form
Originalbooking form with date and slots

