<p>I used the accepted answer here as a starting-off point, and I came up with this solution. I know this looks a little convoluted, but it's actually quite minimal given the the size of the site I'm currently working on. This uses no jquery, so quite basic, vanilla JS. This appeals to me for this project, but this is easily jquery'fied if you want to.</p>
<p>A few notes:</p>
<ul>
<li>This uses radio button <code>&lt;input&gt;</code>'s in order to filter the entries as they are also used to feed the search feature with a category.</li>
<li>I'm using &quot;pretty&quot; permalinks all over, so no ugly <code>?category=</code> parameters in the URLs. In other words, you do NOT need the main listing page, category listing page or the entry itself to even be related by template.</li>
<li><code>/articles/</code> points to &quot;<strong>articles/listing.twig</strong>&quot;</li>
<li><code>/articles/animal/{slug}</code> points to &quot;<strong>articles/animal-category.twig</strong>&quot;</li>
<li><code>/articles/{entry slug}</code> points to &quot;<strong>article.twig</strong>&quot;</li>
</ul>
<p>The main entry listing page (<code>/articles/</code>) as well as the category entry listing page (<code>/articles/animal/{slug}</code>) both use essentially the same template, apart from a few minor details. These, however, differ in everyone's site so I'm going to disregard them here.</p>
<p><code>articles/listing.twig</code> and <code>articles/animal-category.twig</code> (differences noted in comments at the end of the lines):</p>
<pre><code>{% extends &quot;_layouts/base&quot; %}

