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 "My 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.
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('Nothing matches those filters', [
        'icon' => 'filter_alt_off', 'action' => 'Clear filters', 'actionHref' => '?',
    ]) ?>
<?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.
Search landmarkWith a search field the form is role="search", named like the field, so a screen reader can jump straight to it. 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 resultsBecause submission reloads the page, the new result count is announced as part of the new page. If you ever move to async filtering, add a live region for the count.
Focus after reloadA full reload returns focus to the top of the document, so a keyboard user must tab back to the controls. Consider focusing the results heading on load when filters are applied.
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