Message us

All great projects start with a conversation.

Thank you. We received your message and will get back to you soon!

Browse our showcase

or Send another message?

👀 We're on the lookout for a Senior Digital Designer

UX & UI

How to implement intuitive multi-faceted filtering natively in Craft CMS.

James Smith

A common requirement on content-rich sites is to filter the display of content items based on multiple criteria. More specifically, based on multiple criteria groups — or facets, each of which can allow multiple selected filters within it.

You see this commonly on eCommerce sites, where products can be filtered based on their various attributes such as size, colour, material, and price etc, but we’ve also built such filtering interfaces for things like case studies, newsletters, collections of diverse resources, and property listings. You don’t need any third-party plugins, custom modules, or external web services to achieve this.

Please note: this is a technical article for Craft developers. If you are the owner of a Craft site, get in touch to find out how we can help connect your visitors with the content they’re looking for by improving the filtering capabilities of your site.

Approaches

On the surface, the technical solution to such a challenge looks reasonably straight-forward - just add up all the selected criteria and find items that match? But how exactly should you define a “match” in such a scenario?

You have three choices:

  1. Return items that match any of the selected filters (‘OR’ logic)
  2. Return items that match all of the selected filters (‘AND’ logic)
  3. Return items that match any of the selected filters in each facet while also matching all the facet selections together (a mixture of ‘OR’ and ‘AND’ logic).

Each approach is illustrated below:

1. Using “OR” logic across all facets and filters, you’ll get more results than you intuitively expect:

2. Using “AND” logic across all facets and filters, you’ll get far fewer results than you expect because each item must have all of the chosen attributes:

3. Joining the facets together with “AND” logic while joining each filter within each facet with “OR” logic gives the most intuitively expected result:

The first two options are relatively simple to implement, and work fine for filtering systems that are not truly multi-faceted (i.e., where you can only select a single filter from each facet). However, the third option is really what you need in order to meet user expectations in a multi-faceted filtering interface. This article will document how to achieve this using only native Twig and Craft functionality.

In the interests of simplicity, we’ll assume that our filterable elements are Craft Commerce Products, and our filter groups are Craft Category Groups limited to a single hierarchical level. With some relatively minor tweaks you can just as easily filter any kind of element by any kind of facet, such as section handles, other entries, tags, authors, dates, prices, or custom field values such as dropdown values or even complex nested relationships.

Our category group handles will be named in the plural form productColours and productSizes and the field handles we’ll use to relate the products to those categories will be in the singular form: productColour and productSize. (You could just as easily allow for relationships to multiple categories - it doesn’t alter the core logic here).

Wishlist

Let’s start with a basic specification of the features we’d like our filtering system to have, in addition to the requirement to combine OR and AND logic in the filtering:

  1. URL-addressable results. This is handy for users to be able to bookmark results, and to allow deep-linking to specific result sets from other parts of your website, or for sharing links externally such as in email communications. It also makes it easier to implement analytics strategies to gain insight into common filtering patterns.
  2. Fast performance. We want to avoid making per-element database queries to ensure fast load times.
  3. Accessible. The web is accessible by default... let’s not break that.
  4. Paginated results. In case there are hundreds of results, we should paginate them for a better user experience and faster loading.
  5. To only show filters that are assigned to at least one item. This reduces the chance of someone getting no results.
  6. An indication of what filters are currently applied
  7. The ability to filter by arbitrary keyword search in the same form

1. The filter form

In order for the results to be URL-addressable, we’ll need to issue a GET request with all the relevant URL parameters appended to the URL. A form element with no specified method attribute will default to submitting a GET request when the form is submitted, and automatically adds all parameters to the URL as key/value pairs. Furthermore, omitting the form’s action attribute means that it’ll default to submitting to the current URL. So that suits us perfectly:

<form>
	<h2>Product filters</h2>
	...
	<button type="submit">Apply filters</button>
</form>

In the context of a PHP application like Craft, if you append a pair of empty square brackets to the name attributes of your form inputs, the resulting parameters will be treated as arrays when they are received by the server, which is exactly what you need when dealing with multi-faceted filtering.