{% block content %}
    {% set categoryQuery    = craft.app.request.getParam('category') %} {# LISTING #}
    {% set categoryQuery    = category %} {# ANIMAL-CATEGORY #}

    {% set searchQuery      = craft.app.request.getParam('search') %}
    {% set pageQuery        = craft.app.request.getParam('page') %}

    {% set search = '?search='~ searchQuery ~ '&amp;category=' ~ categoryQuery %}

    {% set categories = craft.categories.group('animal').all %}

    &lt;section class=&quot;relative&quot;&gt;
        &lt;header class=&quot;relative z-10&quot;&gt;
            {% if categoryQuery is not null and categoryQuery != '' %}
                {% set categoryObject = craft.categories.id(categoryQuery).one() %}
                &lt;div id=&quot;articleTitleContainer&quot;&gt;&lt;h1&gt;{{ categoryObject.title }}&lt;/h1&gt;&lt;/div&gt;
            {% else %}
                &lt;div id=&quot;articleTitleContainer&quot;&gt;{{ entry.redactorSimple }}&lt;/div&gt;
            {% endif %}
        &lt;/header&gt;
    &lt;/section&gt;

    {% include &quot;articles/articles-search-bar&quot; %} {# LISTING #}
    {% include &quot;articles/articles-search-bar&quot; with { categoryQuery: categoryQuery } %} {# ANIMAL-CATEGORY #}
    {% set articleEntries = craft.entries
        .section('articles')
        .search(searchQuery)
        .relatedTo(categoryQuery)
        .limit(21) 
    %}

    {% paginate articleEntries as articleInfo, articles %}

    &lt;section class=&quot;relative&quot;&gt;
        &lt;div id=&quot;article-loading-spinner&quot;&gt;&lt;/div&gt;
        &lt;div class=&quot;container&quot;&gt;
            {% if articles|length == 0 %}
                &lt;div class=&quot;articles articlesContainer&quot; id=&quot;articleContainer&quot;&gt;
                    &lt;p&gt;{{ 'No results found'|t }}&lt;/p&gt;
                &lt;/div&gt;
            {% else %}
                &lt;div class=&quot;articles articlesContainer&quot; id=&quot;articleContainer&quot;&gt;
                    {% for article in articles %}
                        &lt;a href=&quot;{{ article.url }}&quot; class=&quot;article-card&quot;&gt;
                            &lt;h2 class=&quot;article-card__title text-lg&quot;&gt;{{ article.title }}&lt;/h2&gt;
                            &lt;p class=&quot;article-card__intro&quot;&gt;{{ article.articleIntro|slice(0, 200) ~ '...' }}&lt;/p&gt;
                        &lt;/a&gt;
                    {% endfor %}
                &lt;/div&gt;
            {% endif %}

            &lt;section class=&quot;mb-4 mt-4&quot;&gt;
                &lt;div class=&quot;max-w-2xl mx-auto px-3&quot; id=&quot;articlePagination&quot;&gt;
                    {% if articles|length &gt; 0 %}
                        {% include &quot;pagination&quot; with { 
                            currentPage: articleInfo.currentPage,
                            lastPage: articleInfo.totalPages,
                            searchTerm: search,
                        } %}
                    {% endif %}
                &lt;/div&gt;
            &lt;/section&gt;
        &lt;/div&gt;
    &lt;/section&gt;
{% endblock %}

{% block footer %}
    {{ parent() }}
    {% include &quot;articles/article-js-inline.js.twig&quot; %}
{% endblock %}
</code></pre>
<p><code>articles-search-bar.twig</code> (the important bits, all my layout is removed):</p>
<pre><code>&lt;form&gt;
    {# Static &quot;all&quot; selector. #}
    &lt;div class=&quot;item category-all&quot;&gt;
        &lt;label&gt;
            &lt;input onClick=&quot;onFilterClick('all', '')&quot;  type=&quot;radio&quot; id=&quot;c_all&quot; name=&quot;category&quot; value=&quot;&quot; checked&gt;
            &lt;div class=&quot;icon category-all&quot;&gt;&lt;/div&gt;
            &lt;span&gt;{{ 'All'|t }}&lt;/span&gt;
        &lt;/label&gt;
    &lt;/div&gt;

    {% for categoryOption in categories %}
        {% if categoryOption.id is not null %}
            {% set categoryUrl = siteUrl ~ categoryOption.uri %}
            {% set catID = categoryOption.id %}
            &lt;div class=&quot;item category-{{ catID }}&quot;&gt;
                &lt;label&gt;
                    {% set entryCount = craft.entries.relatedTo(categoryOption).count() %}
                    {% if entryCount &gt; 0 %}
                        &lt;input onClick=&quot;onFilterClick({{ catID }}, '{{ categoryOption.title }}', '{{ categoryUrl }}')&quot; class=&quot;filter-button&quot; type=&quot;radio&quot; id=&quot;c_{{ catID }}&quot; name=&quot;category&quot; 
                            {% if categoryQuery is not null %}
                                value=&quot;{{ catID }}&quot; {{ categoryQuery.id == catID ? 'checked' }}
                            {% endif %}
                        &gt;
                        &lt;div class=&quot;icon category-{{ categoryOption.id }}&quot;&gt;&lt;/div&gt;
                        &lt;span&gt;{{ categoryOption.title }}&lt;/span&gt;
                    {% endif %}
                &lt;/label&gt;
            &lt;/div&gt;&lt;!-- .item END --&gt;
        {% endif %}
    {% endfor %}
    

    {# Dynamically change placeholder based on category ticked in. #}
    {% set placeholder = 'Search for...'|t %}
    {% if categoryQuery is not null and categoryQuery != '' %}
        {% set categoryObject = craft.categories.id(categoryQuery).one() %}
        {% set placeholder = 'Search within'|t ~ ' ' ~ categoryObject.title %}
    {% endif %}
    &lt;input
        class=&quot;&quot;
        placeholder=&quot;{{ placeholder }}&quot;
        aria-label=&quot;{{ 'Search articles'|t }}&quot;
        value=&quot;{{ searchQuery ? craft.app.request.getParam('search') : '' }}&quot;
        name=&quot;search&quot;
        id=&quot;article-search-field&quot;
    &gt;

    &lt;button type=&quot;submit&quot;&gt;
        &lt;span class=&quot;font-bold&quot;&gt;{{ 'Search'|t }}&lt;/span&gt;
    &lt;/button&gt;
&lt;/form&gt;
</code></pre>
<p>What makes this work at all. <code>article-js-inline.js.twig</code>:</p>
<p>Note: There are some twig variables in here, which is why this is loaded inline and not in a separate .js file. I want it inline anyways, since it's only used in essentially one template.</p>
<pre><code>{% js %}
    var historySupport = !!(window.history &amp;&amp; history.pushState); // HTML5 history

    function onFilterClick (categoryId, categoryName, categoryUrl) {
        {% set section = craft.entries().section('articlesListing').one() %}
        {% set sectionUrl = section.getUrl() %}
        let targetUrl;
        if (categoryId == 'all') {
            targetUrl = '{{ sectionUrl }}';
        } else {
            targetUrl = categoryUrl;
        }

        showLoading();
        fetch(targetUrl)
            .then(response =&gt; response.text())
            .then(data =&gt; {
                refreshArticles(data, categoryName, targetUrl)
            })
            .catch(error =&gt; {
                console.log(error)
            });
    }

    function showLoading() {
        let loading = document.getElementById('article-loading-spinner');
        loading.innerHTML = '&lt;img src=&quot;/assets/images/pulse.svg&quot; class=&quot;&quot; /&gt;';
        loading.classList.remove('hidden');
        loading.classList.add('block');
    }

    function hideLoading() {
        let loading = document.getElementById('article-loading-spinner');
        loading.innerHTML = '';
        loading.classList.remove('block');
        loading.classList.add('hidden');
    }

    function changePlaceholderAndResetValue(categoryName) {
        let searchField = document.getElementById('article-search-field');
        searchField.value = '';
        if (categoryName) {
            searchField.placeholder = '{{ 'Search within'|t }} ' + categoryName + '...';
        } else {
            searchField.placeholder = '{{ 'Search for...'|t }}';
        }
    }

    function actualRefresh(data, categoryName = '') {
        let newTitle = '{{ 'Articles and Guides'|t }}: ' + categoryName;
        document.title = newTitle;

        let dom = new DOMParser().parseFromString(data, 'text/html'); // Temp DOM.
        let newHTML = dom.getElementById('articleContainer');
        let newHTMLpagination = dom.getElementById('articlePagination');

        // Target HTML
        let htmlTarget = document.getElementById('articleContainer');
        let htmlTargetTitle = document.getElementById('articleTitleContainer');
        let htmlTargetPagination = document.getElementById('articlePagination');
        
        if (!newHTML) {
            htmlTarget.innerHTML = '&lt;p&gt;{{ 'No results found'|t }}&lt;/p&gt;';
        } else {
            htmlTarget.innerHTML = newHTML.innerHTML;
        }
        
        if (!newHTMLpagination) {
            htmlTargetPagination.innerHTML = '';
        } else {
            htmlTargetPagination.innerHTML = newHTMLpagination.innerHTML;
        }

        if (categoryName) {
            htmlTargetTitle.innerHTML = '&lt;div class=&quot;c-redactor&quot;&gt;&lt;h1&gt;' + categoryName + '&lt;/h1&gt;&lt;/div&gt;';
        } else {
            htmlTargetTitle.innerHTML = '&lt;div class=&quot;c-redactor&quot;&gt;&lt;h1&gt;{{ 'Articles and Guides'|t }}&lt;/h1&gt;&lt;/div&gt;';
        }
    }

    function refreshArticles (data, categoryName, targetUrl) {
        actualRefresh(data, categoryName)
        hideLoading();
        if (historySupport) {
            // Update browser address bar.
            history.pushState(null, null, targetUrl);
        }
        changePlaceholderAndResetValue(categoryName)
    }
{% endjs %}
</code></pre>
<p>I'm not gonna bother posting the CSS unless someone really needs it since it's all Tailwind + some icons for the radio buttons.</p>
<p>I hope this is useful to someone.</p>

CC BY-SA 4.0
2022-03-03T13:24:54.520Z
2022-03-03T13:24:54.520Z
InanisAtheos
<p>There's a ton of ways to pull this off, but in my opinion setting up pages/URLs for each category is probably a <em>good idea</em>, for SEO and sharing reasons. It's not something that requires a lot of work and would make implementing the AJAX part a breeze. Another win is that you're going to have a solution which will actually work without AJAX or JavaScript, too – which is always a plus :)</p>

<p>To start, make sure the template path for your category group points to the same template that you use for your work index listing page (e.g. <code>work/index</code>), and that the URL pattern is <code>work/{slug}</code>.</p>

<p>Then, amend the listing template like this – the idea is to add the category filter to the <code>craft.entries</code> query only when one of the category URLs are requested.</p>

<pre><code>{# Your basic work entries query #}
{% set query = {
    section : 'work',
    limit : null
} %}

{% If a category URL is requested, Craft will have populated your template with a global `category` variable #}
{% if category is defined %}
    {% set query = query|merge({
        search : 'categories:'~category.slug
    }) %}
{% endif %}

{# Fetch entries #}
{% set entries = craft.entries(query) %}

&lt;div class="projects"&gt;
    &lt;ul&gt;
        {% for entry in entries %}
            &lt;li class="project"&gt;
                ...
            &lt;/li&gt;
        {% endfor %}
    &lt;/ul&gt;
&lt;/div&gt;
</code></pre>

<p>For the AJAX part, here's a super simple implementation – this example uses jQuery, and the concept is basically that we'll have the category URLs load using AJAX, and then just swap out the contents of the <code>.projects</code> DOM element with the returned HTML – no need for a separate AJAX template, JSON or even a <code>craft.request.isAjaxRequest</code> test. It's probably also a good idea to use the HTML5 History API to update the browser's address bar:</p>

<pre><code>// Check if the browser supports HTML5 history
var historySupport = !!(window.history &amp;&amp; history.pushState);

// Add an event listener for filter clicks
$(function () {
    $('body').on('click', '.filters a', onFilterClick);
});

// Event handler for the filter click
function onFilterClick (e)
{
    e.preventDefault();
    var $filter = $(e.currentTarget);
    var href = $filter.attr('href');
    if (historySupport) {
        // Update the browser's address bar
        history.pushState(null, null, href);  
    }
    $.ajax($filter.attr('href), {
        dataType : 'html',
        success : function (response) {
            refreshProjects(response);
        }
    });
}

function refreshProjects (html)
{

    // Update the &lt;title&gt; tag
    document.title = $(html).filter('title').text();

    // Update the .projects DOM element with new HTML
    var $html = $('&lt;div /&gt;').append(html);
    $('.projects').html($html.find('.projects').html());

}
</code></pre>

CC BY-SA 3.0
2016-05-26T14:57:54.697Z
Thanks for such a thorough answer Mats. And having one that can work without ajax too is great. Just one last question, I'm using jquery unveil and the images don't load when I click a filter. Where should I use $(".unveil").trigger("unveil"); in the js? I appreciate all of the help, its my first time using ajax and I'm not great with js.
patrick.altair
CC BY-SA 3.0
2016-05-26T15:11:42.937Z
Oh never mind, I figured it out. Thanks again Mats!
patrick.altair
CC BY-SA 3.0
2016-05-27T05:05:00.820Z
No problem, @patrick.altair – nice to be able to help out :)
Mats Mikkel Rummelhoff
CC BY-SA 3.0
2016-05-25T18:02:49.700Z
2016-05-26T03:48:44.947Z
2016-05-26T03:48:44.947Z
Mats Mikkel Rummelhoff
<p>I have a page that lists all entries from the section 'work'. I would like the user to be able to filter these entries by category but via ajax so I don't have to go to a new page.</p>

<pre><code>{% set entries = craft.entries.section('work').limit(null).search('categories:hotel') %}
</code></pre>

<p>This works but each category has to have its own page. Is it possibly to load the filtered entries by ajax? Something using {% if craft.request.isAjax %}?
Here is the relevant html:</p>

<p></p>

<pre><code>    &lt;div class="filters"&gt;
        &lt;p class="categories"&gt;Categories&lt;/p&gt;
        &lt;p&gt;&lt;a href="{{ url('work') }}"&gt;All&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;&lt;a href="{{ url('work/hotel') }}"&gt;Hotel&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;&lt;a href="{{ url('work/bar') }}"&gt;Bar&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;&lt;a href="{{ url('work/club') }}"&gt;Club&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;&lt;a href="{{ url('work/restaurant') }}"&gt;Restaurant&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;&lt;a href="{{ url('work/retail') }}"&gt;Retail&lt;/a&gt;&lt;/p&gt;
    &lt;/div&gt;

    {% set entries = craft.entries.section('work').limit(null) %}
    &lt;div class="projects"&gt;
        &lt;ul&gt;
            {% for entry in entries %}
                &lt;li class="project"&gt;
                    &lt;a href="{{ entry.url }}"&gt;
                        {% for asset in entry.mainPhoto %}
                            &lt;div class="unveil-wrapper"&gt;
                                &lt;img class="unveil" src="{{ url('assets/images/placeholder.png') }}" alt="{{ entry.title }}"&gt;
                            &lt;/div&gt;
                        {% endfor %}
                        &lt;p class="name"&gt;{{ entry.title }}&lt;/p&gt;
                        &lt;p class="address"&gt;{{ entry.address }}&lt;/p&gt;
                    &lt;/a&gt;
                &lt;/li&gt;
            {% endfor %}
        &lt;/ul&gt;
    &lt;/div&gt;

&lt;/section&gt;
</code></pre>

<entries><filter><ajax>
How to load entries via ajax when a filter is clicked?
# How to load entries via ajax when a filter is clicked?

(Asked by: username_0 on 2016-05-25)

I have a page that lists all entries from the section 'work'. I would like the user to be able to filter these entries by category but via ajax so I don't have to go to a new page.
{% set entries = craft.entries.section('work').limit(null).search('categories:hotel') %}

This works but each category has to have its own page. Is it possibly to load the filtered entries by ajax? Something using {% if craft.request.isAjax %}?
Here is the relevant html:

    
        Categories
        All
        Hotel
        Bar
        Club
        Restaurant
        Retail
    

    {% set entries = craft.entries.section('work').limit(null) %}
    
        
            {% for entry in entries %}
                
                    
                        {% for asset in entry.mainPhoto %}
                            
                                
                            
                        {% endfor %}
                        {{ entry.title }}
                        {{ entry.address }}
                    
                
            {% endfor %}

**Tags:** entries, filter, ajax

---

## 2 answers

### Answer by username_2 on 2016-05-25. Score: 10 | Accepted answer

There's a ton of ways to pull this off, but in my opinion setting up pages/URLs for each category is probably a good idea, for SEO and sharing reasons. It's not something that requires a lot of work and would make implementing the AJAX part a breeze. Another win is that you're going to have a solution which will actually work without AJAX or JavaScript, too – which is always a plus :)
To start, make sure the template path for your category group points to the same template that you use for your work index listing page (e.g. work/index), and that the URL pattern is work/{slug}.
Then, amend the listing template like this – the idea is to add the category filter to the craft.entries query only when one of the category URLs are requested.
{# Your basic work entries query #}
{% set query = {
    section : 'work',
    limit : null
} %}

{% If a category URL is requested, Craft will have populated your template with a global `category` variable #}
{% if category is defined %}
    {% set query = query|merge({
        search : 'categories:'~category.slug
    }) %}
{% endif %}

{# Fetch entries #}
{% set entries = craft.entries(query) %}


    
        {% for entry in entries %}
            
                ...
            
        {% endfor %}
    


For the AJAX part, here's a super simple implementation – this example uses jQuery, and the concept is basically that we'll have the category URLs load using AJAX, and then just swap out the contents of the .projects DOM element with the returned HTML – no need for a separate AJAX template, JSON or even a craft.request.isAjaxRequest test. It's probably also a good idea to use the HTML5 History API to update the browser's address bar:
// Check if the browser supports HTML5 history
var historySupport = !!(window.history && history.pushState);

// Add an event listener for filter clicks
$(function () {
    $('body').on('click', '.filters a', onFilterClick);
});

// Event handler for the filter click
function onFilterClick (e)
{
    e.preventDefault();
    var $filter = $(e.currentTarget);
    var href = $filter.attr('href');
    if (historySupport) {
        // Update the browser's address bar
        history.pushState(null, null, href);  
    }
    $.ajax($filter.attr('href), {
        dataType : 'html',
        success : function (response) {
            refreshProjects(response);
        }
    });
}

function refreshProjects (html)
{

    // Update the  tag
    document.title = $(html).filter('title').text();

    // Update the .projects DOM element with new HTML
    var $html = $('').append(html);
    $('.projects').html($html.find('.projects').html());

}

> **Comments:**
> **username_0**: Thanks for such a thorough answer Mats. And having one that can work without ajax too is great. Just one last question, I'm using jquery unveil and the images don't load when I click a filter. Where should I use $(".unveil").trigger("unveil"); in the js? I appreciate all of the help, its my first time using ajax and I'm not great with js.  
> **username_0**: Oh never mind, I figured it out. Thanks again Mats!  
> **username_2**: No problem, @patrick.altair – nice to be able to help out :)  

---

### Answer by username_1 on 2022-03-03. Score: 2

I used the accepted answer here as a starting-off point, and I came up with this solution. I know this looks a little convoluted, but it's actually quite minimal given the the size of the site I'm currently working on. This uses no jquery, so quite basic, vanilla JS. This appeals to me for this project, but this is easily jquery'fied if you want to.
A few notes:

This uses radio button 's in order to filter the entries as they are also used to feed the search feature with a category.
I'm using "pretty" permalinks all over, so no ugly ?category= parameters in the URLs. In other words, you do NOT need the main listing page, category listing page or the entry itself to even be related by template.
/articles/ points to "articles/listing.twig"
/articles/animal/{slug} points to "articles/animal-category.twig"
/articles/{entry slug} points to "article.twig"

The main entry listing page (/articles/) as well as the category entry listing page (/articles/animal/{slug}) both use essentially the same template, apart from a few minor details. These, however, differ in everyone's site so I'm going to disregard them here.
articles/listing.twig and articles/animal-category.twig (differences noted in comments at the end of the lines):
{% extends "_layouts/base" %}

{% block content %}
    {% set categoryQuery    = craft.app.request.getParam('category') %} {# LISTING #}
    {% set categoryQuery    = category %} {# ANIMAL-CATEGORY #}

    {% set searchQuery      = craft.app.request.getParam('search') %}
    {% set pageQuery        = craft.app.request.getParam('page') %}

    {% set search = '?search='~ searchQuery ~ '&category=' ~ categoryQuery %}

    {% set categories = craft.categories.group('animal').all %}

    
        
            {% if categoryQuery is not null and categoryQuery != '' %}
                {% set categoryObject = craft.categories.id(categoryQuery).one() %}
                {{ categoryObject.title }}
            {% else %}
                {{ entry.redactorSimple }}
            {% endif %}
        
    

    {% include "articles/articles-search-bar" %} {# LISTING #}
    {% include "articles/articles-search-bar" with { categoryQuery: categoryQuery } %} {# ANIMAL-CATEGORY #}
    {% set articleEntries = craft.entries
        .section('articles')
        .search(searchQuery)
        .relatedTo(categoryQuery)
        .limit(21) 
    %}

    {% paginate articleEntries as articleInfo, articles %}

    
        
        
            {% if articles|length == 0 %}
                
                    {{ 'No results found'|t }}
                
            {% else %}
                
                    {% for article in articles %}
                        
                            {{ article.title }}
                            {{ article.articleIntro|slice(0, 200) ~ '...' }}
                        
                    {% endfor %}
                
            {% endif %}

            
                
                    {% if articles|length > 0 %}
                        {% include "pagination" with { 
                            currentPage: articleInfo.currentPage,
                            lastPage: articleInfo.totalPages,
                            searchTerm: search,
                        } %}
                    {% endif %}
                
            
        
    
{% endblock %}

{% block footer %}
    {{ parent() }}
    {% include "articles/article-js-inline.js.twig" %}
{% endblock %}

articles-search-bar.twig (the important bits, all my layout is removed):

    {# Static "all" selector. #}
    
        
            
            
            {{ 'All'|t }}
        
    

    {% for categoryOption in categories %}
        {% if categoryOption.id is not null %}
            {% set categoryUrl = siteUrl ~ categoryOption.uri %}
            {% set catID = categoryOption.id %}
            
                
                    {% set entryCount = craft.entries.relatedTo(categoryOption).count() %}
                    {% if entryCount > 0 %}
                        
                        
                        {{ categoryOption.title }}
                    {% endif %}
                
            
        {% endif %}
    {% endfor %}
    

    {# Dynamically change placeholder based on category ticked in. #}
    {% set placeholder = 'Search for...'|t %}
    {% if categoryQuery is not null and categoryQuery != '' %}
        {% set categoryObject = craft.categories.id(categoryQuery).one() %}
        {% set placeholder = 'Search within'|t ~ ' ' ~ categoryObject.title %}
    {% endif %}
    

    
        {{ 'Search'|t }}
    


What makes this work at all. article-js-inline.js.twig:
Note: There are some twig variables in here, which is why this is loaded inline and not in a separate .js file. I want it inline anyways, since it's only used in essentially one template.
{% js %}
    var historySupport = !!(window.history && history.pushState); // HTML5 history

    function onFilterClick (categoryId, categoryName, categoryUrl) {
        {% set section = craft.entries().section('articlesListing').one() %}
        {% set sectionUrl = section.getUrl() %}
        let targetUrl;
        if (categoryId == 'all') {
            targetUrl = '{{ sectionUrl }}';
        } else {
            targetUrl = categoryUrl;
        }

        showLoading();
        fetch(targetUrl)
            .then(response => response.text())
            .then(data => {
                refreshArticles(data, categoryName, targetUrl)
            })
            .catch(error => {
                console.log(error)
            });
    }

    function showLoading() {
        let loading = document.getElementById('article-loading-spinner');
        loading.innerHTML = '';
        loading.classList.remove('hidden');
        loading.classList.add('block');
    }

    function hideLoading() {
        let loading = document.getElementById('article-loading-spinner');
        loading.innerHTML = '';
        loading.classList.remove('block');
        loading.classList.add('hidden');
    }

    function changePlaceholderAndResetValue(categoryName) {
        let searchField = document.getElementById('article-search-field');
        searchField.value = '';
        if (categoryName) {
            searchField.placeholder = '{{ 'Search within'|t }} ' + categoryName + '...';
        } else {
            searchField.placeholder = '{{ 'Search for...'|t }}';
        }
    }

    function actualRefresh(data, categoryName = '') {
        let newTitle = '{{ 'Articles and Guides'|t }}: ' + categoryName;
        document.title = newTitle;

        let dom = new DOMParser().parseFromString(data, 'text/html'); // Temp DOM.
        let newHTML = dom.getElementById('articleContainer');
        let newHTMLpagination = dom.getElementById('articlePagination');

        // Target HTML
        let htmlTarget = document.getElementById('articleContainer');
        let htmlTargetTitle = document.getElementById('articleTitleContainer');
        let htmlTargetPagination = document.getElementById('articlePagination');
        
        if (!newHTML) {
            htmlTarget.innerHTML = '{{ 'No results found'|t }}';
        } else {
            htmlTarget.innerHTML = newHTML.innerHTML;
        }
        
        if (!newHTMLpagination) {
            htmlTargetPagination.innerHTML = '';
        } else {
            htmlTargetPagination.innerHTML = newHTMLpagination.innerHTML;
        }

        if (categoryName) {
            htmlTargetTitle.innerHTML = '' + categoryName + '';
        } else {
            htmlTargetTitle.innerHTML = '{{ 'Articles and Guides'|t }}';
        }
    }

    function refreshArticles (data, categoryName, targetUrl) {
        actualRefresh(data, categoryName)
        hideLoading();
        if (historySupport) {
            // Update browser address bar.
            history.pushState(null, null, targetUrl);
        }
        changePlaceholderAndResetValue(categoryName)
    }
{% endjs %}

I'm not gonna bother posting the CSS unless someone really needs it since it's all Tailwind + some icons for the radio buttons.
I hope this is useful to someone.


---

