# Filter List

> A product list paired with category, price, and attribute filters in a sidebar. Lets shoppers narrow large catalogs and find the right item fast.

A shadcn/ui block from the [@shadcn-ui-blocks](https://www.shadcn-ui-blocks.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcn-ui-blocks/ecommerce-product-list-filter-list).

- Page, with a live preview: https://shadcn.exchange/r/shadcn-ui-blocks/ecommerce-product-list-filter-list
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcn-ui-blocks/ecommerce-product-list-filter-list-light-772debe14501030d.png
- Registry item: https://www.shadcn-ui-blocks.com/r/ecommerce-product-list-filter-list.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.shadcn-ui-blocks.com/r/ecommerce-product-list-filter-list.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `class-variance-authority`, `lucide-react`, `radix-ui`

## Files

### `components/blocks/ecommerce/product-list/filter-list.tsx`

```tsx
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider';
import { Heart, Star, SlidersHorizontal, X } from 'lucide-react';
import { useState } from 'react';

interface Product {
  id: number;
  name: string;
  category: string;
  price: number;
  originalPrice: number;
  rating: number;
  reviews: number;
  features: string[];
}

interface Filter {
  search: string;
  category: string;
  priceRange: [number, number];
  minRating: number;
  features: string[];
}

export default function FilterList() {
  const [showFilters, setShowFilters] = useState(false);
  const [filters, setFilters] = useState<Filter>({
    search: '',
    category: 'all',
    priceRange: [0, 1000],
    minRating: 0,
    features: [],
  });

  const categories = ['all', 'Accessories', 'Electronics', 'Sports'];
  const features = [
    'Water Resistant',
    'Heart Rate Monitor',
    'GPS',
    'Sleep Tracking',
    'Notifications',
    'Leather Strap',
    'Steel Band',
  ];

  const products: Product[] = [
    {
      id: 1,
      name: 'Classic Leather Watch',
      category: 'Accessories',
      price: 299,
      originalPrice: 399,
      rating: 4.9,
      reviews: 128,
      features: ['Water Resistant', 'Leather Strap'],
    },
    {
      id: 2,
      name: 'Smart Watch Pro',
      category: 'Electronics',
      price: 499,
      originalPrice: 599,
      rating: 4.8,
      reviews: 256,
      features: [
        'Heart Rate Monitor',
        'GPS',
        'Sleep Tracking',
        'Notifications',
      ],
    },
    {
      id: 3,
      name: 'Sport Watch Elite',
      category: 'Sports',
      price: 399,
      originalPrice: 449,
      rating: 4.7,
      reviews: 192,
      features: ['Water Resistant', 'Heart Rate Monitor', 'GPS'],
    },
  ];

  const filteredProducts = products.filter((product) => {
    const matchesSearch = product.name
      .toLowerCase()
      .includes(filters.search.toLowerCase());
    const matchesCategory =
      filters.category === 'all' || product.category === filters.category;
    const matchesPrice =
      product.price >= filters.priceRange[0] &&
      product.price <= filters.priceRange[1];
    const matchesRating = product.rating >= filters.minRating;
    const matchesFeatures =
      filters.features.length === 0 ||
      filters.features.every((f) => product.features.includes(f));

    return (
      matchesSearch &&
      matchesCategory &&
      matchesPrice &&
      matchesRating &&
      matchesFeatures
    );
  });

  return (
    <div className="mx-auto w-full max-w-7xl p-6">
      <div className="flex flex-col gap-8 lg:flex-row">
        {/* Filters Sidebar */}
        <div
          className={`shrink-0 space-y-6 lg:w-64 ${
            showFilters
              ? 'bg-background fixed inset-0 z-50 p-6 lg:static lg:p-0'
              : 'hidden lg:block'
          }`}
        >
          <div className="flex items-center justify-between lg:hidden">
            <h2 className="font-semibold">Filters</h2>
            <Button
              size="icon"
              variant="ghost"
              onClick={() => setShowFilters(false)}
            >
              <X className="h-4 w-4" />
            </Button>
          </div>

          <div className="space-y-4">
            <div>
              <label className="text-sm font-medium">Search</label>
              <Input
                placeholder="Search products..."
                value={filters.search}
                onChange={(e) =>
                  setFilters({ ...filters, search: e.target.value })
                }
                className="mt-1"
              />
            </div>

            <div>
              <label className="text-sm font-medium">Category</label>
              <div className="mt-1 grid grid-cols-2 gap-2">
                {categories.map((category) => (
                  <Button
                    key={category}
                    size="sm"
                    variant={
                      filters.category === category ? 'default' : 'outline'
                    }
                    onClick={() =>
                      setFilters({ ...filters, category: category })
                    }
                    className="capitalize"
                  >
                    {category}
                  </Button>
                ))}
              </div>
            </div>

            <div>
              <label className="text-sm font-medium">Price Range</label>
              <div className="mt-4">
                <Slider
                  value={filters.priceRange}
                  min={0}
                  max={1000}
                  step={50}
                  onValueChange={(value: [number, number]) =>
                    setFilters({ ...filters, priceRange: value })
                  }
                />
                <div className="text-muted-foreground mt-2 flex items-center justify-between text-sm">
                  <span>${filters.priceRange[0]}</span>
                  <span>${filters.priceRange[1]}</span>
                </div>
              </div>
            </div>

            <div>
              <label className="text-sm font-medium">Minimum Rating</label>
              <div className="mt-4">
                <Slider
                  value={[filters.minRating]}
                  min={0}
                  max={5}
                  step={0.5}
                  onValueChange={(value) =>
                    setFilters({ ...filters, minRating: value[0]! })
                  }
                />
                <div className="mt-2 flex items-center gap-1">
                  <Star className="fill-primary text-primary h-4 w-4" />
                  <span className="text-sm">{filters.minRating}</span>
                </div>
              </div>
            </div>

            <div>
              <label className="text-sm font-medium">Features</label>
              <div className="mt-1 grid grid-cols-1 gap-2">
                {features.map((feature) => (
                  <Button
                    key={feature}
                    size="sm"
                    variant={
                      filters.features.includes(feature) ? 'default' : 'outline'
                    }
                    onClick={() =>
                      setFilters({
                        ...filters,
                        features: filters.features.includes(feature)
                          ? filters.features.filter((f) => f !== feature)
                          : [...filters.features, feature],
                      })
                    }
                  >
                    {feature}
                  </Button>
                ))}
              </div>
            </div>
          </div>
        </div>

        {/* Product Grid */}
        <div className="flex-1">
          <div className="mb-6 flex items-center justify-between">
            <div className="text-muted-foreground">
              {filteredProducts.length} products found
            </div>
            <Button
              variant="outline"
              className="lg:hidden"
              onClick={() => setShowFilters(true)}
            >
              <SlidersHorizontal className="mr-2 h-4 w-4" />
              Filters
            </Button>
          </div>

          <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
            {filteredProducts.map((product) => (
              <div
                key={product.id}
                className="group bg-card flex flex-col overflow-hidden rounded-xl border shadow-sm transition-shadow duration-300 hover:shadow-lg"
              >
                <div className="bg-muted relative aspect-square">
                  <img
                    src="https://images.unsplash.com/photo-1523275335684-37898b6baf30"
                    alt={product.name}
                    className="object-cover transition-transform group-hover:scale-105"
                    sizes="(max-width: 768px) 100vw, (max-width: 1200px) 33vw, 25vw"
                  />
                  <Button
                    size="icon"
                    variant="ghost"
                    className="bg-background/80 absolute top-2 right-2 h-8 w-8 rounded-full opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100"
                  >
                    <Heart className="h-4 w-4" />
                  </Button>
                </div>
                <div className="flex flex-1 flex-col p-4">
                  <div className="flex-1 space-y-2">
                    <div className="min-w-0">
                      <h3 className="font-medium">{product.name}</h3>
                      <span className="bg-secondary text-secondary-foreground mt-1 inline-block rounded-md px-2 py-1 text-xs font-medium">
                        {product.category}
                      </span>
                      <div className="mt-2 flex items-center gap-1">
                        <Star className="fill-primary text-primary h-3.5 w-3.5" />
                        <span className="text-sm">{product.rating}</span>
                        <span className="text-muted-foreground text-sm">
                          ({product.reviews})
                        </span>
                      </div>
                    </div>
                    <div className="flex items-baseline gap-2">
                      <div className="font-medium">${product.price}</div>
                      <div className="text-muted-foreground text-sm line-through">
                        ${product.originalPrice}
                      </div>
                    </div>
                    <div className="flex flex-wrap gap-1">
                      {product.features.map((feature) => (
                        <span
                          key={feature}
                          className="bg-accent text-accent-foreground inline-block rounded-md px-2 py-1 text-xs font-medium"
                        >
                          {feature}
                        </span>
                      ))}
                    </div>
                  </div>
                  <div className="mt-4">
                    <Button size="sm" className="w-full">
                      Add to Cart
                    </Button>
                  </div>
                </div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}
```

### `components/ui/button.tsx`

```tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/80",
        outline:
          "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
        ghost:
          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
        destructive:
          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default:
          "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
        lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
        icon: "size-9",
        "icon-xs":
          "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
        "icon-sm":
          "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
        "icon-lg": "size-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

function Button({
  className,
  variant = "default",
  size = "default",
  asChild = false,
  ...props
}: React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean
  }) {
  const Comp = asChild ? Slot.Root : "button"

  return (
    <Comp
      data-slot="button"
      data-variant={variant}
      data-size={size}
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

export { Button, buttonVariants }
```

### `components/ui/input.tsx`

```tsx
import * as React from "react"

import { cn } from "@/lib/utils"

function Input({ className, type, ...props }: React.ComponentProps<"input">) {
  return (
    <input
      type={type}
      data-slot="input"
      className={cn(
        "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
        className
      )}
      {...props}
    />
  )
}

export { Input }
```

### `components/ui/slider.tsx`

```tsx
"use client"

import * as React from "react"
import { Slider as SliderPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"

function Slider({
  className,
  defaultValue,
  value,
  min = 0,
  max = 100,
  ...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
  const _values = React.useMemo(
    () =>
      Array.isArray(value)
        ? value
        : Array.isArray(defaultValue)
          ? defaultValue
          : [min, max],
    [value, defaultValue, min, max]
  )

  return (
    <SliderPrimitive.Root
      data-slot="slider"
      defaultValue={defaultValue}
      value={value}
      min={min}
      max={max}
      className={cn(
        "relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col",
        className
      )}
      {...props}
    >
      <SliderPrimitive.Track
        data-slot="slider-track"
        className="relative grow overflow-hidden rounded-full bg-muted data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5"
      >
        <SliderPrimitive.Range
          data-slot="slider-range"
          className="absolute bg-primary select-none data-horizontal:h-full data-vertical:w-full"
        />
      </SliderPrimitive.Track>
      {Array.from({ length: _values.length }, (_, index) => (
        <SliderPrimitive.Thumb
          data-slot="slider-thumb"
          key={index}
          className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] select-none hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
        />
      ))}
    </SliderPrimitive.Root>
  )
}

export { Slider }
```
