# Filter Bar

Arrange search, filters and actions above a collection.

Projects with search in the tab bar

[Complete TSX file](/examples/filter-bar/default.tsx)

```tsx
"use client";

import { SearchIcon } from "lucide-react";
import { useState } from "react";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/siteplane/components/empty-state";
import {
  FilterBar,
  FilterBarControls,
  FilterBarTabs,
} from "@/siteplane/components/filter-bar";
import { MotionReveal } from "@/siteplane/components/motion";
import {
  ResourceList,
  ResourceListContent,
  ResourceListDescription,
  ResourceListIndicator,
  ResourceListItem,
  ResourceListMeta,
  ResourceListTitle,
} from "@/siteplane/components/resource-list";
import { TabsTab } from "@/siteplane/components/tabs";

type View = "mine" | "recent" | "starred" | "templates";

interface Project {
  id: string;
  name: string;
  detail: string;
  status: string;
  tone: "success" | "info" | "neutral";
  views: View[];
}

const projects: Project[] = [
  {
    id: "northlight-website",
    name: "Northlight website",
    detail: "Edited 2 hours ago",
    status: "Live",
    tone: "success",
    views: ["mine", "recent", "starred"],
  },
  {
    id: "customer-portal",
    name: "Customer portal",
    detail: "Edited yesterday",
    status: "In review",
    tone: "info",
    views: ["mine", "recent"],
  },
  {
    id: "onboarding-flow",
    name: "Onboarding flow",
    detail: "Edited 3 days ago",
    status: "Draft",
    tone: "neutral",
    views: ["mine", "starred"],
  },
  {
    id: "pricing-page",
    name: "Pricing page",
    detail: "Ben Carter · Viewed yesterday",
    status: "Live",
    tone: "success",
    views: ["recent"],
  },
  {
    id: "help-center",
    name: "Help center",
    detail: "Jonas Weber · Viewed 4 days ago",
    status: "Live",
    tone: "success",
    views: ["recent", "starred"],
  },
  {
    id: "landing-template",
    name: "Landing page",
    detail: "Template · Marketing",
    status: "Template",
    tone: "neutral",
    views: ["templates"],
  },
  {
    id: "dashboard-template",
    name: "Dashboard",
    detail: "Template · Product",
    status: "Template",
    tone: "neutral",
    views: ["templates"],
  },
  {
    id: "blog-template",
    name: "Blog",
    detail: "Template · Content",
    status: "Template",
    tone: "neutral",
    views: ["templates"],
  },
];

export default function Example() {
  const [view, setView] = useState<View>("mine");
  const [query, setQuery] = useState("");

  // A query searches every project; without one the list shows the selected view.
  const text = query.trim().toLowerCase();
  const results = projects.filter((project) =>
    text
      ? `${project.name} ${project.detail}`.toLowerCase().includes(text)
      : project.views.includes(view),
  );

  return (
    <div className="grid w-full max-w-xl gap-3">
      <FilterBar aria-label="Project filters">
        <FilterBarControls>
          <FilterBarTabs
            aria-label="Project view"
            inputProps={{ "aria-label": "Search projects" }}
            onQueryChange={setQuery}
            onValueChange={setView}
            query={query}
            value={view}
          >
            <TabsTab value="mine">My projects</TabsTab>
            <TabsTab value="recent">Recently viewed</TabsTab>
            <TabsTab value="starred">Starred</TabsTab>
            <TabsTab value="templates">Templates</TabsTab>
          </FilterBarTabs>
        </FilterBarControls>
      </FilterBar>
      <ResourceList>
        {results.map((project) => (
          <ResourceListItem key={project.id}>
            <ResourceListIndicator tone={project.tone} />
            <ResourceListContent>
              <ResourceListTitle>{project.name}</ResourceListTitle>
              <ResourceListDescription>
                {project.detail}
              </ResourceListDescription>
            </ResourceListContent>
            <ResourceListMeta>{project.status}</ResourceListMeta>
          </ResourceListItem>
        ))}
      </ResourceList>
      <MotionReveal open={results.length === 0}>
        <Empty className="py-10">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <SearchIcon aria-hidden="true" />
            </EmptyMedia>
            <EmptyTitle>No projects found</EmptyTitle>
            <EmptyDescription>
              No project matches your search. Try another name.
            </EmptyDescription>
          </EmptyHeader>
        </Empty>
      </MotionReveal>
    </div>
  );
}
```

