Hi Timmse, I’m going to test your solution, which seems much simpler. On my end, I asked Claude Code to modify the element. Here’s my prompt:
“Is it possible to create a bricks element? I’d like to improve the “active filter” element. I’d like to be able to add a title to the active filter element, as well as a separator that would appear below the filter. Most importantly, I’d like the “hide empty elements” option to hide the title and the separator if the entire filter returns nothing.”
It would be useful to have this function on all filters, since they’re generally stacked one after the other.
Adding a Title and a Separator to the “Active filters” element — and hiding them with it
TL;DR — I needed a heading and a rule around Bricks’ Filter → Active filters element that disappear together with the filters when nothing is active. Doing it with sibling elements leaves an orphan “Active filters:” heading and a stray divider on every unfiltered page load. So I extended the native element instead of building a new one, and the “hide when empty” costs zero JavaScript.
Posting the code plus the reasoning, because the interesting part isn’t the element — it’s the three Bricks internals that constrain how you’re allowed to write it.
Tested on Bricks 2.4-rc, WordPress 7.1, PHP 8.4, WooCommerce 11.1. All line numbers below are from 2.4-rc.
The problem
The usual layout is a wrapper block containing three siblings:
Block
├── Text "Active filters:"
├── Filter - Active filters
└── Divider
Bricks core already ships .brxe-filter-active-filters:empty { display: none } in assets/css/frontend.min.css, so the filter element itself vanishes when there’s nothing to show. The Text and the Divider don’t. You get a heading over empty space and a floating rule, on every unfiltered page view.
Why I extended the native element instead of registering a new one
This was the first real decision, and it’s the one I’d get wrong if I hadn’t grepped first.
The string filter-active-filters is hardcoded in four places in Bricks 2.4-rc:
| # |
Location |
What it gates |
| 1 |
Query_Filters::filter_controls_elements() — includes/query-filters.php:864 |
Which elements in a page tree get written to the {prefix}bricks_filters_element table |
| 2 |
Query_Filters::dynamic_update_elements() — includes/query-filters.php:932 |
Which elements get_updated_filters() re-renders into the AJAX response |
| 3 |
The serialized-string map at includes/query-filters.php:785 ('filter-active-filters' => 's:21:"filter-active-filters"') |
The meta_query for a full filter re-index |
| 4 |
assets/js/filters.js:2321 — selector .brxe-filter-active-filters[data-brx-filter] [data-filter-id] |
The clear-filter click handler |
A brand-new element named my-active-filters renders as .brxe-my-active-filters and stores name: 'my-active-filters'. It gets skipped by all four: never registered as a filter element, never refreshed by AJAX, and its clear buttons are dead. I found no filter/hook in 2.4-rc that lets a differently-named element opt into those lists.
So: subclass the native element and keep its $name. Bricks\Elements::$elements is keyed by element name, and Elements::register_element() assigns self::$elements[$element_name] unconditionally — so registering under a name that’s already taken replaces the native entry rather than adding a second one to the panel.
The payoff is that nothing else has to change. Existing pages still store name: 'filter-active-filters', the root class stays brxe-filter-active-filters, indexing keeps working, and Frontend::render_element() resolves the class through Elements::$elements[$element_name]['class'] (includes/frontend.php), so the override is used for the AJAX re-render too, not just the initial page render.
Registration
add_action( 'init', function() {
// Absent when Query Filters are disabled in Bricks > Settings.
// Without this guard, `extends \Bricks\Filter_Active_Filters` is a fatal error.
if ( ! class_exists( '\Bricks\Filter_Active_Filters' ) ) {
return;
}
\Bricks\Elements::register_element(
get_stylesheet_directory() . '/elements/filter-active-filters.php',
'filter-active-filters', // same name -> replaces the native element
'ES_Filter_Active_Filters'
);
}, 11 );
Two details worth knowing:
- Priority 11. Bricks requires its native element class files from
Elements::init_elements() → register_elements(), hooked on init at the default priority 10 (includes/elements.php:13). At 11 the parent class is declared, and Elements::load_elements() — which instantiates everything — hasn’t run yet, because it’s on the later wp hook (includes/elements.php:16).
- Always pass
$element_name and $element_class_name. With both omitted, register_element() falls back to end( get_declared_classes() ) and then instantiates the element purely to read $instance->label / ->description — which are dynamic properties assigned in Bricks\Element::__construct() (includes/elements/base.php:71). Passing them explicitly skips that and is deterministic no matter what else got autoloaded before you.
The gotcha that shapes everything: the AJAX replace contract
This is the part I’d want to know before writing any custom filter element.
bricksDisplayQueryResult() in assets/js/frontend.js refreshes each filter like this:
const dummyDiv = document.createElement('div')
dummyDiv.innerHTML = filterHtml
let newFilterElement = dummyDiv.childNodes[0]
// ...
filterInstance.filterElement.replaceWith(newFilterElement)
Two consequences:
1. The returned HTML must start with the element root. It takes childNodes[0], not firstElementChild. One stray leading newline and childNodes[0] is a text node — which then replaces your element in the DOM. (filterInstance.filterElement is the node carrying data-brx-filter, i.e. the root, as registered in assets/js/filters.js around line 748.)
2. render() must never echo an empty string. With filterHtml === '', dummyDiv.childNodes[0] is undefined, and Node.replaceWith(undefined) stringifies its argument per the DOM spec’s “convert nodes into a node” step — it injects a text node reading literally undefined into the page, and destroys the node the next refresh needs to find.
So “hide when empty” cannot be “render nothing”. It has to be “render an empty root and let CSS hide it”.
Which is exactly what core already does for you:
.brxe-filter-active-filters:empty { display: none }
Emptiness is expressed in the DOM rather than computed, so it survives every AJAX refresh with no event listener. That single fact is what made the whole feature cheap.
Where the title and separator go
Inside the root <ul>, as presentational <li> — not in a wrapper <div>.
A wrapper would satisfy the childNodes[0] rule fine. What it wouldn’t survive is everything already styled on existing instances: changing the root tag silently re-targets flex-direction, gap, align-self, and the inherited buttonGap control whose css selector is '', i.e. the root. Keeping the <ul> means every already-built page renders pixel-identically after you drop this in.
It also keeps Bricks’ own Filter: Empty / Not empty interaction triggers working. For filterType === 'active-filters', bricksFilterOptionsInteractions() in assets/js/filters.js computes emptiness as:
totalOptions = filter.filterElement.innerHTML
An implementation that always rendered a title inside the root would report “not empty” forever and quietly break that native feature. Rendering nothing at all when empty keeps it honest.
Output:
<ul class="brxe-filter-active-filters" data-brx-filter="...">
<li class="brx-af-title" role="presentation"><h4>Active filters</h4></li>
<li><button aria-label="Clear filter: aeg" ...>aeg</button></li>
<li class="brx-af-separator" role="presentation" aria-hidden="true"></li>
</ul>
On the accessibility trade-off, honestly: role="presentation" strips the listitem semantics from the <li>, and it is not inherited by the heading — listitem has no required owned elements, so the <h4> is still exposed and announced as a heading. The separator is purely decorative, so it’s aria-hidden="true" and gone from the tree entirely. That said, a heading in a wrapper outside the list would be cleaner semantics. I traded that away for the AJAX root constraint above, and I think it’s the right trade — but it is a trade, and if someone has a better markup that satisfies childNodes[0], I’d genuinely like to see it.
Why render() buffers parent::render()
Bricks\Filter_Active_Filters::get_item() is private, so a subclass can’t reuse it. Re-implementing render() means copying ~150 lines of label resolution — range formatting, datepicker display formats, custom label mapping, sort and per-page options — and re-copying it on every Bricks update. Buffering the parent and splicing two <li> in is far less to maintain:
public function render() {
ob_start();
try {
parent::render();
} finally {
$html = ob_get_clean();
}
if ( ! preg_match( '#^(<ul\b[^>]*>)(.*)(</ul>)$#s', $html, $matches ) ) {
echo $html; // builder placeholder, or empty on the frontend: pass through
return;
}
list( , $open_tag, $items, $close_tag ) = $matches;
$has_items = trim( $items ) !== '';
if ( ! $has_items && ! isset( $this->settings['afShowWhenEmpty'] ) ) {
echo $open_tag . $close_tag; // strictly empty root -> :empty hides it
return;
}
echo $open_tag . $this->render_af_title() . $items . $this->render_af_separator() . $close_tag;
}
Yes, it’s a regex over HTML. Here’s why it’s safe for this specific string:
Element::render_attributes() escapes attribute values with esc_attr(), so a raw > cannot appear inside the root tag and [^>]* can’t stop early on the JSON in data-brx-filter;
- the native render loop does
$label = esc_attr( $item['label'] ) (includes/elements/filter-active-filters.php:336), so < is already entity-encoded in the items — and even if it weren’t, the greedy .* binds to the last </ul>, which is the real closing tag.
And the failure mode is benign: if a future Bricks release changes the parent’s output shape, the regex stops matching and the element passes the native output through unchanged. It degrades to stock behaviour instead of breaking.
Note the echo has no whitespace between the parts. Both :empty and childNodes[0] depend on that.
Controls
Added right after the native excludeIds control (rather than appended, so they don’t end up buried under the Icon section):
- Title — text with
hasDynamicData, HTML tag (whitelisted), typography, margin
- Separator — checkbox, style, height, color, margin
- No active filter — checkbox “Keep title and separator”
The empty behaviour is inverted deliberately: the checkbox is off by default, so the whole element disappears when nothing is active. A 'default' => true checkbox doesn’t work here, because Bricks removes the key from settings when you untick it — so a default-on checkbox can’t be turned off. Inverting the label solves it and matches the native :empty behaviour.
Gating a control behind a checkbox uses 'required' => [ 'afSeparator', '=', true ], which is the idiomatic core form — see post-toc.php:79, shortcode.php:33, search.php:188.
Alternatives I considered
Worth listing, because for a one-off two of these are genuinely better than writing PHP:
| Approach |
Verdict |
CSS :has() — .wrapper:has(> .brxe-filter-active-filters:empty) { display: none } on the wrapper block |
Works today, zero PHP, no flash. Use this if you just need it once. Downsides: needs a class on the wrapper, the title stays a separate element, no builder controls. |
| Native interaction — trigger Filter: Empty / Not empty, action hide/show |
Works with no code at all. Downside: JS-driven, so there’s a paint before it applies on load, and it’s one interaction to wire per block. |
bricks/elements/filter-active-filters/controls filter (base.php:154) |
Adds controls to a native element without subclassing — great when controls are all you need. Doesn’t let you change render(). |
| A brand-new element |
Breaks all four coupling points above. Don’t. |
The subclass earns its keep when you want it repeatable across many instances with builder controls. Arguably this belongs in core as a native option, and I’d be happy to see this thread turn into that feature request.
Caveats before you paste this
- Versions. Everything cited is from 2.4-rc. The element itself exists since 1.11 (
query-filters.php:941). I did not test older releases — if you’re on one, re-check the four coupling points and the childNodes[0] line in frontend.js before relying on this.
- Rename the class.
ES_Filter_Active_Filters is in the global namespace. Use your own prefix.
- i18n. Labels that reuse a string Bricks already ships (Title, Typography, Margin, Color, Style, Height, Separator) get translated for free. The custom ones won’t — swap in your own text domain and ship a
.po if you need them localized.
- Stylesheet position.
Element::enqueue_scripts() is called from Element::init() right before render (base.php:2949) and from Elements::load_element() when bricks_is_builder_iframe() (elements.php:380) — so it loads on the frontend and the builder canvas, only on pages that use the element. In my tests the <link> landed in <head>; if it ends up in your footer, wp_register_style() the handle on wp_enqueue_scripts and reduce the element method to wp_enqueue_style( 'handle' ).
- Reverting is clean. Delete the two files and the
add_action block. Pages keep working: the extra settings keys (afTitle, afSeparator, …) are simply ignored by the native element, and the title and rule disappear. Nothing in the database needs undoing.
- On Bricks updates, the risk is concentrated in exactly one place — the shape of the parent’s output — and, as noted above, a mismatch degrades to native rendering rather than a fatal.
The technique generalises
Nothing here is specific to active filters. Subclass a native element, keep its $name, re-register it on init priority 11 works for any Bricks element, and it’s much safer than a fresh element whenever core references that element by name. The three hooks to know, from lightest to heaviest:
bricks/elements/{name}/controls (base.php:154) — add controls, no subclass.
bricks/element/settings (base.php:3042) — rewrite settings immediately before render(). Excellent for testing an option from a mu-plugin without touching saved page data, which is how I verified all of this without editing my templates.
- Subclass +
ob_start(); parent::render(); — when the helpers you need are private on the native class.
Files attached: filter-active-filters.php (element), filter-active-filters.css (stylesheet), functions-snippet.php (registration). Feedback welcome, especially on the markup trade-off.