WooCommerce sidebar filter widgets with per-taxonomy selection rules: a plugin you can build today

· 5 min read

WooCommerce ships with a Product Categories widget and a Layered Navigation widget. Neither supports configurable selection rules. Both work the same way regardless of what they are filtering: a visitor clicks a term, the page reloads, that term is active. You cannot say that one widget replaces its selection on each click while a second one accumulates selections.

Print-on-demand stores run into this regularly. A typical catalog has two cross-cutting taxonomies: one for product type (hoodie, shirt, sweatshirt) and one for design theme (cat, dog, bird). A visitor browsing only hoodies expects clicking 'shirt' to replace 'hoodie', not add it. That same visitor selecting 'cat' expects that adding 'dog' expands the results to show both, not intersects them down to nothing. Those are two different rules, and no built-in widget expresses them.

A small plugin registers a configurable sidebar widget. Each instance gets its own taxonomy slug and its own selection mode: single-select, which replaces the current selection on each click, or multi-select OR, which adds and removes terms while showing everything that matches any active term. You can place as many instances as you need and configure each independently.

What one build gives you

  • Configurable sidebar widget with display name, taxonomy slug, and selection mode
  • Single-select mode: one active term at a time, clicking another replaces it
  • Multi-select OR mode: multiple terms active simultaneously, each click adds or removes
  • URL query parameter-based filter state, works with browser back and forward navigation
  • Compatible with any registered WordPress taxonomy including product categories

What it does not do

  • WooCommerce product attributes (pa_color, pa_size): use the built-in Layered Navigation widget for those
  • AND logic between selected terms: multi-select uses OR only
  • AJAX live filtering: each selection requires a page reload
  • Price range, stock status, or rating filtering
  • Integration with FacetWP, WOOF, or YITH Product Filter

The prompt

Loads into the composer so you can edit it first. Nothing is built, and nothing is charged, until you send it.

Build a WordPress plugin called WC Taxonomy Filter. Plugin Name: WC Taxonomy Filter. Description: A sidebar widget that filters WooCommerce products by any registered taxonomy, with configurable single-select or multi-select OR selection rules. Single PHP file. Register a widget class WC_Taxonomy_Filter_Widget that extends WP_Widget. Constructor: id_base 'wc_taxonomy_filter', name 'WC Taxonomy Filter', description 'Filters WooCommerce products by taxonomy with configurable selection rules'. Widget form (backend, method form($instance)): render three fields. First: text input for key 'title', label 'Display Name', default empty. Second: text input for key 'taxonomy', label 'Taxonomy slug (e.g. product_cat)', default 'product_cat'. Third: select input for key 'mode', label 'Selection mode', options are value 'single' with label 'Single-select (replace on click)' and value 'multi_or' with label 'Multi-select OR (add or remove)'. Use $this->get_field_id() and $this->get_field_name() for each field. Widget update (method update($new_instance, $old_instance)): return array with title => sanitize_text_field($new_instance['title']), taxonomy => sanitize_key($new_instance['taxonomy']), mode => in_array($new_instance['mode'], ['single','multi_or']) ? $new_instance['mode'] : 'single'. Widget widget (method widget($args, $instance)): read $title = $instance['title'] ?? '', $taxonomy = $instance['taxonomy'] ?? 'product_cat', $mode = $instance['mode'] ?? 'single'. Get terms: $terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => true]). If is_wp_error($terms) or empty($terms), return. Echo $args['before_widget']. If $title is non-empty, echo $args['before_title'] . esc_html($title) . $args['after_title']. Determine active selections from URL. The query parameter for each taxonomy is 'filter_' . $taxonomy. For single mode: $active = sanitize_key($_GET['filter_' . $taxonomy] ?? ''). For multi_or mode: $raw = $_GET['filter_' . $taxonomy] ?? ''; $active = array_filter(array_map('sanitize_key', explode(',', $raw))). Render: echo '<ul style="list-style:none;margin:0;padding:0;">'. For each $term in $terms, determine $is_active. For single mode: $is_active = ($active === $term->slug). For multi_or mode: $is_active = in_array($term->slug, $active, true). Build toggle URL. For single mode: if $is_active, $url = remove_query_arg('filter_' . $taxonomy); else $url = add_query_arg('filter_' . $taxonomy, $term->slug). For multi_or mode: $new_set = $is_active ? array_values(array_diff($active, [$term->slug])) : array_values(array_merge($active, [$term->slug])); if empty $new_set, $url = remove_query_arg('filter_' . $taxonomy); else $url = add_query_arg('filter_' . $taxonomy, implode(',', $new_set)). Note: add_query_arg and remove_query_arg with no third argument use the current request URI as the base URL. Render each term as: '<li><a href="' . esc_url($url) . '"' . ($is_active ? ' style="font-weight:bold;color:#0073aa;"' : '') . '>' . esc_html($term->name) . ' <span style="color:#999;font-size:0.9em;">(' . intval($term->count) . ')</span></a></li>'. After the loop, echo '</ul>' and $args['after_widget']. Register a function wc_taxonomy_filter_pre_get_posts hooked on pre_get_posts at priority 10. In this function: return if is_admin(). Return if not $query->is_main_query(). Return if not (function_exists('is_shop') && (is_shop() || is_product_taxonomy() || is_product_category())). Collect all $_GET keys that start with 'filter_'. For each such key: $taxonomy_name = sanitize_key(substr($key, 7)); if empty $taxonomy_name, skip. $raw = $_GET[$key]; $slugs = array_filter(array_map('sanitize_key', explode(',', $raw))); if empty $slugs, skip. Build a tax_query entry: ['taxonomy' => $taxonomy_name, 'field' => 'slug', 'terms' => array_values($slugs), 'operator' => 'IN']. Collect all entries into $new_entries. If $new_entries is non-empty: $existing = $query->get('tax_query') ?: []; $merged = array_merge($existing, $new_entries); $merged['relation'] = 'AND'; $query->set('tax_query', $merged). At the bottom of the file: add_action('widgets_init', function(){ register_widget('WC_Taxonomy_Filter_Widget'); }); add_action('pre_get_posts', 'wc_taxonomy_filter_pre_get_posts'); No database writes. No admin menu. No settings page.

