Skip to content
KONIGI Patterns

Dashboards / Dashboard features / Filter bar

‹ 7 of 10 ›

Filter bar

People need to narrow the data and see which filters are active.

Updated September 10, 2026

Problem

People need to narrow a large dataset and keep track of which choices produced the current view.

Solution

Place shared filtering controls above the content. Show active selections clearly, with a way to remove each one and reset the full set.

Use labels that name the selected values rather than only the filter category. Where possible, update available choices to reflect the current selection. Explain when a combination has no matches.

Preserve filters in shareable links or saved views when appropriate. If selections persist across sessions, keep them visible when people return.

Use when

People need to narrow records using several attributes and may choose different combinations.

Don’t use when

There is one dimension and few values. Three tabs beat a filter bar with one dropdown. Also avoid a filter bar as the only way in: a page whose default state is unfiltered and unusable has pushed its information architecture onto the viewer.

Trade-offs

A growing filter bar can crowd the page. Persistent selections can be forgotten, and incompatible choices can produce confusing empty results. Screenshots need enough visible filter context to be interpreted correctly.

Checklist

  • Can a viewer see every applied filter without opening a control?
  • Is removing a filter as cheap as adding one, and is there a clear-all?
  • Do the offered values reflect the current set, or can a viewer construct an empty result?
  • If the result is empty, does the page say which filter caused it?
  • Does the filter state go into the URL?
  • Do filters persist across sessions, and does the viewer know?
  • Can filters be chained, so one narrows the options of the next?
  • How many controls before this stops being a bar and becomes a form?
  • Does everything on the page honour the filters, and do exceptions say so?
  • Does a screenshot of this page carry enough to be read correctly?

Compare

Grafana makes filters a declared object rather than an ad-hoc control: template variables are defined on the dashboard, populated by queries, chained so one constrains the next, and carried in the URL, which makes state shareable and every filter something someone had to anticipate. Honeycomb puts a query builder where the bar would be, so narrowing is composing a query and there is no fixed set of filterable attributes—the cost is a higher floor for a casual viewer. Sentry uses a single search field with a tag syntax, trading discoverability for speed once learned, and softens it with autocomplete over real tag values. Datadog scopes a whole page by tags from one control, so filters apply uniformly across every widget by construction rather than by each panel remembering to honour them.

Template variable applies shared values to panel queries. Cross-filter selects data directly in a chart. Search across panels provides text-based navigation.

Saved view preserves useful selections. Ranked list shows an ordering within the selected population.

Filter bar anatomy A filter bar showing every applied value as its own removable chip, with a clear-all beside them. Below, the same three filters collapsed into a dropdown reading "Service (3)", which has hidden the thing that decides what is on screen. The state is the bar; the content is a consequence service: checkout env: prod sev: 1 and 2 clear all 1 2 The same three, hidden Service (3) Which three? Not on screen anywhere. 1 CHIPS, NOT COUNTS A dropdown reading "Service" when three are selected has hidden what decides the screen. Chips are longer and correct. 2 REMOVAL COSTS ONE CLICK Filtering is exploratory and exploration means overshooting. One click to add and three to remove makes people stop narrowing. The open question is whether the bar offers only values that exist in the current set. Doing so prevents dead ends. Not doing so is much cheaper, and is why so many bars let you build an empty result and then wonder which choice caused it.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Narrowing a large set by several attributes, with what is applied visible without opening anything. Applied filters belong as removable chips, and the result count belongs beside them so an empty result has an explanation.

shadcn
npx shadcn@latest add button popover command
npm
lucide-react
Tokens
--card--card-foreground--muted-foreground--border--chart-1
service: checkoutenv: prodsev: 1 and 24 of 9 alerts
  • p95 latency above 800 ms for 5 minsev 1 · 14:52
  • Card auth error rate 3.1% (threshold 2%)sev 2 · 14:47
  • Queue depth 4,200 and risingsev 2 · 14:41
  • Deploy 4a91c: canary failing health checksev 1 · 14:30

FilterBar.tsxA chip per applied filter, one click to remove, clear all, a count beside them, and a menu that offers only values the other filters leave.

import { useState, type ReactNode } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";

/** One narrowing: a dimension and the values allowed through. */
export type Filter = { key: string; values: string[] };
export type Row = Record<string, string>;

/** "1 and 2", "checkout", "a, b and c". A chip says the values, never a count. */
const list = (v: string[]) => (v.length < 3 ? v.join(" and ") : `${v.slice(0, -1).join(", ")} and ${v[v.length - 1]}`);

const passes = (row: Row, filters: Filter[]) => filters.every((f) => f.values.includes(row[f.key]));