<form>
	<h2>Product filters</h2>

	<h3>Search by keyword</h3>
	<label for="keywords">Enter keywords</label>
	<input type="search" id="keywords" name="keywords" value="">

	<h3>Colours</h3>
	<ul>
		<li>
			<input id="red" type="checkbox" name="colours[]" value="Red">
			<label for="red">Red</label>
		</li>
		<li>
			<label for="blue">Blue</label>
			<input id="blue" type="checkbox" name="colours[]" value="Blue">
		</li>
		<li>
			<label for="purple">Purple</label>
			<input id="purple" type="checkbox" name="colours[]" value="Purple">
		</li>
	</ul>

	<h3>Sizes</h3>
	<ul>
		<li>
			<label for="small">Small</label>
			<input id="small" type="checkbox" name="sizes[]" value="Small">
		</li>
		<li>
			<label for="medium">Medium</label>
			<input id="medium" type="checkbox" name="sizes[]" value="Medium">
		</li>
		<li>
			<label for="large">Large</label>
			<input id="large" type="checkbox" name="sizes[]" value="Large">
		</li>
	</ul>
	<button type="submit">Apply filters</button>
</form>

So far so good - submitting this form will result in a standard GET request with a query string like this:

?colours[]=Red&colours[]=Blue&sizes[]=Small&sizes[]=Medium

A bit ugly and hard to read, but that’s how the web works by default and it’s always best to go with the grain. We’ll enhance this later.

Before we proceed, let’s output those filter lists dynamically so that 1) the client can create as many filters as they need in each facet, and 2) we don’t output filters that have no related products. At the same time we’ll also dynamically add the checked attribute to any checkboxes whose values are already in the query string so the user can see which filters are currently applied, and make sure that any search keywords are still populated after submission.

