Skip to content
    Style: Wireframe

    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,073 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/ui/r/filter-sheet.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/filter-sheet.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI 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';