E-commerce

Category Filter Rail

A faceted filter sidebar with a dual-thumb price range, brand checkboxes with counts, star ratings, and active-filter chips.

Install

npx shadcn@latest add @paragon/category-filter-rail

category-filter-rail.tsx

"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>
  );
}