A faceted filter sidebar with a dual-thumb price range, brand checkboxes with counts, star ratings, and active-filter chips.
npx shadcn@latest add @paragon/category-filter-rail"use client";
import * as React from "react";
import { ChevronDown, Check, Star, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface FacetOption {
value: string;
label: string;
count: number;
}
export interface PriceRange {
min: number;
max: number;
step?: number;
}
export interface CategoryFilterRailProps
extends Omit<React.ComponentProps<"aside">, "onChange"> {
brands: FacetOption[];
price: PriceRange;
/** Rating rows: option value is the minimum star count as a string. */
ratings?: FacetOption[];
onChange?: (state: {
brands: string[];
price: [number, number];
minRating: number | null;
}) => void;
currency?: string;
locale?: Intl.LocalesArgument;
}
/**
* A faceted filter sidebar: collapsible sections (grid-rows disclosure) for a
* dual-thumb price range, brand checkboxes with counts, and a star-rating
* facet. An active-filter chip row lets each pick be cleared. Counts and prices
* are tabular. Reduced motion drops the section transitions.
*/
export function CategoryFilterRail({
brands,
price,
ratings,
onChange,
currency = "USD",
locale = "en-US",
className,
...props
}: CategoryFilterRailProps) {
const step = price.step ?? 5;
const [lo, setLo] = React.useState(price.min);
const [hi, setHi] = React.useState(price.max);
const [selectedBrands, setSelectedBrands] = React.useState<Set<string>>(
new Set(),
);
const [minRating, setMinRating] = React.useState<number | null>(null);
const format = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
maximumFractionDigits: 0,
}),
[locale, currency],
);
const emit = React.useCallback(
(next?: Partial<{ brands: Set<string>; lo: number; hi: number; rating: number | null }>) => {
onChange?.({
brands: Array.from(next?.brands ?? selectedBrands),
price: [next?.lo ?? lo, next?.hi ?? hi],
minRating: next?.rating !== undefined ? next.rating : minRating,
});
},
[onChange, selectedBrands, lo, hi, minRating],
);
const toggleBrand = (value: string) => {
setSelectedBrands((prev) => {
const next = new Set(prev);
if (next.has(value)) next.delete(value);
else next.add(value);
emit({ brands: next });
return next;
});
};
const setLoClamped = (v: number) => {
const next = Math.min(v, hi - step);
setLo(next);
emit({ lo: next });
};
const setHiClamped = (v: number) => {
const next = Math.max(v, lo + step);
setHi(next);
emit({ hi: next });
};
const loPct = ((lo - price.min) / (price.max - price.min)) * 100;
const hiPct = ((hi - price.min) / (price.max - price.min)) * 100;
const priceDirty = lo !== price.min || hi !== price.max;
const anyActive =
selectedBrands.size > 0 || priceDirty || minRating !== null;
const clearAll = () => {
setSelectedBrands(new Set());
setLo(price.min);
setHi(price.max);
setMinRating(null);
onChange?.({ brands: [], price: [price.min, price.max], minRating: null });
};
return (
<aside
className={cn(
"w-full max-w-64 rounded-xl bg-card p-4 shadow-border",
className,
)}
{...props}
>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium text-card-foreground">Filters</h3>
{anyActive && (
<button
type="button"
onClick={clearAll}
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
Clear all
</button>
)}
</div>
{/* Active chips */}
{anyActive && (
<div className="mb-3 flex flex-wrap gap-1.5">
{priceDirty && (
<Chip
onClear={() => {
setLo(price.min);
setHi(price.max);
emit({ lo: price.min, hi: price.max });
}}
>
{format.format(lo)}–{format.format(hi)}
</Chip>
)}
{Array.from(selectedBrands).map((v) => (
<Chip key={v} onClear={() => toggleBrand(v)}>
{brands.find((b) => b.value === v)?.label ?? v}
</Chip>
))}
{minRating !== null && (
<Chip
onClear={() => {
setMinRating(null);
emit({ rating: null });
}}
>
{minRating}★ & up
</Chip>
)}
</div>
)}
<Section title="Price">
<div className="px-1 pt-2">
<div className="relative h-4">
<span className="absolute top-1/2 h-1 w-full -translate-y-1/2 rounded-full bg-secondary" />
<span
className="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-foreground"
style={{ left: `${loPct}%`, right: `${100 - hiPct}%` }}
/>
<input
type="range"
aria-label="Minimum price"
min={price.min}
max={price.max}
step={step}
value={lo}
onChange={(e) => setLoClamped(Number(e.target.value))}
className="pointer-events-none absolute inset-0 w-full appearance-none bg-transparent [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-foreground [&::-moz-range-thumb]:shadow-border [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:shadow-border"
/>
<input
type="range"
aria-label="Maximum price"
min={price.min}
max={price.max}
step={step}
value={hi}
onChange={(e) => setHiClamped(Number(e.target.value))}
className="pointer-events-none absolute inset-0 w-full appearance-none bg-transparent [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-foreground [&::-moz-range-thumb]:shadow-border [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:shadow-border"
/>
</div>
<div className="mt-1 flex justify-between text-xs tabular-nums text-muted-foreground">
<span>{format.format(lo)}</span>
<span>{format.format(hi)}</span>
</div>
</div>
</Section>
<Section title="Brand">
<ul className="flex flex-col gap-0.5 pt-1">
{brands.map((b) => {
const on = selectedBrands.has(b.value);
return (
<li key={b.value}>
<label className="flex cursor-pointer items-center gap-2.5 rounded-md px-1 py-1.5 transition-colors duration-150 hover:bg-accent/40">
<input
type="checkbox"
className="sr-only"
checked={on}
onChange={() => toggleBrand(b.value)}
/>
<span
aria-hidden
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-[5px] transition-[background-color,box-shadow] duration-150 ease-out",
on
? "bg-primary text-primary-foreground"
: "shadow-[inset_0_0_0_1.5px_var(--color-input)]",
)}
>
<Check
className={cn(
"size-3 transition-[scale,opacity] duration-150 ease-out",
on ? "scale-100 opacity-100" : "scale-50 opacity-0",
)}
/>
</span>
<span className="flex-1 text-sm text-card-foreground">
{b.label}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{b.count}
</span>
</label>
</li>
);
})}
</ul>
</Section>
{ratings && ratings.length > 0 && (
<Section title="Rating" last>
<ul className="flex flex-col gap-0.5 pt-1">
{ratings.map((r) => {
const stars = Number(r.value);
const on = minRating === stars;
return (
<li key={r.value}>
<button
type="button"
aria-pressed={on}
onClick={() => {
const next = on ? null : stars;
setMinRating(next);
emit({ rating: next });
}}
className={cn(
"flex w-full items-center gap-2 rounded-md px-1 py-1.5 text-left outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring",
on ? "bg-accent/50" : "hover:bg-accent/40",
)}
>
<span className="flex" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn(
"size-3.5",
i < stars
? "fill-warning text-warning"
: "text-muted-foreground/40",
)}
/>
))}
</span>
<span className="flex-1 text-xs text-card-foreground">
& up
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{r.count}
</span>
</button>
</li>
);
})}
</ul>
</Section>
)}
</aside>
);
}
function Chip({
children,
onClear,
}: {
children: React.ReactNode;
onClear: () => void;
}) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-secondary py-0.5 pl-2 pr-1 text-xs text-foreground tabular-nums">
{children}
<button
type="button"
aria-label="Remove filter"
onClick={onClear}
className="flex size-4 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground"
>
<X className="size-3" aria-hidden />
</button>
</span>
);
}
function Section({
title,
children,
last,
}: {
title: string;
children: React.ReactNode;
last?: boolean;
}) {
const [open, setOpen] = React.useState(true);
const id = React.useId();
return (
<div className={cn(!last && "border-b border-border")}>
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between py-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{title}
</span>
<ChevronDown
aria-hidden
className={cn(
"size-4 text-muted-foreground transition-transform duration-200 ease-out motion-reduce:transition-none",
open && "rotate-180",
)}
/>
</button>
<div
id={id}
className="grid transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-in-out) motion-reduce:transition-none"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden pb-2">{children}</div>
</div>
</div>
);
}