Skip to content
Caseroom

Components · Data

Filter bar

Toggle chips, a sort select and a live result count; Clear turns every filter off.

The UI Index has no filter on its 2,062 sites yet.

12 deploys

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/filter-bar.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/filter-bar.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.

filter-bar.tsx
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Select } from '../select/select';
import { CHECK_PATH, filterBar as c, type FilterOption } from './filter-bar.variants';

export type { FilterOption };

export interface FilterBarProps {
  id: string;
  /** Names the chip group, e.g. "Filter by status". */
  label: string;
  filters: FilterOption[];
  sort: { value: string; label: string }[];
  sortValue?: string;
  /** The result line, e.g. "24 deploys". */
  count: string;
  onChange?: (detail: { active: string[]; sort?: string }) => void;
  className?: string;
}

/** Toggle chips, a sort select and the result count; Clear turns every filter off. */
export function FilterBar({ id, label, filters, sort, sortValue, count, onChange, className }: FilterBarProps) {
  const [active, setActive] = useState(filters.filter((f) => f.pressed).map((f) => f.value));
  const [order, setOrder] = useState(sortValue);
  const update = (next: string[], s = order) => {
    setActive(next);
    onChange?.({ active: next, sort: s });
  };
  return (
    <div data-filter-bar="" className={cn(c.root, className)}>
      <div className={c.inner}>
        <ul aria-label={label} className={c.chips}>
          {filters.map((f) => {
            const on = active.includes(f.value);
            return (
              <li key={f.value}>
                <button type="button" data-filter={f.value} aria-pressed={on ? 'true' : 'false'} className={c.chip} onClick={(e) => { e.preventDefault(); update(on ? active.filter((v) => v !== f.value) : [...active, f.value]); }}>
                  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={c.check}><path d={CHECK_PATH} /></svg>
                  {f.label}
                </button>
              </li>
            );
          })}
        </ul>
        <div className={c.side}>
          <p role="status" className={c.count}>{count}</p>
          <div className={c.tools}>
          <label htmlFor={`${id}-sort`} className="sr-only">Sort by</label>
          <div className={c.sort}><Select id={`${id}-sort`} defaultValue={sortValue} options={sort} onChange={(e) => { setOrder(e.target.value); onChange?.({ active, sort: e.target.value }); }} /></div>
          <button type="button" data-filter-clear="" disabled={active.length === 0} className={c.clear} onClick={(e) => { e.preventDefault(); update([]); }}>Clear</button>
          </div>
        </div>
      </div>
    </div>
  );
}
filter-bar.variants.ts
/** A filter bar: toggle chips (aria-pressed) for the categories, a sort select and the result count (role=status,
 *  so a change is announced), with Clear once anything is on. One row from a wide container, wrapping on phones.
 *  It changes no results itself: listen to `filter-change` ({ active: string[], sort }) and filter your list. */
export const filterBar = {
  root: '@container w-full',
  inner: 'flex flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:justify-between',
  chips: 'm-0 flex list-none flex-wrap items-center gap-2 p-0',
  chip:
    'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border border-input bg-background px-3 text-[13px] font-medium transition-[background-color,border-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--primary)_var(--hover-amount),var(--background))] aria-pressed:active:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] [&_svg]:size-3.5',
  check: 'hidden [[aria-pressed=true]_&]:block',
  // The count leads; the sort and Clear stay together on one line.
  side: 'flex flex-wrap items-center justify-between gap-3 @2xl:justify-end',
  tools: 'flex items-center gap-3',
  count: 'text-sm text-muted-foreground tabular-nums',
  sort: 'w-44',
  clear: '-mx-1.5 inline-flex h-8 cursor-pointer items-center rounded-md px-1.5 text-sm font-medium underline decoration-foreground/30 underline-offset-4 transition-colors hover:decoration-foreground active:bg-pressed active:decoration-2 disabled:pointer-events-none disabled:no-underline disabled:opacity-50',
};

export type FilterOption = { value: string; label: string; pressed?: boolean };
export const CHECK_PATH = 'M3.5 8.5l3 3 6-7';