Skip to content
    Style: Wireframe

    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,073 sites yet.

    Used in UI Pro: Integrations

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