{# Fetch all available filters for the sidebar, but only show
ones which actually have related products #}
{% set allProductIds = craft.products.limit(null).ids() %}
{% set colourFilters = craft.categories.group('productColours').relatedTo({ sourceElement:allProductIds }).all() %}
{% set sizeFilters = craft.categories.group('productSizes').relatedTo({ sourceElement:allProductIds }).all() %}
<form>
	<h2>Product filters</h2>

	<h3>Search by keyword</h3>
	<label for="keywords">Enter keywords</label>
	<input type="search" id="keywords" name="keywords" value="{{ craft.app.request.param('keywords') ?? null }}">

	<h3>Colours</h3>
	<ul>
		{% for filter in colourFilters %}
			<li>				
				<input id="{{ filter.slug }}" type="checkbox" name="colours[]" value="{{ filter.title }}" {{ filter.slug in craft.app.request.param('colours') ? 'checked' }}>
				<label for="{{ filter.slug }}">{{ filter.title }}</label>
			</li>
		{% endfor %}			
	</ul>

	<h3>Sizes</h3>
	<ul>
		{% for filter in sizeFilters %}
			<li>				
				<input id="{{ filter.slug }}" type="checkbox" name="sizes[]" value="{{ filter.title }}" {{ filter.slug in craft.app.request.param('sizes') ? 'checked' }}>
				<label for="{{ filter.slug }}">{{ filter.title }}</label>
			</li>
		{% endfor %}
	</ul>
	<button type="submit">Apply filters</button>
</form>

(In real-world use, you should abstract those repeated filter blocks to their own template partial).

2. The unfiltered result set

For the result set, we’ll need a basic paginated element query. To start off with, we’ll output everything unfiltered like this:

{% set results = craft.products
	.limit(24)
	.with(['productColour','productSize'])
%}

{% paginate results as pageInfo, products %}

<ul>
	{% for product in products %}
		<li>
			<h3>{{ product.title }}</h3>
			<p>Colour: {{ product.productColour[0] }}</p>
			<p>Size: {{ product.productSize[0] }})</p>
			{# if you're allowing multiple selections in a given category group, you could use a `join` filter here like this #}
			{# <p>Colours: {{ product.productColour|join(', ') }}</p> #}
		</li>
	{% endfor %}
</ul>

{# PAGINATION CONTROLS #}
{% if pageInfo.nextUrl or pageInfo.prevUrl %}
	<ul>
		{% if pageInfo.prevUrl %}
			<li><a href="{{ pageInfo.prevUrl ~ queryString }}">Previous page</a></li>
		{% endif %}
		{% if pageInfo.nextUrl %}
			<li><a href="{{ pageInfo.nextUrl ~ queryString }}">Next page</a></li>
		{% endif %}
	</ul>
{% endif %}

Be sure to use Eager Loading for any assets, categories or other relationships that you want to display in the listings to avoid making per-item database queries in your main loop.

3. The filtered result set

To deal with filtered listings, we can handle the checkboxes using Craft’s relatedTo method, and the keyword search with the search method, which almost always goes hand-in-hand with orderBy('score'). We want to keep our code readable, so for now we’ll just add some variables to pass in to the methods, and gradually populate them as appropriate.

{% set keywordSearch = null %}
{% set filterParameters = null %}
...
{% set results = craft.products
	.limit(24)
	.search(keywordSearch)
	.relatedTo(filterParameters)
	.with(['productColour','productSize'])
	.orderBy('score')
%}

The keyword search is the simplest part so let’s do that first. Craft can output the value of a GET or POST parameter by passing its key into craft.app.request.param() like this:

{% set keywordSearch = craft.app.request.param('keywords') ?? null %}

Next comes the fun part - populating our filterParameters variable. First, we need to fetch the parameters from the URL in the same way we did with the keywords:

{% set colourSlugs = craft.app.request.param('colours') ?? null %}
{% set sizeSlugs = craft.app.request.param('sizes') ?? null %}

Because we named our inputs with square brackets at the end, Twig will read these parameter values into arrays (even if only one checkbox from each facet is checked).

Next, we feed those arrays into Craft element queries to get the element IDs like this:

{% set colourIds = colourSlugs ? craft.categories.group('productColours').slug(colourSlugs).ids() %}
{% set sizeIds = sizeSlugs ? craft.categories.group('productSizes').slug(sizeSlugs).ids() %}

(You could forego this step by directly using the IDs as the checkbox values instead of the slugs, but I quite like having readable URLs instead of a long string of numbers.)

By feeding arrays of slugs into Craft’s slug method, we have effectively completed the ‘OR’ logic part of our query, as this fetches all element IDs that match any of the passed-in slugs. We can now complete the ‘AND’ logic part as follows:

{# Set the filter parameter objects... #}
{% set coloursFilter = colourIds ? { targetElement: colourIds, field: 'productColour' } : null %}
{% set sizesFilter = sizeIds ? { targetElement: sizeIds, field: 'productSize' } : null %}

{# ...and stitch them all together with 'and' #}
{% set filterParameters = ['and'] %}
{% set filterParameters = coloursFilter ? filterParameters|merge([coloursFilter]) : filterParameters %}
{% set filterParameters = sizesFilter ? filterParameters|merge([sizesFilter]) : filterParameters %}

{# No filters? Nullify the variable (remember the first item in our array is the word "and", so we're checking for a length of 1) #}
{% set filterParameters = filterParameters|length == 1 ? null : filterParameters %}

For safety, we specified explicitly which field handles (productColour and productSize) Craft should examine when determining a matching category relationship. If your content model is simple you could omit those, but it’s best to keep them in, as it can cause some hard-to-find bugs as your content model expands in future and content relationships start being formed that you hadn’t envisaged.

You should now have a basic working multi-faceted filtering form. But let’s take it a little further with some extra enhancements.

Levelling up enhancements

1. Nicer URLs

HTML has no concept of arrays, so when you check multiple checkboxes and submit them as a GET request, the resulting query string will simply repeat each key value pair:

?colours[]=Red&colours[]=Blue&sizes[]=Small&sizes[]=Medium

This gets pretty ugly and can also be very long if a user has selected lots of options. With some progressive enhancement we can layer-on some JavaScript to transform those ugly query strings into pipe-separated strings like this:

?colours=Red|Blue&sizes=Small|Medium

We’ll need to add two things to our template to achieve this - First the JavaScript. This script hijacks the form submission, creates our custom query string from the submitted values, and then redirects to the current URL with the query string appended.

{% js %}
	// just be sure to give your <form> element a class of `js-filtersForm` to wire up this event:
	document.querySelector('.js-filtersForm').addEventListener('submit', function(e){
		e.preventDefault();
		const colours = Array.from(document.querySelectorAll('[name="colours[]"]:checked'),function(el){ return el.value; }).join('|');
		const sizes = Array.from(document.querySelectorAll('[name="colours[]"]:checked'),function(el){ return el.value; }).join('|');
		const queryString = '?colours=' + colours + '&sizes=' + sizes;
		window.location = window.location.href.split('?')[0] + queryString;
	});
{% endjs %}

Second, since the incoming parameters might no longer be arrays, but pipe-separated strings, we need to add some extra checks after we first fetch the parameters at the top, and convert any strings to arrays:

{% set colourSlugs = craft.app.request.param('colours') %}
{% set sizeSlugs = craft.app.request.param('sizes') %}

{# use `is iterable` to check if we have arrays. If not, use `split` to create arrays based on each pipe character #}
{% set colourSlugs = colourSlugs is iterable ? colourSlugs : colourSlugs|length ? colourSlugs|split('|') : null %}
{% set sizeSlugs = sizeSlugs is iterable  ? sizeSlugs  : sizeSlugs|length ? sizeSlugs|split('|') : null %}

2. Avoiding the possibility of getting “No Results”.

So far we made an ordinary HTML form with a submit button that submits all the chosen values in one go. However, it’s common to see multi-faceted filtering that updates every time you make a new selection. There are pros and cons to consider when deciding whether to implement such a pattern. The most compelling advantage is that you can visually update all the filters depending on what the user has selected so far. This makes it impossible for a user to ever see a “No Results” outcome. On the other hand, it can make it slower for a user to narrow down their selection if they have many facets they want to filter by, having to wait for each refresh before selecting the next facet.

The foundation given in this article should give you enough of a head start to create your own instantly-updating user interface. (Hint: the secret is to amend the sourceElement when checking which facets have related items so that you’re not passing in allProductIds, but only the ones from the current result set (and don’t forget to take pagination into account!)). Drop me a message on Craft Discord if you hit any difficulties.

3. Bells and whistles

We can use our existing keywordSearch and filterParameters variables to output different introductory text before the result set, taking care to pluralise where necessary - for example:

{% if keywordSearch %}
	<p>
		{{ pageInfo.total ? 'Your' : 'Sorry, your' }}
		search for
		<mark>{{ keywordSearch }}</mark>
		found {{ products|length }}
		{{ pageInfo.total == 1 ? 'result' : 'results' }}{{ pageInfo.total ? ':' : '.' }}
	</p>
{% elseif filterParameters %}
	<p>
		{{ pageInfo.total }}
		{{ pageInfo.total == 1 ? 'result matches' : 'results match' }}
		your filters:
	</p>
{% endif %}

Putting it all together

Here’s a complete working example of everything mentioned above, all in a single Twig template:

{# ==================================
COMPLETE EXAMPLE
===================================== #}

{% set keywordSearch = craft.app.request.param('keywords') ?? null %}

{% set colourSlugs = craft.app.request.param('colours') %}
{% set sizeSlugs = craft.app.request.param('sizes') %}

{# use `is iterable` to check if we have arrays. If not, use `split` to create arrays based on each pipe character #}
{% set colourSlugs = colourSlugs is iterable ? colourSlugs : colourSlugs|length ? colourSlugs|split('|') : null %}
{% set sizeSlugs = sizeSlugs is iterable  ? sizeSlugs  : sizeSlugs|length ? sizeSlugs|split('|') : null %}

{% set colourIds = colourSlugs ? craft.categories.group('productColours').slug(colourSlugs).ids() %}
{% set sizeIds = sizeSlugs ? craft.categories.group('productSizes').slug(sizeSlugs).ids() %}

{# Set the filter parameter objects... #}
{% set coloursFilter = colourIds ? { targetElement: colourIds, field: 'productColour' } : null %}
{% set sizesFilter = sizeIds ? { targetElement: sizeIds, field: 'productSize' } : null %}

{# ...and stitch them all together with 'and' #}
{% set filterParameters = ['and'] %}
{% set filterParameters = coloursFilter ? filterParameters|merge([coloursFilter]) : filterParameters %}
{% set filterParameters = sizesFilter ? filterParameters|merge([sizesFilter]) : filterParameters %}

{# No filters? Nullify the variable (remember the first item in our array is the word "and", so we're checking for a length of 1) #}
{% set filterParameters = filterParameters|length == 1 ? null : filterParameters %}

{# Finally, pull the trigger on the element query... #}
{% set results = craft.products
	.limit(24)
	.search(keywordSearch)
	.relatedTo(filterParameters)
	.with(['productColour','productSize'])
	.orderBy('score')
%}

{% paginate results as pageInfo, products %}

{# ==================================
FILTERS FORM
===================================== #}

{# Fetch all available filters for the sidebar, but only show
ones which actually have related products #}
{% set allProductIds = craft.products.limit(null).ids() %}
{% set colourFilters = craft.categories.group('productColours').relatedTo({ sourceElement:allProductIds }).all() %}
{% set sizeFilters = craft.categories.group('productSizes').relatedTo({ sourceElement:allProductIds }).all() %}

<form class="js-filtersForm">
	<h2>Product filters</h2>

	<h3>Search by keyword</h3>
	<label for="keywords">Enter keywords</label>
	<input type="search" id="keywords" name="keywords" value="{{ keywordSearch ?? null }}">

	<h3>Colours</h3>
	<ul>
		{% for filter in colourFilters %}
			<li>				
				<input id="{{ filter.slug }}" type="checkbox" name="colours[]" value="{{ filter.title }}" {{ filter.slug in craft.app.request.param('colours') ? 'checked' }}>
				<label for="{{ filter.slug }}">{{ filter.title }}</label>
			</li>
		{% endfor %}			
	</ul>

	<h3>Sizes</h3>
	<ul>
		{% for filter in sizeFilters %}
			<li>				
				<input id="{{ filter.slug }}" type="checkbox" name="sizes[]" value="{{ filter.title }}" {{ filter.slug in craft.app.request.param('sizes') ? 'checked' }}>
				<label for="{{ filter.slug }}">{{ filter.title }}</label>
			</li>
		{% endfor %}
	</ul>
	<button type="submit">Apply filters</button>
</form>

{# ==================================
RESULTS INTRO
===================================== #}

{% if keywordSearch %}
	<p>
		{{ pageInfo.total ? 'Your' : 'Sorry, your' }}
		search for
		<mark>{{ keywordSearch }}</mark>
		found {{ products|length }}
		{{ pageInfo.total == 1 ? 'result' : 'results' }}{{ pageInfo.total ? ':' : '.' }}
	</p>
{% elseif filterParameters %}
	<p>
		{{ pageInfo.total }}
		{{ pageInfo.total == 1 ? 'result matches' : 'results match' }}
		your filters:
	</p>
{% endif %}

{# ==================================
RESULTS LIST
===================================== #}

<ul>
	{% for product in products %}
		<li>
			<h3>{{ product.title }}</h3>
			<p>Colour: {{ product.productColour[0] }}</p>
			<p>Size: {{ product.productSize[0] }})</p>
			{# if you're allowing multiple selections in a given category group, you could use a `join` filter here like this #}
			{# <p>Colours: {{ product.productColour|join(', ') }}</p> #}
		</li>
	{% endfor %}
</ul>

{# ==================================
PAGINATION
===================================== #}

{% set queryString = craft.app.request.getQueryStringWithoutPath() ? '?' ~ craft.app.request.getQueryStringWithoutPath() : '' %}

{% if pageInfo.nextUrl or pageInfo.prevUrl %}
	<ul>
		{% if pageInfo.prevUrl %}
			<li><a href="{{ pageInfo.prevUrl ~ queryString }}">Previous page</a></li>
		{% endif %}
		{% if pageInfo.nextUrl %}
			<li><a href="{{ pageInfo.nextUrl ~ queryString }}">Next page</a></li>
		{% endif %}
	</ul>
{% endif %}

{# ==================================
SCRIPTS - Progressively enhance our URLs with 
pipe-separated strings.
===================================== #}

{% js %}
	document.querySelector('.js-filtersForm').addEventListener('submit', function(e){
		e.preventDefault();
		const colours = Array.from(document.querySelectorAll('[name="colours[]"]:checked'),function(el){ return el.value; }).join('|');
		const sizes = Array.from(document.querySelectorAll('[name="colours[]"]:checked'),function(el){ return el.value; }).join('|');
		const queryString = '?colours=' + colours + '&sizes=' + sizes;
		window.location = window.location.href.split('?')[0] + queryString;
	});
{% endjs %}

Going deeper - multiple ranges

Sometimes, facets take the form of ranges, such as with prices or dates. This adds some extra complication which is worth covering here. We recently built a multi-faceted search form for a document repository, where one of the facets allows users to filter documents published in given months. Unlike a comparatively simple price filter, this date filter requires that multiple ranges are taken into account at the same time (e.g., if the user ticks to filter documents published in January and March, the ranges in question span from 1st January to 31st January and from 1st March to 31st March, thereby not including February).

Craft is built on top of Yii, and in this case, the secret sauce to filtering items by multiple ranges is to leverage the underlying power of Yii’s query builder. Specifically, their where() or filterWhere() methods. You can use the following syntax in a Craft element query to filter by multiple ranges:

{# This will fetch entries published in Jan OR Jun OR Dec 2022: #}
{% set results = craft.entries.section('stuff').filterWhere(['or',
	['and','postDate >= '2022-01-01 00:00:00', 'postDate <= '2022-01-31 23:59:00'],
	['and','postDate >= '2022-06-01 00:00:00', 'postDate <= '2022-06-30 23:59:00'],
	['and','postDate >= '2022-12-01 00:00:00', 'postDate <= '2022-12-31 23:59:00'],
]).all() %}

With that knowledge in hand, we can then proceed to dynamically build the array based on the user’s selections, and even combine it with any other facets using relatedTo alongside the filterWhere method:

{% set monthFilters = craft.app.request.param('month') ?? null %}
{% set monthFilterParams = ['or'] %}
{% for month in monthFilters %}
	{% set startOfMonth = date(month ~ '-01')|date('Y-m-d') %}
	{% set endOfMonth = date('last day of ' ~ month)|date('Y-m-d') %}
	{% set monthFilterParams = monthFilterParams|merge([['and', "postDate >= '#{startOfMonth} 00:00:00'", "postDate <= '#{endOfMonth} 23:59:00'"]]) %}
{% endfor %}

{% set results = craft.entries.section('stuff').limit(null)
	.with(['document'])
	.relatedTo(filterParameters)
	.filterWhere(monthFilterParams)
%}

{% paginate results as pageInfo, documents %}

For range filters where you don’t need to deal with multiple simulataneous ranges, it’s a lot simpler:

{# fetch all products between £50 and £100 #}
{% set results = craft.products.price([
	'and',
	'>= ' ~ 50,
	'<= ' ~ 100,
]) %}

The possibilities for native multi-faceted filtering in Craft are extensive: you don’t need to rely on any plugins or third-party service providers, or even have any PHP knowledge to achieve powerful results and create compelling user experiences. There are lots of ways to enhance the basics here, so be sure to use these concepts as a starting point and tailor each implementation to its unique requirements.

Professional Craft CMS help

Get in touch to arrange a free audit of your Craft CMS website. We’ll help connect and convert your visitors.

Super-charge your Craft site