Canvas Design System
Main Site Tokens

Filter Bar

The controls above a listing grid: a search box, dropdown filters, and chips for what's currently applied.

When to Use

Use when: A listing needs GET-based search or filtering, with or without filters: exercise lists, workshop catalogues, saved-plan tables, a search box above "Your workshops". It standardises controls that were previously rebuilt per page. Its "Clear all" link beside the chips is the one clear control DESIGN.md 11.6 allows: it loads the unfiltered list, and Back undoes it. On a page routed by its query string, pass keep with the names that route it (inside /app/, ['view', 'step']), and give the no-match empty state's "Show all workshops" the same address: '?' . http_build_query(array_intersect_key($_GET, array_flip(['view', 'step']))). Put a one-line count under the bar with ws_text's caption variant ("Showing 3 of 12 workshops for “retro”") and leave focus where the reload puts it.
Don't use when: The search field belongs to a form about something else, or sits in the site header rather than above a list — use Input with type: 'search'. For client-side switching between a few fixed sets, Tabs is lighter.
check_circle Fixed July 2026. Chip remove buttons call wsFilterBarRemove(name), which the template referenced but nothing defined — every chip threw ReferenceError: wsFilterBarRemove is not defined. It now ships in components.js: it drops that parameter from the query string, resets page, and reloads, preserving all other filters and the hash. No consumer code required.

Variants

Search and filters

The common case. Filters render through Select internally, so they inherit its styling and focus treatment.

<?= ws_filter_bar([
    ['name' => 'category_id', 'label' => 'Category',
     'options' => [1 => 'Design Sprints', 2 => 'Icebreakers'], 'value' => 1],
    ['name' => 'duration', 'label' => 'Duration',
     'options' => ['short' => 'Under 30 min', 'long' => 'Over 1 hour']],
], [
    'search' => ['placeholder' => 'Search activities…'],
]) ?>

Search only

The search box above a list, with no filters: pass an empty filters array. The submit button reads "Search" rather than "Filter", and showSearchLabel puts the field's name above it as a visible label, which a placeholder is not.

<?= ws_filter_bar([], [
    'search' => ['placeholder' => 'e.g. retrospective', 'value' => $q, 'name' => 'q'],
    'searchLabel' => 'Search your workshops by name',
    'showSearchLabel' => true,
]) ?>

With active chips

Chips summarise what's applied, with a "Clear all" alongside. Each remove button names its filter, here "Remove Design Sprints filter".

<?= ws_filter_bar($filters, [
    'activeChips' => [['label' => 'Design Sprints', 'name' => 'category_id', 'value' => 1]],
]) ?>

States

StateBehavior
DefaultA GET form. Submitting puts search and filter values in the query string, so filtered views are linkable and survive a reload.
Filter selectedPass the filter's current value so the dropdown reflects the query string on redisplay.
FocusSearch and selects use the shared --focus-ring-width / --focus-ring-offset geometry.
Chips shownRendered whenever activeChips is non-empty, with a "Clear all" link: the one clear control DESIGN.md 11.6 allows, because it loads the unfiltered list and Back undoes it. Building the chip array is yours — the component doesn't derive it from the filters.
Chip removalHandled by wsFilterBarRemove() in components.js: drops that one parameter, resets page, keeps the rest of the query string and the hash, then reloads.
Empty resultsNot handled here. Pair the bar with Empty State below the grid.

Real-World Usage

The exercises listing. Chips are derived from the query string, and the missing remove function is supplied locally — it drops one parameter and reloads, which is all it needs to do.

<?php
$chips = [];
if (!empty($_GET['category_id'])) {
    $chips[] = ['label' => $categoryNames[$_GET['category_id']],
                'name'  => 'category_id',
                'value' => $_GET['category_id']];
}
if (!empty($_GET['duration'])) {
    $chips[] = ['label' => $durationLabels[$_GET['duration']],
                'name'  => 'duration',
                'value' => $_GET['duration']];
}
?>
<?= ws_filter_bar($filters, [
    'search'      => ['placeholder' => 'Search activities…', 'value' => $_GET['q'] ?? ''],
    'activeChips' => $chips,
]) ?>

<!-- Chip removal needs no wiring — wsFilterBarRemove() ships in components.js -->

<?php if (!$results): ?>
    <?= ws_empty_state('No activities match those filters', [
        'description' => 'Drop a filter, or search for one word from the name.',
        'icon' => 'filter_alt_off', 'action' => 'Show all activities', 'actionHref' => '?',
        'actionVariant' => 'secondary',
    ]) ?>
<?php endif; ?>

Options

Bar options