/**
 * The state is the bar; the content is a consequence. Every applied filter is
 * its own chip, removal is one click, and the offered values come from the
 * rows the other filters leave, so the viewer cannot build an empty result
 * from the menu. The count sits beside the chips, and an empty one says which
 * chip did it.
 */
export function FilterBar({ rows, keys, filters, onFiltersChange, noun = "rows", children }: {
  rows: Row[];
  /** The dimensions a viewer may filter on, in menu order. */
  keys: string[];
  filters: Filter[];
  onFiltersChange: (filters: Filter[]) => void;
  noun?: string;
  /** Renders the rows that pass. */
  children: (rows: Row[]) => ReactNode;
}) {
  const [open, setOpen] = useState(false);
  const matches = rows.filter((r) => passes(r, filters));

  // Which chip emptied the page: the one whose removal brings rows back.
  const culprit = matches.length === 0
    ? filters.find((f) => rows.some((r) => passes(r, filters.filter((g) => g !== f))))
    : undefined;

  const remove = (key: string) => onFiltersChange(filters.filter((f) => f.key !== key));
  const add = (key: string, value: string) => {
    const cur = filters.find((f) => f.key === key);
    onFiltersChange(cur
      ? filters.map((f) => (f.key === key ? { key, values: [...f.values, value] } : f))
      : [...filters, { key, values: [value] }]);
    setOpen(false);
  };
  // Options for a key come from rows that pass every other filter.
  const options = (key: string) => {
    const others = filters.filter((f) => f.key !== key);
    const chosen = filters.find((f) => f.key === key)?.values ?? [];
    return [...new Set(rows.filter((r) => passes(r, others)).map((r) => r[key]))].filter((v) => !chosen.includes(v)).sort();
  };

  return (
    <div className="rounded-lg border bg-card">
      <div className="flex flex-wrap items-center gap-2 border-b p-3">
        {filters.map((f) => (
          <span key={f.key} className="inline-flex items-center gap-1.5 rounded-md border border-chart-1 bg-chart-1/10 py-0.5 pl-2.5 pr-1 text-[11px] text-card-foreground">
            {f.key}: {list(f.values)}
            <Button variant="ghost" size="sm" className="h-5 w-5 p-0 text-chart-1" onClick={() => remove(f.key)} aria-label={`remove ${f.key} filter`}>✕</Button>
          </span>
        ))}
        {filters.length > 0 && (
          <Button variant="link" size="sm" className="h-auto p-0 text-[11px] text-muted-foreground underline underline-offset-2" onClick={() => onFiltersChange([])}>clear all</Button>
        )}
        <Popover open={open} onOpenChange={setOpen} modal={false}>
          <PopoverTrigger asChild>
            <Button variant="outline" size="sm" className="h-6 gap-1 px-2 text-[11px]"><Plus className="size-3" aria-hidden /> filter</Button>
          </PopoverTrigger>
          <PopoverContent align="start" className="w-56 p-0">
            <Command>
              <CommandInput placeholder="Narrow by…" className="h-8 text-xs" />
              <CommandList>
                <CommandEmpty>Nothing left to narrow by.</CommandEmpty>
                {keys.map((key) => (
                  <CommandGroup key={key} heading={key}>
                    {options(key).map((v) => (
                      <CommandItem key={v} value={`${key} ${v}`} onSelect={() => add(key, v)} className="text-xs">{v}</CommandItem>
                    ))}
                  </CommandGroup>
                ))}
              </CommandList>
            </Command>
          </PopoverContent>
        </Popover>
        <span className="ml-auto text-[11px] tabular-nums text-muted-foreground" aria-live="polite">
          {matches.length.toLocaleString("en-US")} of {rows.length.toLocaleString("en-US")} {noun}
        </span>
      </div>
      {culprit ? (
        <p className="p-3 text-xs text-muted-foreground">
          Nothing matches. Removing <span className="text-card-foreground">{culprit.key}: {list(culprit.values)}</span> would bring {noun} back.
        </p>
      ) : children(matches)}
    </div>
  );
}

demo.tsxHow it is called: the rows, the filterable keys, the applied filters in state, and a render function for what passes.

import { useState } from "react";
import { FilterBar, type Filter, type Row } from "./FilterBar";

