Skip to content
Caseroom

Components · Data

Filter sheet

The Filters button of a phone layout: the active count on the button, checkbox groups in a side sheet, Clear all and Show results.

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

Filters

Status
Framework
Region

6 of 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-sheet.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/filter-sheet.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-sheet.client.ts
// Filter sheet behaviour for Astro, HTML and React (React keeps no state of its own). Every checkbox change in a
// [data-filter-sheet] updates the trigger's count (hidden at 0) and Clear all's disabled state, and dispatches a
// bubbling `filter-change` ({ active: { [group]: string[] }, total }). Clear all unchecks every enabled box and keeps
// focus in the sheet. Opening, the focus trap, Escape and the return of focus are the Drawer's (native dialog).
declare global {
  interface Window {
    __caseroomFilterSheet?: boolean;
  }
}

const update = (sheet: HTMLElement) => {
  const boxes = [...sheet.querySelectorAll<HTMLInputElement>('input[type=checkbox]')];
  const active: Record<string, string[]> = {};
  for (const b of boxes) if (b.checked) (active[b.name] ??= []).push(b.value);
  const total = boxes.filter((b) => b.checked).length;
  const count = sheet.querySelector<HTMLElement>('[data-filter-sheet-count]');
  if (count) {
    count.hidden = total === 0;
    const n = count.querySelector<HTMLElement>('[data-filter-sheet-total]');
    if (n) n.textContent = String(total);
  }
  const clear = sheet.querySelector<HTMLButtonElement>('[data-filter-sheet-clear]');
  if (clear) clear.disabled = !boxes.some((b) => b.checked && !b.disabled);
  sheet.dispatchEvent(new CustomEvent('filter-change', { bubbles: true, detail: { active, total } }));
};

if (typeof window !== 'undefined' && !window.__caseroomFilterSheet) {
  window.__caseroomFilterSheet = true;
  document.addEventListener('change', (e) => {
    const t = e.target as Element;
    const sheet = t.closest<HTMLElement>('[data-filter-sheet]');
    if (sheet && t.matches('input[type=checkbox]')) update(sheet);
  });
  document.addEventListener('click', (e) => {
    const clear = (e.target as Element).closest<HTMLButtonElement>('[data-filter-sheet-clear]');
    const sheet = clear?.closest<HTMLElement>('[data-filter-sheet]');
    if (!clear || !sheet) return;
    e.preventDefault();
    for (const b of sheet.querySelectorAll<HTMLInputElement>('input[type=checkbox]')) if (!b.disabled) b.checked = false;
    update(sheet);
    // The button just turned itself off: keep focus on the first filter, not on the dialog.
    sheet.querySelector<HTMLInputElement>('input[type=checkbox]:not(:disabled)')?.focus();
  });
}

export {};
filter-sheet.tsx
import { cn } from '@/lib/utils';
import { Button } from '../button/button';
import { Checkbox } from '../checkbox/checkbox';
import { Drawer } from '../drawer/drawer';
import './filter-sheet.client';
import { activeCount, FILTER_PATH, filterSheet as c, type FilterSheetGroup, type FilterSheetOption } from './filter-sheet.variants';

export type { FilterSheetGroup, FilterSheetOption };

export interface FilterSheetProps {
  /** Unique on the page: the sheet's id. */
  id: string;
  groups: FilterSheetGroup[];
  /** The button and the sheet's title. */
  label?: string;
  /** The closing action, e.g. "Show 24 results". */
  apply?: string;
  variant?: 'outline' | 'default' | 'secondary' | 'ghost';
  className?: string;
}

/** A "Filters" button with the active count that opens a side sheet of checkbox groups, Clear all and Show results.
 *  Listen to `filter-change` on a parent ({ active, total }); the boxes are uncontrolled. */
export function FilterSheet({ id, groups, label = 'Filters', apply = 'Show results', variant = 'outline', className }: FilterSheetProps) {
  const total = activeCount(groups);
  const opens = { command: 'show-modal', commandfor: id } as Record<string, string>;
  return (
    <div data-filter-sheet="" className={cn(c.root, className)}>
      <Button variant={variant} data-variant={variant} {...opens} data-filter-sheet-trigger="">
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true" className={c.icon}><path d={FILTER_PATH} /></svg>
        {label}
        <span data-filter-sheet-count="" hidden={total === 0} className={c.count}><span className="sr-only">, </span><span data-filter-sheet-total="">{total}</span><span className="sr-only"> active</span></span>
      </Button>
      <Drawer
        id={id}
        title={label}
        footer={
          <>
            <button type="button" data-filter-sheet-clear="" disabled={total === 0} className={c.clear}>Clear all</button>
            <Button type="submit" value="apply">{apply}</Button>
          </>
        }
      >
        <div className={c.form}>
          {groups.map((g) => (
            <fieldset key={g.name} className={c.group}>
              <legend className={c.legend}>{g.legend}</legend>
              {g.options.map((o) => (
                <label key={o.value} className={c.option}>
                  <Checkbox name={g.name} value={o.value} defaultChecked={o.checked} disabled={o.disabled} />
                  {o.label}
                  {o.count !== undefined && <span className={c.optionCount}>{o.count}</span>}
                </label>
              ))}
            </fieldset>
          ))}
        </div>
      </Drawer>
    </div>
  );
}
filter-sheet.variants.ts
/** A filter sheet: the "Filters" button a phone layout shows instead of a row of chips. It carries the number of
 *  active filters and opens a Drawer with the filters as checkbox groups (fieldset and legend), Clear all and Show
 *  results. It filters nothing itself: listen to `filter-change` ({ active: { [group]: string[] }, total }) from
 *  [data-filter-sheet] and filter your list; Show results only closes the sheet. */
export const filterSheet = {
  root: 'inline-flex',
  // The count is part of the button's name ("Filters, 2 active"); hidden when nothing is on.
  count:
    'inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[11px] font-semibold text-primary-foreground tabular-nums [[data-variant=default]_&]:bg-primary-foreground [[data-variant=default]_&]:text-primary',
  icon: 'size-4',
  form: 'grid gap-6',
  group: 'm-0 grid min-w-0 gap-0.5 border-0 p-0',
  legend: 'mb-2 p-0 text-sm font-semibold',
  // A whole row is the target (40px), the checkbox and the label in one click.
  option: '-mx-2 flex min-h-10 cursor-pointer items-center gap-3 rounded-md px-2 text-sm transition-colors hover:bg-accent has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 has-[:disabled]:hover:bg-transparent',
  optionCount: 'ml-auto text-xs text-muted-foreground tabular-nums',
  clear:
    'mr-auto -mx-1.5 inline-flex h-9 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 FilterSheetOption = { value: string; label: string; count?: number; checked?: boolean; disabled?: boolean };
export type FilterSheetGroup = { name: string; legend: string; options: FilterSheetOption[] };

export const activeCount = (groups: FilterSheetGroup[]) => groups.reduce((n, g) => n + g.options.filter((o) => o.checked).length, 0);
export const FILTER_PATH = 'M2.5 4.5h7M12.5 4.5h1M2.5 11.5h1M6.5 11.5h7M11 3a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3M5 10a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3';