Horizontal Filters
A row of filter dropdowns and chips sitting above the product grid. Lets shoppers narrow by category, price, and brand without giving up vertical space.
A row of filter dropdowns and chips sitting above the product grid. Lets shoppers narrow by category, price, and brand without giving up vertical space.
Search blocks by description or jump to a page.
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { useState } from 'react';
import { Check, ChevronDown, Filter, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
// Modified base slider component to show end circle
interface SliderProps
extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
showEndCircle?: boolean;
}
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
SliderProps
>(({ className, showEndCircle = false, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none items-center select-none',
className
)}
{...props}
>
<SliderPrimitive.Track className="bg-secondary relative h-2 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="border-primary bg-background ring-offset-background focus-visible:ring-ring block h-5 w-5 rounded-full border-2 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50" />
{showEndCircle && (
<SliderPrimitive.Thumb className="border-primary bg-background ring-offset-background focus-visible:ring-ring block h-5 w-5 rounded-full border-2 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50" />
)}
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
// End Modified base slider component to show end circle
interface HorizontalFiltersProps {
className?: string;
onFilterChange?: (filters: FilterState) => void;
}
interface FilterState {
categories: string[];
priceRange: [number, number];
brands: string[];
colors: string[];
sizes: string[];
sortBy: string;
}
export default function HorizontalFilters({
className,
onFilterChange,
}: HorizontalFiltersProps) {
const [filters, setFilters] = useState<FilterState>({
categories: [],
priceRange: [0, 500],
brands: [],
colors: [],
sizes: [],
sortBy: 'featured',
});
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
const [showMobileFilters, setShowMobileFilters] = useState(false);
const updateFilters = (newFilters: Partial<FilterState>) => {
const updated = { ...filters, ...newFilters };
setFilters(updated);
// Count active filters
let count = 0;
if (updated.categories.length) count += updated.categories.length;
if (updated.brands.length) count += updated.brands.length;
if (updated.colors.length) count += updated.colors.length;
if (updated.sizes.length) count += updated.sizes.length;
if (updated.priceRange[0] > 0 || updated.priceRange[1] < 500) count += 1;
setActiveFiltersCount(count);
onFilterChange?.(updated);
};
const toggleFilter = (type: keyof FilterState, value: string) => {
if (type === 'sortBy') {
updateFilters({ [type]: value } as Partial<FilterState>);
return;
}
const currentValues = filters[type] as string[];
const newValues = currentValues.includes(value)
? currentValues.filter((v) => v !== value)
: [...currentValues, value];
updateFilters({ [type]: newValues } as Partial<FilterState>);
};
const clearFilters = () => {
setFilters({
categories: [],
priceRange: [0, 500],
brands: [],
colors: [],
sizes: [],
sortBy: 'featured',
});
setActiveFiltersCount(0);
onFilterChange?.({
categories: [],
priceRange: [0, 500],
brands: [],
colors: [],
sizes: [],
sortBy: 'featured',
});
};
const categories = [
'Clothing',
'Shoes',
'Accessories',
'Sportswear',
'Outerwear',
];
const brands = ['Nike', 'Adidas', 'Puma', 'Under Armour', 'New Balance'];
const colors = [
{ name: 'Black', value: '#000000' },
{ name: 'White', value: '#FFFFFF' },
{ name: 'Red', value: '#FF0000' },
{ name: 'Blue', value: '#0000FF' },
{ name: 'Green', value: '#00FF00' },
{ name: 'Yellow', value: '#FFFF00' },
];
const sizes = ['XS', 'S', 'M', 'L', 'XL', 'XXL'];
const sortOptions = [
{ label: 'Featured', value: 'featured' },
{ label: 'Price: Low to High', value: 'price-asc' },
{ label: 'Price: High to Low', value: 'price-desc' },
{ label: 'Newest', value: 'newest' },
{ label: 'Best Selling', value: 'best-selling' },
];
const ActiveFilterBadges = () => {
const activeFilters = [
...filters.categories.map((cat) => ({ type: 'categories', value: cat })),
...filters.brands.map((brand) => ({ type: 'brands', value: brand })),
...filters.colors.map((color) => ({ type: 'colors', value: color })),
...filters.sizes.map((size) => ({ type: 'sizes', value: size })),
...(filters.priceRange[0] > 0 || filters.priceRange[1] < 500
? [
{
type: 'price',
value: `$${filters.priceRange[0]} - $${filters.priceRange[1]}`,
},
]
: []),
];
if (activeFilters.length === 0) return null;
return (
<div className="mt-3 flex flex-wrap gap-2">
{activeFilters.map((filter, index) => (
<Badge
key={`${filter.type}-${filter.value}-${index}`}
variant="outline"
className="flex items-center gap-1 px-2 py-1"
>
{filter.value}
<Button
variant="ghost"
size="icon"
className="ml-1 h-4 w-4 p-0"
onClick={() =>
toggleFilter(filter.type as keyof FilterState, filter.value)
}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
{activeFilters.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={clearFilters}
>
Clear all
</Button>
)}
</div>
);
};
return (
<>
{/* Height set for example purpose only */}
<div className={cn('h-[350px] w-full p-6', className)}>
<div className="mb-4 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex flex-wrap items-center gap-2">
{/* Category Filter */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
Category
<ChevronDown className="ml-1 h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-3">
<div className="space-y-2">
{categories.map((category) => (
<div key={category} className="flex items-center">
<Button
variant="ghost"
size="sm"
className={cn(
'w-full justify-start font-normal',
filters.categories.includes(category) && 'font-medium'
)}
onClick={() => toggleFilter('categories', category)}
>
<div className="flex w-full items-center justify-between">
{category}
{filters.categories.includes(category) && (
<Check className="h-4 w-4" />
)}
</div>
</Button>
</div>
))}
</div>
</PopoverContent>
</Popover>
{/* Brand Filter */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
Brand
<ChevronDown className="ml-1 h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-3">
<div className="space-y-2">
{brands.map((brand) => (
<div key={brand} className="flex items-center">
<Button
variant="ghost"
size="sm"
className={cn(
'w-full justify-start font-normal',
filters.brands.includes(brand) && 'font-medium'
)}
onClick={() => toggleFilter('brands', brand)}
>
<div className="flex w-full items-center justify-between">
{brand}
{filters.brands.includes(brand) && (
<Check className="h-4 w-4" />
)}
</div>
</Button>
</div>
))}
</div>
</PopoverContent>
</Popover>
{/* Price Range Filter */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
Price
<ChevronDown className="ml-1 h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-4">
<div className="space-y-4">
<h4 className="text-sm font-medium">Price Range</h4>
<Slider
value={filters.priceRange}
min={0}
max={500}
step={10}
onValueChange={(value) =>
updateFilters({ priceRange: value as [number, number] })
}
showEndCircle
/>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
${filters.priceRange[0]}
</span>
<span className="text-muted-foreground text-sm">
${filters.priceRange[1]}
</span>
</div>
</div>
</PopoverContent>
</Popover>
{/* Size Filter */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
Size
<ChevronDown className="ml-1 h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-3">
<div className="grid grid-cols-3 gap-2">
{sizes.map((size) => (
<Button
key={size}
variant={
filters.sizes.includes(size) ? 'default' : 'outline'
}
size="sm"
onClick={() => toggleFilter('sizes', size)}
className="h-8"
>
{size}
</Button>
))}
</div>
</PopoverContent>
</Popover>
{/* Color Filter */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
Color
<ChevronDown className="ml-1 h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-3">
<div className="grid grid-cols-4 gap-2">
{colors.map((color) => (
<div
key={color.name}
className="flex flex-col items-center gap-1"
>
<button
className={cn(
'border-input flex h-8 w-8 items-center justify-center rounded-full border',
filters.colors.includes(color.name) &&
'ring-primary ring-2'
)}
style={{ backgroundColor: color.value }}
onClick={() => toggleFilter('colors', color.name)}
>
{filters.colors.includes(color.name) && (
<Check
className={cn(
'h-4 w-4',
['White', 'Yellow'].includes(color.name)
? 'text-black'
: 'text-white'
)}
/>
)}
</button>
<span className="text-xs">{color.name}</span>
</div>
))}
</div>
</PopoverContent>
</Popover>
{/* Mobile All Filters Button */}
<Button
variant="outline"
size="sm"
className="h-8 sm:hidden"
onClick={() => setShowMobileFilters(true)}
>
<Filter className="mr-1 h-3 w-3" />
All Filters
{activeFiltersCount > 0 && (
<Badge variant="secondary" className="ml-1">
{activeFiltersCount}
</Badge>
)}
</Button>
</div>
{/* Sort By Dropdown */}
<div className="flex items-center gap-2">
<span className="text-muted-foreground hidden text-sm sm:inline">
Sort by:
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
{sortOptions.find((opt) => opt.value === filters.sortBy)
?.label || 'Featured'}
<ChevronDown className="ml-1 h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{sortOptions.map((option) => (
<DropdownMenuItem
key={option.value}
className={cn(
filters.sortBy === option.value && 'font-medium'
)}
onClick={() => toggleFilter('sortBy', option.value)}
>
{option.label}
{filters.sortBy === option.value && (
<Check className="ml-2 h-4 w-4" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Active Filter Badges */}
<ActiveFilterBadges />
{/* Mobile Filters Overlay */}
{showMobileFilters && (
<div className="bg-background/80 fixed inset-0 z-50 backdrop-blur-sm">
<div className="bg-background fixed inset-y-0 right-0 z-50 w-full max-w-xs p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Filters</h2>
<Button
variant="ghost"
size="icon"
onClick={() => setShowMobileFilters(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="h-[calc(100vh-8rem)]">
<div className="space-y-6 pr-4">
{/* Categories */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Categories</h3>
<div className="space-y-2">
{categories.map((category) => (
<Button
key={category}
variant="ghost"
size="sm"
className={cn(
'w-full justify-start font-normal',
filters.categories.includes(category) &&
'font-medium'
)}
onClick={() => toggleFilter('categories', category)}
>
<div className="flex w-full items-center justify-between">
{category}
{filters.categories.includes(category) && (
<Check className="h-4 w-4" />
)}
</div>
</Button>
))}
</div>
</div>
<Separator />
{/* Brands */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Brands</h3>
<div className="space-y-2">
{brands.map((brand) => (
<Button
key={brand}
variant="ghost"
size="sm"
className={cn(
'w-full justify-start font-normal',
filters.brands.includes(brand) && 'font-medium'
)}
onClick={() => toggleFilter('brands', brand)}
>
<div className="flex w-full items-center justify-between">
{brand}
{filters.brands.includes(brand) && (
<Check className="h-4 w-4" />
)}
</div>
</Button>
))}
</div>
</div>
<Separator />
{/* Price Range */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Price Range</h3>
<div className="space-y-4">
<Slider
value={filters.priceRange}
min={0}
max={500}
step={10}
onValueChange={(value) =>
updateFilters({
priceRange: value as [number, number],
})
}
/>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
${filters.priceRange[0]}
</span>
<span className="text-muted-foreground text-sm">
${filters.priceRange[1]}
</span>
</div>
</div>
</div>
<Separator />
{/* Sizes */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Sizes</h3>
<div className="grid grid-cols-3 gap-2">
{sizes.map((size) => (
<Button
key={size}
variant={
filters.sizes.includes(size) ? 'default' : 'outline'
}
size="sm"
onClick={() => toggleFilter('sizes', size)}
className="h-8"
>
{size}
</Button>
))}
</div>
</div>
<Separator />
{/* Colors */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Colors</h3>
<div className="grid grid-cols-4 gap-2">
{colors.map((color) => (
<div
key={color.name}
className="flex flex-col items-center gap-1"
>
<button
className={cn(
'border-input flex h-8 w-8 items-center justify-center rounded-full border',
filters.colors.includes(color.name) &&
'ring-primary ring-2'
)}
style={{ backgroundColor: color.value }}
onClick={() => toggleFilter('colors', color.name)}
>
{filters.colors.includes(color.name) && (
<Check
className={cn(
'h-4 w-4',
['White', 'Yellow'].includes(color.name)
? 'text-black'
: 'text-white'
)}
/>
)}
</button>
<span className="text-xs">{color.name}</span>
</div>
))}
</div>
</div>
</div>
</ScrollArea>
<div className="mt-6 flex items-center justify-between border-t pt-4">
<Button variant="outline" onClick={clearFilters}>
Clear all
</Button>
<Button onClick={() => setShowMobileFilters(false)}>
Apply filters
</Button>
</div>
</div>
</div>
)}
</div>
</>
);
}
Horizontal Filters is a compact toolbar that sits above a product grid. A row of outline buttons, one each for Category, Brand, Price, Size, and Color, opens a popover when clicked. The Category and Brand popovers show a plain list where each option toggles on with a checkmark. The Price popover holds a two-handle range slider with live dollar readouts. The Size popover shows a three-column grid of toggle buttons. The Color popover shows circular swatches with a ring highlight and a checkmark when selected. A Sort dropdown anchored to the right of the toolbar lets visitors order results by featured, price, or recency. On narrow screens the individual filter buttons are replaced by a single "All Filters" button with an active-count badge; tapping it opens a full-height slide-in panel that mirrors every filter in a scrollable list.
When at least one filter is active, a row of outline badges appears below the toolbar. Each badge names the active value and carries an X button to remove it inline. A "Clear all" button at the end of the row resets everything at once. All filter state is tracked internally, and an optional callback fires on every change so you can wire results to a sibling grid without managing state outside the block.
This layout suits any listing page where screen space is at a premium and shoppers need to narrow a large set of results without losing sight of the grid. It works especially well for a real estate marketplace where buyers want to filter by property type, price band, and other attributes while keeping as many listings visible as possible. The horizontal bar adds less visual weight than a sidebar, so the listings always feel like the main event. When the filter count is small and the audience skews mobile, the Filter Drawer is a closer fit; when you need facets and counts alongside each option, the Filter Demo sidebar gives you more room.
A natural flow around it on an Ecommerce page:
Before
After
Each popover is self-contained, so you can add, remove, or rename any filter group without touching the others.
Tip: wire the
onFilterChangecallback to your data-fetching layer so the grid re-queries on every selection rather than waiting for an apply button.