Answers.Body: <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>
  
Answers.ContentLicense: CC BY-SA 4.0
Answers.CreationDate: 2022-03-03T13:24:54.520Z
Answers.LastActivityDate: 2022-03-03T13:24:54.520Z
Answers.UserDisplayName: InanisAtheos
Answers.Body: <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>
  
Answers.ContentLicense: CC BY-SA 3.0
Answers.CreationDate: 2016-05-26T14:57:54.697Z
Answers.Text: 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.
Answers.UserDisplayName: patrick.altair
Answers.ContentLicense: CC BY-SA 3.0
Answers.CreationDate: 2016-05-26T15:11:42.937Z
Answers.Text: Oh never mind, I figured it out. Thanks again Mats!
Answers.UserDisplayName: patrick.altair
Answers.ContentLicense: CC BY-SA 3.0
Answers.CreationDate: 2016-05-27T05:05:00.820Z
Answers.Text: No problem, @patrick.altair – nice to be able to help out :)
Answers.UserDisplayName: Mats Mikkel Rummelhoff
Answers.ContentLicense: CC BY-SA 3.0
Answers.CreationDate: 2016-05-25T18:02:49.700Z
Answers.LastActivityDate: 2016-05-26T03:48:44.947Z
Answers.LastEditDate: 2016-05-26T03:48:44.947Z
Answers.UserDisplayName: Mats Mikkel Rummelhoff
Body: <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>
  
Tags: <entries><filter><ajax>
Title: How to load entries via ajax when a filter is clicked?
ThreadText: # 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:
  
  ```
      <div class="filters">
          <p class="categories">Categories</p>
          <p><a href="{{ url('work') }}">All</a></p>
          <p><a href="{{ url('work/hotel') }}">Hotel</a></p>
          <p><a href="{{ url('work/bar') }}">Bar</a></p>
          <p><a href="{{ url('work/club') }}">Club</a></p>
          <p><a href="{{ url('work/restaurant') }}">Restaurant</a></p>
          <p><a href="{{ url('work/retail') }}">Retail</a></p>
      </div>
  
      {% set entries = craft.entries.section('work').limit(null) %}
      <div class="projects">
          <ul>
              {% for entry in entries %}
                  <li class="project">
                      <a href="{{ entry.url }}">
                          {% for asset in entry.mainPhoto %}
                              <div class="unveil-wrapper">
                                  <img class="unveil" src="{{ url('assets/images/placeholder.png') }}" alt="{{ entry.title }}">
                              </div>
                          {% endfor %}
                          <p class="name">{{ entry.title }}</p>
                          <p class="address">{{ entry.address }}</p>
                      </a>
                  </li>
              {% endfor %}
          </ul>
      </div>
  
  </section>
  
  ```
  
  **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) %}
  
  <div class="projects">
      <ul>
          {% for entry in entries %}
              <li class="project">
                  ...
              </li>
          {% endfor %}
      </ul>
  </div>
  
  ```
  
  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 <title> tag
      document.title = $(html).filter('title').text();
  
      // Update the .projects DOM element with new HTML
      var $html = $('<div />').append(html);
      $('.projects').html($html.find('.projects').html());
  
  }
  
  ```
  
  > **Comments:**
  > **username_0**: Thanks for such a thorough answer username_2. 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 username_2!  
  > **username_2**: No problem, @username_0 – 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 `<input>`'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 %}
  
      <section class="relative">
          <header class="relative z-10">
              {% if categoryQuery is not null and categoryQuery != '' %}
                  {% set categoryObject = craft.categories.id(categoryQuery).one() %}
                  <div id="articleTitleContainer"><h1>{{ categoryObject.title }}</h1></div>
              {% else %}
                  <div id="articleTitleContainer">{{ entry.redactorSimple }}</div>
              {% endif %}
          </header>
      </section>
  
      {% 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 %}
  
      <section class="relative">
          <div id="article-loading-spinner"></div>
          <div class="container">
              {% if articles|length == 0 %}
                  <div class="articles articlesContainer" id="articleContainer">
                      <p>{{ 'No results found'|t }}</p>
                  </div>
              {% else %}
                  <div class="articles articlesContainer" id="articleContainer">
                      {% for article in articles %}
                          <a href="{{ article.url }}" class="article-card">
                              <h2 class="article-card__title text-lg">{{ article.title }}</h2>
                              <p class="article-card__intro">{{ article.articleIntro|slice(0, 200) ~ '...' }}</p>
                          </a>
                      {% endfor %}
                  </div>
              {% endif %}
  
              <section class="mb-4 mt-4">
                  <div class="max-w-2xl mx-auto px-3" id="articlePagination">
                      {% if articles|length > 0 %}
                          {% include "pagination" with { 
                              currentPage: articleInfo.currentPage,
                              lastPage: articleInfo.totalPages,
                              searchTerm: search,
                          } %}
                      {% endif %}
                  </div>
              </section>
          </div>
      </section>
  {% endblock %}
  
  {% block footer %}
      {{ parent() }}
      {% include "articles/article-js-inline.js.twig" %}
  {% endblock %}
  
  ```
  
  `articles-search-bar.twig` (the important bits, all my layout is removed):
  
  ```
  <form>
      {# Static "all" selector. #}
      <div class="item category-all">
          <label>
              <input onClick="onFilterClick('all', '')"  type="radio" id="c_all" name="category" value="" checked>
              <div class="icon category-all"></div>
              <span>{{ 'All'|t }}</span>
          </label>
      </div>
  
      {% for categoryOption in categories %}
          {% if categoryOption.id is not null %}
              {% set categoryUrl = siteUrl ~ categoryOption.uri %}
              {% set catID = categoryOption.id %}
              <div class="item category-{{ catID }}">
                  <label>
                      {% set entryCount = craft.entries.relatedTo(categoryOption).count() %}
                      {% if entryCount > 0 %}
                          <input onClick="onFilterClick({{ catID }}, '{{ categoryOption.title }}', '{{ categoryUrl }}')" class="filter-button" type="radio" id="c_{{ catID }}" name="category" 
                              {% if categoryQuery is not null %}
                                  value="{{ catID }}" {{ categoryQuery.id == catID ? 'checked' }}
                              {% endif %}
                          >
                          <div class="icon category-{{ categoryOption.id }}"></div>
                          <span>{{ categoryOption.title }}</span>
                      {% endif %}
                  </label>
              </div><!-- .item END -->
          {% 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 %}
      <input
          class=""
          placeholder="{{ placeholder }}"
          aria-label="{{ 'Search articles'|t }}"
          value="{{ searchQuery ? craft.app.request.getParam('search') : '' }}"
          name="search"
          id="article-search-field"
      >
  
      <button type="submit">
          <span class="font-bold">{{ 'Search'|t }}</span>
      </button>
  </form>
  
  ```
  
  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 = '<img src="/assets/images/pulse.svg" class="" />';
          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 = '<p>{{ 'No results found'|t }}</p>';
          } else {
              htmlTarget.innerHTML = newHTML.innerHTML;
          }
          
          if (!newHTMLpagination) {
              htmlTargetPagination.innerHTML = '';
          } else {
              htmlTargetPagination.innerHTML = newHTMLpagination.innerHTML;
          }
  
          if (categoryName) {
              htmlTargetTitle.innerHTML = '<div class="c-redactor"><h1>' + categoryName + '</h1></div>';
          } else {
              htmlTargetTitle.innerHTML = '<div class="c-redactor"><h1>{{ 'Articles and Guides'|t }}</h1></div>';
          }
      }
  
      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.
  
  
  ---
  
  