Filter Bar is the strip above a list or table that holds what narrows it: view tabs, a search field, filter menus and actions, with the applied filters underneath. It only arranges those controls; your application owns the query and the filters and filters the collection itself.

## Installation

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/filter-bar
```

With npm: `npx shadcn@4.18.0 add @siteplane/filter-bar`. [Registry JSON](/registry/filter-bar.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/date-range-picker @siteplane/empty-state @siteplane/input-group @siteplane/menu @siteplane/resource-list @siteplane/tag-group
```

## Usage

```tsx
import {
  FilterBar,
  FilterBarControls,
  FilterBarTabs,
} from "@/siteplane/components/filter-bar";
import { TabsTab } from "@/siteplane/components/tabs";

<FilterBar aria-label="Project filters">
  <FilterBarControls>
    <FilterBarTabs aria-label="Project view" defaultValue="mine">
      <TabsTab value="mine">My projects</TabsTab>
      <TabsTab value="starred">Starred</TabsTab>
    </FilterBarTabs>
  </FilterBarControls>
</FilterBar>;
```

Or keep a search field in view and add actions:

```tsx
import { Button } from "@/siteplane/components/button";
import {
  FilterBar,
  FilterBarActions,
  FilterBarControls,
  FilterBarSearch,
} from "@/siteplane/components/filter-bar";
import { Input } from "@/siteplane/components/input";

<FilterBar aria-label="Project filters">
  <FilterBarControls>
    <FilterBarSearch>
      <Input aria-label="Search projects" type="search" />
    </FilterBarSearch>
    <FilterBarActions>
      <Button>New project</Button>
    </FilterBarActions>
  </FilterBarControls>
</FilterBar>;
```

## Composition

```text
FilterBar
├── FilterBarControls
│   ├── FilterBarTabs (view tabs with a search segment)
│   ├── FilterBarSearch
│   ├── FilterBarFilters
│   └── FilterBarActions
└── FilterBarActiveFilters
```

## Search in the tab bar

`FilterBarTabs` puts the search into the tab bar, for a list with a few views where a permanent search field would take room; the example at the top of the page is this bar above a project list. A Search segment leads the bar. Choosing it moves the tab indicator to Search, fades the tabs out and opens a field in the same bar, with the focus in the field. Escape or the clear button empties the search and returns to the tabs, with the selected tab unchanged and the focus back on Search. When the search must stay in view, or the tabs would not fit beside it, place `FilterBarSearch` next to a `TabsList` instead. The bar takes the rest of its row. With a filter beside it, set `grow={false}`: the bar is then as wide as its tabs, its surface ends after the last tab, and the filters keep their place at the end of the row. The search opens in that width.

The tabs are ordinary [`TabsTab`](/docs/components/tabs) elements: `value`, `defaultValue` and `onValueChange` work as on Tabs, and a tab can be disabled or carry an icon. The search keeps its own state. Read `query` to filter the collection. `searching` tells you whether the field is open, for a list that treats an open search differently from the selected view. Both can be controlled with `query` and `searching`, or left to the bar with `defaultQuery` and `defaultSearching`; closing the search empties the query, so a hidden query never filters the list.

```tsx
<FilterBarTabs
  aria-label="Project view"
  onQueryChange={setQuery}
  onSearchingChange={setSearching}
  onValueChange={setView}
  query={query}
  searching={searching}
  value={view}
>
  …
</FilterBarTabs>
```

Set `variant="pill"` to round the bar and the tabs fully, and `size` to match other tabs on the page. Set `searchLabel`, `clearLabel` and `inputProps` (`aria-label`, `placeholder`) for another language or a more specific name. The field is a search input named after the segment unless `inputProps` gives another name.

