Skip to content

Filter bar

3 min read · Updated 28 Oct 2026

A filter bar is a row of mounted filter objects under the header, with the apply bar below it. It sticks with the header. When the row runs out of width, its filters move into a filter panel and the apply bar stays. Selections and the apply bar itself are covered in Filters and selections. Keep the apply bar row even when selections apply automatically: it is the page’s selection bar.

Filter bar: Filter controls sit below the header, with pending changes in an apply bar.

Illustrative example with synthetic data. Filter controls sit below the header, with pending changes in an apply bar.

  1. Are there more filters than fit across the narrowest width you support, or do readers filter rarely? Use a filter panel only, with the apply bar under the header.
  2. Otherwise, use a filter bar that folds into the panel.
Use Use cases Choose another when
Filter bar Three to six filters adjusted while reading: region, channel, team, priority Filters are set once per visit, there are many, or they need a tall list
Filter panel Many filters, or filters set rarely Readers need to see every filter’s value while they read
<div class="app-top">
<header class="app-header">…</header>
<div class="filter-bar">
<div class="filter-bar__row" role="group" aria-label="Filters">
<div class="filter-host" data-object-id="region-filter@bar"></div>
<div class="filter-host" data-object-id="channel-filter@bar"></div>
</div>
<div class="apply-bar" role="region" aria-label="Selections">
<div class="apply-bar__chips"></div>
<button type="button" class="apply-bar__clear">Clear all</button>
<span class="apply-bar__pending" hidden>
<button type="button">Discard</button>
<button type="button">Apply</button>
</span>
</div>
</div>
</div>

Each instance id names its placement (@bar), because the filter panel mounts its own instance of the same filter.

The bar uses the shared tokens in Tokens and thresholds.

:root {
--filter-h: 72px;
--apply-h: 40px;
}
/* On the element that contains the header and the board. */
.app {
/* 16 + 72 + 40 = 128 */
--filter-bar-h: calc(var(--gap) + var(--filter-h) + var(--apply-h));
--above-board: var(--filter-bar-h);
}
.app[data-filters='folded'],
.app[data-filters='docked'] {
/* 16 + 40 = 56 */
--filter-bar-h: calc(var(--gap) + var(--apply-h));
}
.filter-bar {
display: grid;
grid-template-rows: var(--filter-h) var(--apply-h);
box-sizing: border-box;
height: var(--filter-bar-h);
padding: var(--gap) var(--gap) 0;
}
.filter-bar__row {
--filter-min: 160px;
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(var(--filter-min), 240px);
gap: var(--gap);
min-width: 0;
overflow: hidden;
}
/* Size the host, not the filter. */
.filter-host {
height: var(--filter-h);
min-width: 0;
}
.apply-bar {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.apply-bar__chips {
display: flex;
flex: 1 1 auto;
gap: 8px;
min-width: 0;
overflow-x: auto;
}
.apply-bar__chips > * {
flex: none;
}
.app[data-filters='folded'] .filter-bar,
.app[data-filters='docked'] .filter-bar {
grid-template-rows: var(--apply-h);
}
.app[data-filters='folded'] .filter-bar__row,
.app[data-filters='docked'] .filter-bar__row {
display: none;
}
.app:not([data-filters='folded'], [data-filters='docked']) .filters-trigger {
display: none;
}
  • --above-board reads --filter-bar-h, so the board and the bar follow one value. With more than one band above the board, set --above-board once as their sum; see Bands above the board.
  • Add the bar to the layout’s scrolling threshold, for example 728 + 128 = 856.
  • Show applied filters only while something is applied, and put them in a row that is already there, such as the end of the filter row or the header, so showing them moves nothing. A separate apply bar keeps its height when empty, so applying or clearing does not move the board.
  • Use the page’s one selection group; see Use one selection group.
  • Turn responsive sizing off (presentation.responsive: false, or setFilterResponsive) and size the host; see Header and controls.
  • Use dropdown mode (setFilterMode) in the bar.
  • Turn quick select off (presentation.quickSelect: false, or setFilterQuickSelect). With it on, a choice is applied immediately and skips the apply bar.

See the filter operations.

Fold when the row’s minimum width exceeds the header’s content width. Measure the header, not the viewport. Unmount the bar’s filters when they fold; see Hidden means unmounted.

// Panel is the lifecycle class from Hidden means unmounted; one per bar instance.
declare const barFilters: { panel: Panel; host: HTMLElement }[];
const app = document.querySelector<HTMLElement>('.app');
const header = document.querySelector<HTMLElement>('.app-header');
const row = document.querySelector<HTMLElement>('.filter-bar__row');
if (!app || !header || !row) throw new Error('Header or filter bar is missing.');
// Every host at its minimum width, plus the gaps between them.
function neededWidth(row: HTMLElement): number {
const style = getComputedStyle(row);
const min = parseFloat(style.getPropertyValue('--filter-min'));
const gap = parseFloat(style.columnGap);
const count = row.children.length;
return count * min + (count - 1) * gap;
}
new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry || app.dataset.filters === 'docked') return;
const state = neededWidth(row) > entry.contentRect.width ? 'folded' : 'inline';
if (app.dataset.filters === state) return;
app.dataset.filters = state;
for (const { panel, host } of barFilters) {
if (state === 'inline') panel.show(host);
else panel.hide();
}
}).observe(header);
  • A ResizeObserver reports once when observation starts, so this also makes the first mount. Do not mount the bar’s filters anywhere else.
  • contentRect excludes the header’s padding, which equals the bar’s.
  • Pending selections belong to the group, so folding keeps them in the apply bar.

For example, five filters need 5 × 160 + 4 × 16 = 864px of row, so the bar folds below a 896px-wide header.