Build this pluginAbout 55 credits · the free plan includes enough for one

Why existing options fall short

The built-in WooCommerce Product Categories widget navigates to the category archive page but does not support multi-select. Clicking a subcategory replaces the current view. There is no way to show products from two categories at once.

WooCommerce Layered Navigation filters by product attributes, not product taxonomies. It handles the pa_color and pa_size attributes WooCommerce generates, but it does not filter by product_cat or by custom taxonomies registered outside the attribute system.

Third-party filter plugins such as WOOF, FacetWP, and YITH Product Filter solve this problem fully, but they come with admin screens, database tables, settings to manage, and price tags that exceed the problem. For a store that needs two filter widgets with different rules, the dependency is larger than the job.

What the plugin does

The plugin registers a WordPress widget called WC Taxonomy Filter. Each widget instance is configured with three fields: a display name for the sidebar heading, a taxonomy slug (for example product_cat for categories, or the slug of any custom taxonomy registered on your site), and a selection mode.

Single-select mode behaves like a radio button list. The currently active term is shown in bold. Clicking a different term navigates to a URL where that term is active and the previous one is gone. Clicking the active term again removes the filter entirely.

Multi-select OR mode behaves like a checklist. Clicking any term adds it to the active set. Clicking it again removes it. The archive page shows every product that belongs to any active term. Selecting cat and dog shows all cat products and all dog products, not only products that are both.

  • Configurable widget with display name, taxonomy slug, and selection mode
  • Single-select: one term active at a time, clicking another replaces it
  • Multi-select OR: multiple terms active simultaneously, each click adds or removes
  • Filter state carried in URL query parameters, compatible with browser back and forward navigation
  • Works on the shop archive and any taxonomy archive page
  • Term counts shown next to each term name

What the plugin does not cover

WooCommerce product attributes, pa_color and pa_size, are already handled by the built-in Layered Navigation widget. This plugin works alongside it: both can live in the same sidebar without conflict.

Multi-select AND logic is not supported. Selecting cat and dog in multi-select OR mode shows products from either category. A product must match only one of the selected terms, not all of them. If your use case requires strict intersection, that requires a different architecture and a fuller conversation with Steem.

Every selection reloads the page. There is no AJAX live filtering. For a catalog of a few hundred products on a standard host, the reload is fast enough that most visitors will not notice. For a catalog of tens of thousands of products with complex queries, ask Steem to add AJAX filtering as a second pass.

Adding it to your sidebar

Once the plugin is installed, open Appearance and then Widgets. Drag a WC Taxonomy Filter widget into your shop sidebar. Set the display name to whatever you want the heading to read. Enter the taxonomy slug: product_cat for product categories, product_tag for product tags, or the slug of any custom taxonomy registered on your site. Choose Single-select or Multi-select OR from the mode dropdown. Save.

Repeat for each taxonomy you want to expose. A typical POD store needs two instances: one for product type set to single-select, one for design theme set to multi-select OR. Each widget manages its own URL parameter independently.

The filter parameters look like ?filter_product_cat=hoodies&filter_design=cat,dog on the URL. If you use a caching plugin, most will treat each filtered URL as a separate page and cache it correctly. If you see stale results after filtering, check that your cache is not normalizing or stripping query strings.

Questions

Can I use this to filter by a custom taxonomy I registered myself?

Yes. Enter the taxonomy slug exactly as you registered it with register_taxonomy. The widget works with any taxonomy attached to the product post type, whether you created it or a plugin did.

The filter works on page one of the archive but shows wrong results on page two.

WooCommerce generates pagination links from its own URL structure and can drop query string parameters in the process. Ask Steem to add a filter on the pagination links to carry the filter parameters forward across pages.

Will this conflict with WooCommerce's built-in Layered Navigation widget?

No. WooCommerce Layered Navigation uses filter_<attribute> parameters for product attributes. This plugin uses the same naming convention for any taxonomy you configure. They do not interfere with each other and can both appear in the same sidebar.

I need AND logic: show only products that match all selected terms simultaneously.

