Siteplane UI

Filter Bar

Arrange search, filters and actions above a collection.

Markdown
Northlight websiteEdited 2 hours ago
Live
Customer portalEdited yesterday
In review
Onboarding flowEdited 3 days ago
Draft
"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

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

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

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

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:

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

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

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

Acme CorpBen Carter
Active
Delta FreightPriya Shah
Active
Evergreen HealthJonas Weber
Active
"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.

Delta FreightINV-1047 · Sep 29, 2026
$3,450.00
Evergreen HealthINV-1046 · Sep 21, 2026
$920.00
Brightwave StudioINV-1045 · Sep 14, 2026
$2,100.00
Fjord AnalyticsINV-1044 · Sep 3, 2026
$640.00
"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

Component properties
PropTypeDefaultDescription
FilterBar.densitycompact | defaultprovider / defaultSpacing density.
FilterBarActiveFilters.openbooleantrueShows the row of applied filters. false collapses it, and the space above it, with a fade.
FilterBarTabs.value / defaultValueunknownBase UI defaultControlled or initial tab, as on Tabs.
FilterBarTabs.onValueChange(value, eventDetails) => voidunsetSelection change, as on Tabs.
FilterBarTabs.query / defaultQuerystringemptyControlled or initial search text.
FilterBarTabs.onQueryChange(query: string) => voidunsetCalled as the text is typed, and with an empty text when the search closes.
FilterBarTabs.searching / defaultSearchingbooleanfalseControlled or initial open state of the search field.
FilterBarTabs.onSearchingChange(searching: boolean) => voidunsetCalled when the search opens or closes.
FilterBarTabs.growbooleantrueThe 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.searchLabelstringSearchVisible name of the leading segment and default name of the field.
FilterBarTabs.clearLabelstringClear searchAccessible name of the clear button.
FilterBarTabs.inputPropsFilterBarTabsInputPropsunsetProps of the search input, such as aria-label or placeholder.
FilterBarTabs.variantdefault | pilldefaultRail and tab treatment, as on TabsList.
FilterBarTabs.sizesm | md | lg | xlprovider / lgShared size, as on Tabs.
FilterBarTabs.aria-label / aria-labelledbystringunsetNames 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.

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.