Tab reaches Search and then the selected tab; arrow keys move between the tabs, and Enter or Space selects one. Enter or Space on Search opens the field and moves the focus into it. Escape closes the search from the field, the clear button or the Search segment. On a narrow screen Search stays in place while the tabs scroll, and the edge where more tabs wait fades, as in a [Scroll Area](/docs/components/scroll-area/#fade-the-edges) with `scrollFade`. While the tabs overflow, the strip that scrolls them takes a tab stop of its own, so keyboard users can scroll it with the arrow keys. Reduced motion makes the swap instant.

## Filter menu and active filters

Put filter menus and a Clear button in `FilterBarFilters`, and list the applied filters in `FilterBarActiveFilters`. Removing a tag or choosing Clear updates the same application state, and the list below follows it.

Set `open` to whether any filter applies. When the last filter goes, the row collapses with a fade while it still shows its last tags, and the space above it closes with it, so the collection glides up instead of jumping. The row takes that space from the bar's `--filter-bar-gap`, 12 px or 8 px in the compact density; to change the spacing, set `[--filter-bar-gap:…]` on the FilterBar rather than a gap utility.

Accounts with a status filter and removable tags

[Complete TSX file](/examples/filter-bar/removable-filters.tsx)

```tsx
"use client";

import { SearchIcon, SlidersHorizontalIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/siteplane/components/button";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/siteplane/components/empty-state";
import {
  FilterBar,
  FilterBarActiveFilters,
  FilterBarControls,
  FilterBarFilters,
  FilterBarSearch,
} from "@/siteplane/components/filter-bar";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/siteplane/components/input-group";
import {
  Menu,
  MenuCheckboxItem,
  MenuPopup,
  MenuTrigger,
} from "@/siteplane/components/menu";
import { MotionReveal } from "@/siteplane/components/motion";
import {
  ResourceList,
  ResourceListContent,
  ResourceListDescription,
  ResourceListIndicator,
  ResourceListItem,
  ResourceListMeta,
  ResourceListTitle,
} from "@/siteplane/components/resource-list";
import { Tag, TagGroup } from "@/siteplane/components/tag-group";

const statuses = ["Active", "Paused", "At risk"];

const accounts = [
  { name: "Acme Corp", owner: "Ben Carter", status: "Active", tone: "success" },
  {
    name: "Brightwave Studio",
    owner: "Jonas Weber",
    status: "At risk",
    tone: "warning",
  },
  {
    name: "Cedar & Pine",
    owner: "Ben Carter",
    status: "Paused",
    tone: "neutral",
  },
  {
    name: "Delta Freight",
    owner: "Priya Shah",
    status: "Active",
    tone: "success",
  },
  {
    name: "Evergreen Health",
    owner: "Jonas Weber",
    status: "Active",
    tone: "success",
  },
  {
    name: "Fjord Analytics",
    owner: "Priya Shah",
    status: "At risk",
    tone: "warning",
  },
] as const;

export default function Example() {
  const [query, setQuery] = useState("");
  const [selected, setSelected] = useState(["Active"]);

  function setStatus(status: string, checked: boolean) {
    setSelected((current) =>
      checked
        ? [...current, status]
        : current.filter((item) => item !== status),
    );
  }

  // Every applied filter narrows the list; with none applied it shows every account.
  const text = query.trim().toLowerCase();
  const results = accounts.filter(
    (account) =>
      (selected.length === 0 || selected.includes(account.status)) &&
      `${account.name} ${account.owner}`.toLowerCase().includes(text),
  );

  return (
    <div className="grid w-full max-w-xl gap-3">
      <FilterBar aria-label="Account filters">
        <FilterBarControls>
          <FilterBarSearch>
            <InputGroup>
              <InputGroupAddon>
                <SearchIcon />
              </InputGroupAddon>
              <InputGroupInput
                aria-label="Search accounts"
                onValueChange={setQuery}
                placeholder="Search accounts"
                type="search"
                value={query}
              />
            </InputGroup>
          </FilterBarSearch>
          <FilterBarFilters className="ms-auto">
            <Menu>
              <MenuTrigger render={<Button variant="outline" />}>
                <SlidersHorizontalIcon />
                Status
              </MenuTrigger>
              <MenuPopup align="end">
                {statuses.map((status) => (
                  <MenuCheckboxItem
                    checked={selected.includes(status)}
                    key={status}
                    onCheckedChange={(checked) => setStatus(status, checked)}
                  >
                    {status}
                  </MenuCheckboxItem>
                ))}
              </MenuPopup>
            </Menu>
            <Button
              onClick={() => {
                setQuery("");
                setSelected([]);
              }}
              variant="ghost"
            >
              <XIcon />
              Clear
            </Button>
          </FilterBarFilters>
        </FilterBarControls>
        <FilterBarActiveFilters open={selected.length > 0}>
          <TagGroup aria-label="Active filters">
            {selected.map((status) => (
              <Tag
                key={status}
                onRemove={() => setStatus(status, false)}
                removeLabel={`Remove ${status} filter`}
              >
                {status}
              </Tag>
            ))}
          </TagGroup>
        </FilterBarActiveFilters>
      </FilterBar>
      <ResourceList>
        {results.map((account) => (
          <ResourceListItem key={account.name}>
            <ResourceListIndicator tone={account.tone} />
            <ResourceListContent>
              <ResourceListTitle>{account.name}</ResourceListTitle>
              <ResourceListDescription>{account.owner}</ResourceListDescription>
            </ResourceListContent>
            <ResourceListMeta>{account.status}</ResourceListMeta>
          </ResourceListItem>
        ))}
      </ResourceList>
      <MotionReveal open={results.length === 0}>
        <Empty className="py-10">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <SearchIcon aria-hidden="true" />
            </EmptyMedia>
            <EmptyTitle>No accounts found</EmptyTitle>
            <EmptyDescription>
              No account matches these filters. Remove one or choose Clear.
            </EmptyDescription>
          </EmptyHeader>
        </Empty>
      </MotionReveal>
    </div>
  );
}
```

## Date range

Place a date range picker next to the search when results are filtered by time. The range is application state like the query: the example keeps the invoices whose date falls inside it.

Invoices filtered by a date range

[Complete TSX file](/examples/filter-bar/date-range.tsx)

```tsx
"use client";

import {
  CheckIcon,
  CircleAlertIcon,
  ClockIcon,
  SearchIcon,
  XIcon,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/siteplane/components/button";
import {
  DateRangePicker,
  type DateRange,
} from "@/siteplane/components/date-range-picker";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/siteplane/components/empty-state";
import {
  FilterBar,
  FilterBarControls,
  FilterBarFilters,
  FilterBarSearch,
} from "@/siteplane/components/filter-bar";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/siteplane/components/input-group";
import { MotionReveal } from "@/siteplane/components/motion";
import {
  ResourceList,
  ResourceListContent,
  ResourceListDescription,
  ResourceListIndicator,
  ResourceListItem,
  ResourceListMeta,
  ResourceListTitle,
} from "@/siteplane/components/resource-list";

const statuses = {
  Paid: { icon: <CheckIcon />, tone: "success" },
  Due: { icon: <ClockIcon />, tone: "info" },
  Overdue: { icon: <CircleAlertIcon />, tone: "error" },
} as const;

interface Invoice {
  id: string;
  customer: string;
  amount: string;
  date: Date;
  status: keyof typeof statuses;
}

const invoices: Invoice[] = [
  {
    id: "INV-1048",
    customer: "Acme Corp",
    amount: "$1,280.00",
    date: new Date(2026, 9, 2),
    status: "Paid",
  },
  {
    id: "INV-1047",
    customer: "Delta Freight",
    amount: "$3,450.00",
    date: new Date(2026, 8, 29),
    status: "Due",
  },
  {
    id: "INV-1046",
    customer: "Evergreen Health",
    amount: "$920.00",
    date: new Date(2026, 8, 21),
    status: "Paid",
  },
  {
    id: "INV-1045",
    customer: "Brightwave Studio",
    amount: "$2,100.00",
    date: new Date(2026, 8, 14),
    status: "Overdue",
  },
  {
    id: "INV-1044",
    customer: "Fjord Analytics",
    amount: "$640.00",
    date: new Date(2026, 8, 3),
    status: "Paid",
  },
  {
    id: "INV-1043",
    customer: "Cedar & Pine",
    amount: "$1,760.00",
    date: new Date(2026, 7, 26),
    status: "Paid",
  },
  {
    id: "INV-1042",
    customer: "Acme Corp",
    amount: "$890.00",
    date: new Date(2026, 7, 11),
    status: "Paid",
  },
];

const formatDate = new Intl.DateTimeFormat("en-US", {
  day: "numeric",
  month: "short",
  year: "numeric",
}).format;

export default function Example() {
  const [query, setQuery] = useState("");
  const [range, setRange] = useState<DateRange | null>({
    from: new Date(2026, 8, 1),
    to: new Date(2026, 8, 30),
  });

  // The invoice dates and the range are both whole days, so they compare directly.
  const text = query.trim().toLowerCase();
  const results = invoices.filter(
    (invoice) =>
      `${invoice.customer} ${invoice.id}`.toLowerCase().includes(text) &&
      (!range?.from || invoice.date >= range.from) &&
      (!range?.to || invoice.date <= range.to),
  );

  return (
    <div className="grid w-full max-w-xl gap-3">
      <FilterBar aria-label="Invoice filters">
        <FilterBarControls>
          <FilterBarSearch>
            <InputGroup>
              <InputGroupAddon>
                <SearchIcon />
              </InputGroupAddon>
              <InputGroupInput
                aria-label="Search invoices"
                onValueChange={setQuery}
                placeholder="Search invoices"
                type="search"
                value={query}
              />
            </InputGroup>
          </FilterBarSearch>
          <FilterBarFilters>
            <DateRangePicker
              aria-label="Invoice dates"
              onValueChange={(next) => setRange(next ?? null)}
              value={range}
            />
            <Button
              disabled={!range && query === ""}
              onClick={() => {
                setQuery("");
                setRange(null);
              }}
              variant="ghost"
            >
              <XIcon />
              Clear
            </Button>
          </FilterBarFilters>
        </FilterBarControls>
      </FilterBar>
      <ResourceList>
        {results.map((invoice) => (
          <ResourceListItem key={invoice.id}>
            <ResourceListIndicator
              icon={statuses[invoice.status].icon}
              label={invoice.status}
              tone={statuses[invoice.status].tone}
            />
            <ResourceListContent>
              <ResourceListTitle>{invoice.customer}</ResourceListTitle>
              <ResourceListDescription>
                {invoice.id} · {formatDate(invoice.date)}
              </ResourceListDescription>
            </ResourceListContent>
            <ResourceListMeta>{invoice.amount}</ResourceListMeta>
          </ResourceListItem>
        ))}
      </ResourceList>
      <MotionReveal open={results.length === 0}>
        <Empty className="py-10">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <SearchIcon aria-hidden="true" />
            </EmptyMedia>
            <EmptyTitle>No invoices found</EmptyTitle>
            <EmptyDescription>
              No invoice matches this search in the chosen dates.
            </EmptyDescription>
          </EmptyHeader>
        </Empty>
      </MotionReveal>
    </div>
  );
}
```

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| FilterBar.density | compact \| default | provider / default | Spacing density. |
| FilterBarActiveFilters.open | boolean | true | Shows the row of applied filters. false collapses it, and the space above it, with a fade. |
| FilterBarTabs.value / defaultValue | unknown | Base UI default | Controlled or initial tab, as on Tabs. |
| FilterBarTabs.onValueChange | (value, eventDetails) => void | unset | Selection change, as on Tabs. |
| FilterBarTabs.query / defaultQuery | string | empty | Controlled or initial search text. |
| FilterBarTabs.onQueryChange | (query: string) => void | unset | Called as the text is typed, and with an empty text when the search closes. |
| FilterBarTabs.searching / defaultSearching | boolean | false | Controlled or initial open state of the search field. |
| FilterBarTabs.onSearchingChange | (searching: boolean) => void | unset | Called when the search opens or closes. |
| FilterBarTabs.grow | boolean | true | The bar takes the rest of its row. false gives it the width of its tabs, so its surface ends after the last tab; the search opens in that width. |
| FilterBarTabs.searchLabel | string | Search | Visible name of the leading segment and default name of the field. |
| FilterBarTabs.clearLabel | string | Clear search | Accessible name of the clear button. |
| FilterBarTabs.inputProps | FilterBarTabsInputProps | unset | Props of the search input, such as aria-label or placeholder. |
| FilterBarTabs.variant | default \| pill | default | Rail and tab treatment, as on TabsList. |
| FilterBarTabs.size | sm \| md \| lg \| xl | provider / lg | Shared size, as on Tabs. |
| FilterBarTabs.aria-label / aria-labelledby | string | unset | Names the tab list. |
FilterBarProps extends native section props with density. FilterBarControls,
FilterBarSearch, FilterBarFilters, FilterBarActiveFilters and FilterBarActions
accept native div props, children, refs and string className;
FilterBarActiveFilters adds `open`. These parts arrange controls; none owns a
query, filter schema, fetching, selected filters or clear callback.
FilterBarTabsProps extends TabsProps without children and orientation; its
remaining props, className and refs reach the Tabs root, and its children are
TabsTab elements. Use public controls within the named layout parts.

[Read the installed source](/source/filter-bar).

## Usage notes

Keep the collection’s pending, failure and empty states outside the bar and associated with results. Do not reset a focused search input when filtering; announce useful result changes without flooding a live region.

The tabs of `FilterBarTabs` have no panels: the collection below the bar is their content, so name the tab list for what it switches, such as the project view. While the search is open the tabs are hidden from assistive technology and cannot be focused; the selected tab is restored when it closes.