Multi-select OR is the only mode this plugin ships. For AND logic, ask Steem to add a third mode option that changes the WP_Query operator from IN to AND. Keep in mind that AND logic across many terms often returns very few or zero results, which is confusing for visitors unless the catalog is specifically structured for it.

Can I show this on a non-WooCommerce site to filter regular posts?

Not without modification. The pre_get_posts hook only runs on WooCommerce shop and taxonomy archive pages. Ask Steem to add a shortcode version that renders the filter list inline and triggers on any archive type, using the same URL parameter approach.

The prompt

Loads into the composer so you can edit it first. Nothing is built, and nothing is charged, until you send it.

Build a WordPress plugin called WC Taxonomy Filter. Plugin Name: WC Taxonomy Filter. Description: A sidebar widget that filters WooCommerce products by any registered taxonomy, with configurable single-select or multi-select OR selection rules. Single PHP file. Register a widget class WC_Taxonomy_Filter_Widget that extends WP_Widget. Constructor: id_base 'wc_taxonomy_filter', name 'WC Taxonomy Filter', description 'Filters WooCommerce products by taxonomy with configurable selection rules'. Widget form (backend, method form($instance)): render three fields. First: text input for key 'title', label 'Display Name', default empty. Second: text input for key 'taxonomy', label 'Taxonomy slug (e.g. product_cat)', default 'product_cat'. Third: select input for key 'mode', label 'Selection mode', options are value 'single' with label 'Single-select (replace on click)' and value 'multi_or' with label 'Multi-select OR (add or remove)'. Use $this->get_field_id() and $this->get_field_name() for each field. Widget update (method update($new_instance, $old_instance)): return array with title => sanitize_text_field($new_instance['title']), taxonomy => sanitize_key($new_instance['taxonomy']), mode => in_array($new_instance['mode'], ['single','multi_or']) ? $new_instance['mode'] : 'single'. Widget widget (method widget($args, $instance)): read $title = $instance['title'] ?? '', $taxonomy = $instance['taxonomy'] ?? 'product_cat', $mode = $instance['mode'] ?? 'single'. Get terms: $terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => true]). If is_wp_error($terms) or empty($terms), return. Echo $args['before_widget']. If $title is non-empty, echo $args['before_title'] . esc_html($title) . $args['after_title']. Determine active selections from URL. The query parameter for each taxonomy is 'filter_' . $taxonomy. For single mode: $active = sanitize_key($_GET['filter_' . $taxonomy] ?? ''). For multi_or mode: $raw = $_GET['filter_' . $taxonomy] ?? ''; $active = array_filter(array_map('sanitize_key', explode(',', $raw))). Render: echo '<ul style="list-style:none;margin:0;padding:0;">'. For each $term in $terms, determine $is_active. For single mode: $is_active = ($active === $term->slug). For multi_or mode: $is_active = in_array($term->slug, $active, true). Build toggle URL. For single mode: if $is_active, $url = remove_query_arg('filter_' . $taxonomy); else $url = add_query_arg('filter_' . $taxonomy, $term->slug). For multi_or mode: $new_set = $is_active ? array_values(array_diff($active, [$term->slug])) : array_values(array_merge($active, [$term->slug])); if empty $new_set, $url = remove_query_arg('filter_' . $taxonomy); else $url = add_query_arg('filter_' . $taxonomy, implode(',', $new_set)). Note: add_query_arg and remove_query_arg with no third argument use the current request URI as the base URL. Render each term as: '<li><a href="' . esc_url($url) . '"' . ($is_active ? ' style="font-weight:bold;color:#0073aa;"' : '') . '>' . esc_html($term->name) . ' <span style="color:#999;font-size:0.9em;">(' . intval($term->count) . ')</span></a></li>'. After the loop, echo '</ul>' and $args['after_widget']. Register a function wc_taxonomy_filter_pre_get_posts hooked on pre_get_posts at priority 10. In this function: return if is_admin(). Return if not $query->is_main_query(). Return if not (function_exists('is_shop') && (is_shop() || is_product_taxonomy() || is_product_category())). Collect all $_GET keys that start with 'filter_'. For each such key: $taxonomy_name = sanitize_key(substr($key, 7)); if empty $taxonomy_name, skip. $raw = $_GET[$key]; $slugs = array_filter(array_map('sanitize_key', explode(',', $raw))); if empty $slugs, skip. Build a tax_query entry: ['taxonomy' => $taxonomy_name, 'field' => 'slug', 'terms' => array_values($slugs), 'operator' => 'IN']. Collect all entries into $new_entries. If $new_entries is non-empty: $existing = $query->get('tax_query') ?: []; $merged = array_merge($existing, $new_entries); $merged['relation'] = 'AND'; $query->set('tax_query', $merged). At the bottom of the file: add_action('widgets_init', function(){ register_widget('WC_Taxonomy_Filter_Widget'); }); add_action('pre_get_posts', 'wc_taxonomy_filter_pre_get_posts'); No database writes. No admin menu. No settings page.

Build this pluginAbout 55 credits · the free plan includes enough for one

Read next

Other plugins you can build this way

Each loads into the composer, ready to edit.