# Filter bar (Caseroom component)

Toggle chips, a sort select and a live result count; Clear turns every filter off. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

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

## 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`
- HTML: https://www.themes.dev/caseroom/r/html/filter-bar.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/filter-bar/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### filter-bar.astro

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

interface Props {
  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". Kept current by your code. */
  count: string;
  class?: string;
}
const { id, label, filters, sort, sortValue, count, class: className } = Astro.props;
const anyOn = filters.some((f) => f.pressed);
---

<div data-filter-bar class={cn(c.root, className)}>
  <div class={c.inner}>
    <ul aria-label={label} class={c.chips}>
      {
        filters.map((f) => (
          <li>
            <button type="button" data-filter={f.value} aria-pressed={f.pressed ? 'true' : 'false'} class={c.chip}>
              <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class={c.check}><path d={CHECK_PATH} /></svg>
              {f.label}
            </button>
          </li>
        ))
      }
    </ul>
    <div class={c.side}>
      <p role="status" class={c.count}>{count}</p>
      <div class={c.tools}>
        <label for={`${id}-sort`} class="sr-only">Sort by</label>
      <div class={c.sort}><Select id={`${id}-sort`} value={sortValue} options={sort} /></div>
      <button type="button" data-filter-clear disabled={!anyOn} class={c.clear}>Clear</button>
      </div>
    </div>
  </div>
</div>

<script>
  import './filter-bar.client';
</script>
```

### filter-bar.client.ts

```tsx
// Filter bar behaviour for Astro and plain HTML (React keeps state): chips toggle aria-pressed, Clear turns them all
// off, and every change dispatches a bubbling `filter-change` event ({ active, sort }) from [data-filter-bar].
declare global {
  interface Window {
    __caseroomFilter?: boolean;
  }
}

const emit = (bar: HTMLElement) => {
  const active = [...bar.querySelectorAll<HTMLElement>('[data-filter][aria-pressed=true]')].map((b) => b.dataset.filter!);
  const sort = bar.querySelector<HTMLSelectElement>('select')?.value;
  const clear = bar.querySelector<HTMLButtonElement>('[data-filter-clear]');
  if (clear) clear.disabled = active.length === 0;
  bar.dispatchEvent(new CustomEvent('filter-change', { bubbles: true, detail: { active, sort } }));
};

if (typeof window !== 'undefined' && !window.__caseroomFilter) {
  window.__caseroomFilter = true;
  document.addEventListener('click', (e) => {
    if (e.defaultPrevented) return;
    const t = e.target as Element;
    const chip = t.closest<HTMLButtonElement>('[data-filter-bar] [data-filter]');
    const clear = t.closest<HTMLButtonElement>('[data-filter-bar] [data-filter-clear]');
    const bar = (chip ?? clear)?.closest<HTMLElement>('[data-filter-bar]');
    if (!bar) return;
    if (chip) chip.setAttribute('aria-pressed', String(chip.getAttribute('aria-pressed') !== 'true'));
    if (clear) for (const b of bar.querySelectorAll('[data-filter]')) b.setAttribute('aria-pressed', 'false');
    emit(bar);
  });
  document.addEventListener('change', (e) => {
    const bar = (e.target as Element).closest<HTMLElement>('[data-filter-bar]');
    if (bar && (e.target as Element).matches('select')) emit(bar);
  });
}

export {};
```

### filter-bar.tsx

```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

```tsx
/** 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';
```