OptionTypeDefaultPurpose
$filtersarray[]Positional. Dropdown configs; may be empty for search-only.
searcharraynull['placeholder' => …, 'value' => …, 'name' => 'q']. Omit for filters only.
searchLabelstringthe placeholderThe search input's accessible name (its aria-label). Defaults to the placeholder less any trailing ellipsis, or "Search". Set it when the placeholder is an example rather than a description.
showSearchLabelboolfalseShows searchLabel as a visible <label> above the field; the row then lines up on the inputs.
submitLabelstring'Search' or 'Filter'The submit button's text: "Search" with no filters, "Filter" with them.
keeparray[]Query-string names the search and "Clear all" carry through, as hidden fields: inside /app/, ['view', 'step'], or a search sends the person back to Home. Inside a tab panel of a ws_tabs with a param, the panel's own tab is carried too, without listing it.
activeChipsarray[][['label' => …, 'name' => …, 'value' => …]]. You build this from the query string.
colorstring'learn'learn | plan | facilitate | reflect. learn, the default, has no rule of its own: it is the base look, and since the Red Unification the other three render the same.
classstring''Additional CSS classes on the container
attrsarray[]Extra HTML attributes on the container

Filter options

OptionTypeDefaultPurpose
namestring''Query-string parameter name
labelstring''The select's accessible name (its aria-label) and the text of its empty "any" option
optionsarray[]Flat value => label pairs, as ws_select() takes
valuestring''Currently selected value

Accessibility

ConcernBehavior
SemanticsA real GET <form> with native inputs, so filtered views are linkable and the back button behaves.
NamesEvery control is named without changing the look. Each select's aria-label is its filter label; the search input's is searchLabel, or the placeholder. They are aria-label rather than a hidden <label for> because a select's id defaults to its field name, and two bars with the same filter on one page would point both labels at the first select. A placeholder alone is not a name: it disappears as soon as someone types.
Visible labelSet showSearchLabel when the brief asks for visible labels, when the placeholder is an example ("e.g. retrospective") rather than a description, or when no heading directly above says what the field searches. Without it, the placeholder stands in for the label, so it must describe the field ("Search your workshops"). With it, the <label> wraps the field and its aria-hidden icon: the computed name is searchLabel alone, though a tool reading textContent also shows the icon's ligature word. Check names in the accessibility tree, not in textContent.
Search landmarkWith a search field the form is role="search", named like the field, so a screen reader can jump straight to it. The landmark and the field sharing one name is intended: the landmark list and the field both say what is searched. Two bars on one page need different searchLabels, or the landmark list shows two entries with the same name.
KeyboardNative throughout: Tab across search and selects, Enter in the search field submits.
Chip remove buttonsEach names its filter, aria-label="Remove Design Sprints filter", so a row of chips does not announce the same words for every button.
Announcing resultsSubmitting loads a new page, and a new page is read from the top, so nothing is announced by itself: the count has to be on the page. Put one visible line between the bar and the results, ws_text($count, ['variant' => 'caption']), in every state ("12 workshops", "Showing 3 of 12 workshops for “retro”"), worded so it does not repeat the empty state's title. Its live option adds nothing after a reload; set it only when a script changes the count without one.
Focus after reloadLeave focus where the reload puts it, at the top of the page. Do not move it to the results on load: the field and its button come before them, so the next Tab would skip both, and a screen reader starts reading mid-page with no warning. The search landmark and the count line just below it are how a screen-reader user gets back to the results.
Search iconDecorative and aria-hidden, so the icon's ligature text is not read. The field's own name carries the meaning.

Tokens

TokenUsed for
--bg-surfaceBar and search-field background
--color-borderBar and field borders
--color-ink / --color-ink-mutedField text and the chips label
--brand / --phase-facilitate / --phase-reflect, with --phase-plan-light / --phase-facilitate-light / --phase-reflect-lightColour-key accents (all red since Red Unification)
--focus-ring-width / --focus-ring-offsetFocus geometry on search and selects
--radius-lgBar and field corners
--space-2 / --space-3 / --space-4 / --space-6Control spacing and the chips row
--text-caption / --text-uiChip and control type scale
--font-body / --font-mediumFace and weight
--transition-fastHover and focus transitions

CSS Classes

ClassPurpose
.ws-filter-barThe form container
.ws-filter-bar--plan / --facilitate / --reflectColour keys. --learn, the default, is emitted by the template but has no rule: it is the base look.
.ws-filter-bar__controlsSearch and selects row
.ws-filter-bar__search / __search-input / __search-iconSearch field and its icon
.ws-filter-bar__selectsDropdown group
.ws-filter-bar__submitSubmit button
.ws-filter-bar__chips / __chips-labelActive-chip row and its label
.ws-filter-bar__clear-all"Clear all" link
.ws-filter-chipAn individual chip. Carries data-filter-name and data-filter-value.
.ws-filter-chip__clearChip remove button; calls wsFilterBarRemove()

Files

FilePurpose
includes/components/helpers.phpws_filter_bar() helper function
includes/components/filter-bar.phpTemplate — form, search, chips. Renders ws_select() for each filter.
includes/components/components.cssStyles (.ws-filter-bar and .ws-filter-chip rules)
includes/components/components.jswsFilterBarRemove() — chip removal