/** Alerts from the last hour. Three filters applied, four rows left. */
const ALERTS: Row[] = [
  { at: "14:52", service: "checkout", env: "prod", sev: "1", title: "p95 latency above 800 ms for 5 min" },
  { at: "14:47", service: "checkout", env: "prod", sev: "2", title: "Card auth error rate 3.1% (threshold 2%)" },
  { at: "14:41", service: "checkout", env: "prod", sev: "2", title: "Queue depth 4,200 and rising" },
  { at: "14:30", service: "checkout", env: "prod", sev: "1", title: "Deploy 4a91c: canary failing health check" },
  { at: "14:28", service: "checkout", env: "prod", sev: "3", title: "Cache hit ratio below 90%" },
  { at: "14:22", service: "checkout", env: "staging", sev: "1", title: "Pod restarting in a loop" },
  { at: "14:15", service: "search", env: "prod", sev: "2", title: "Index lag 90 s" },
  { at: "14:09", service: "cart", env: "prod", sev: "1", title: "Redis primary failover" },
  { at: "13:58", service: "search", env: "staging", sev: "3", title: "Slow query logged 12 times" },
];

export default function Demo() {
  const [filters, setFilters] = useState<Filter[]>([
    { key: "service", values: ["checkout"] },
    { key: "env", values: ["prod"] },
    { key: "sev", values: ["1", "2"] },
  ]);
  return (
    <FilterBar rows={ALERTS} keys={["service", "env", "sev"]} filters={filters} onFiltersChange={setFilters} noun="alerts">
      {(rows) => (
        <ul className="flex flex-col divide-y">
          {rows.map((r) => (
            <li key={r.at + r.title} className="flex items-center gap-4 px-3 py-2 text-xs">
              <span className="text-card-foreground">{r.title}</span>
              <span className="ml-auto shrink-0 tabular-nums text-muted-foreground">sev {r.sev} · {r.at}</span>
            </li>
          ))}
        </ul>
      )}
    </FilterBar>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Tableau Public

Published workbooks show how individual authors arrange charts and interactions in Tableau.

Emergency Department / Clinical Dashboard September 11, 2026 Tableau Public embed view; emergency department patient flow workbook dense · light · desktop-web
This workbook analyzes emergency-department activity rather than showing a live bed board. Its hour-by-weekday grid makes recurring busy periods easy to locate. Monthly small multiples support seasonal comparison on a shared scale. The color ramps need careful labeling: a diverging scale on a count or duration needs a meaningful center, not an unexplained midpoint.
Show 5 more examples Hide the rest

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Alert List September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
This list shows seven alerts with their states and durations. Several have been firing for days, which raises a question about whether the rules still prompt a useful response. The names use inconsistent conventions and often do not describe the affected system clearly. Pending and firing states are labeled separately, and each row links back to its rule.
Demo / Annotations September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
Frequent red event markers make the request-rate line difficult to follow across this twenty-four-hour view. The tag filter is already active, so filtering alone has not reduced the crowding enough. The event list below is easier to read because it separates timestamps and tags into individual entries. This example shows why event density matters as much as recording the events.
  • Annotation Frequent event markers compete with the request-rate line.
  • Annotation The event list provides readable timestamps and tags.
  • Filter bar A tag filter is already applied to the annotations.
  • Time-range picker The selected twenty-four-hour range affects how densely markers appear.
Examples / Dashboard Variables September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
This dashboard demonstrates six selectors and explains how their values affect the page. Selected values appear as removable chips, making the active scope easy to read. A text panel also describes a hidden variable that has no visible control. Another note explains that changing the data source clears an instance selection. These dependencies need clear feedback when people change a value.

Kibana

A query bar and filtering controls define the data shown in charts and document tables.

Discover / filebeat logs September 10, 2026 Elastic demo environment (guest session; no version string exposed) dense · light · desktop-web
The view places a searchable field list beside a large table of documents, with a volume chart above. Search is useful for navigating the hundreds of available fields. The default Summary column combines many fields into each row, making quick comparisons difficult until people choose more focused columns. Pagination and sorting are visible, but the scope of each operation needs to be clear.
Dashboards / [Flights] Global Flight Dashboard September 10, 2026 Elastic demo environment, sample flight data (guest session) dense · light · desktop-web
The delay table shows percentages without the number of flights behind each one. A high rate based on very few flights would look as prominent as one based on many. A nearby comparison tile names the previous week but does not clearly explain what its percentage represents. The filtering controls are more explicit: a query bar sits above pickers and a price range, giving people both typed and guided ways to narrow the view.
  • Ratio and rate Delay percentages appear without their flight counts.
  • Compare periods The comparison period is named, but the meaning of the percentage is unclear.
  • Share and embed Sharing and full-screen controls are available in the header.
  • Filter bar Pickers and a range control provide alternatives to typing a query.
  • Panel grid Panels use different sizes within a grid.
  • Stacked composition A 100% stack shows the mix of delay types without total volume.
  • Annotation Grouped event markers share the chart’s time axis.
  • Header KPI strip Different tile sizes and arrangements make the summary row less consistent.