New May 17, 2026

Accessible Blog Search with Eleventy

More Front-end Bloggers All from Kitty Giraudel View Accessible Blog Search with Eleventy on kittygiraudel.com

I’ve recently reworked my search page, and realised I never wrote about how it works. So let’s have a look into how to set up a blog search in Eleventy.

Data source

The first thing we need is a data source, which is to say a big list with all our articles, which we can execute our search against. There is no built-in way to do that, but we can have Eleventy generate a JSON file, which we’ll be able to fetch in our frontend.

---
permalink: /blog/search/data.json
eleventyExcludeFromCollections: true
---
{% assign empty_array = '' | split: '' %}
[
  {% for post in collections.posts %}
    {
      "title" : {{ post.data.title | jsonify }},
      "tags"  : {{ post.data.tags | default: empty_array | jsonify }},
      "url"   : {{ post.url | jsonify }},
      "date"  : {{ post.date | date: '%B %e, %Y' | jsonify }}
    } {% unless forloop.last %},{% endunless %}
  {% endfor %}
]

It’s a bit hacky, but it does the job well. It renders an array of objects looking like this:

{
	"title" : "Blog Search with Eleventy",
	"tags"  : ["Eleventy", "Accessibility"],
	"url"   : "/2026/05/07/blog-search-with-11ty/",
	"date"  : "May 7, 2026"
}

Cooking a search widget

Now, we need a dedicated search widget. You’d think you need nothing but a text field, but there is actually a lot going on to make it semantic and accessible. We’ll go through the code step-by-step to make it more digestible.

Landmark

We’re going to place it all inside a <search> element, which is widely available in browsers today. To quote Scott O’Hara:

[O]ne would use the search element to expose the search landmark in the browser’s accessibility API, allowing people using assistive technology, such as screen readers, to discover this content area and allow for quick access to it. Being a “search” landmark, it implicitly indicates that the content one would find within would be related to searching for, or even filtering content (filtering is a ‘searching’ behavior… designers sure do like to use the magnify glass icon interchangeably between these UI controls, at least).
— Scott O’Hara in The search element

If you are interested in the history behind the <search> element and the use of landmark in general, I can recommend reading In Quest of Search by Sara Soueidan.

Enforcing JavaScript

First things first, we want to make it clear that JavaScript is necessary to use the search. This can be done with a <noscript> element.

<noscript>
	Unfortunately this site has no server-side search available,
	so please enable JavaScript in your browser to be able to
	use the provided search engine.
</noscript>

Search form

Then, we want a proper form with our input. It’s important to remember that the use of the <search> element does not negate the need for an actual <form>, because that element is really just a landmark for navigational purposes — not a functional container.

<form id="search-form" method="GET">
	<label for="search-input">
		Search blog articles
	</label>
	<input
		type="search"
		id="search-input"
		name="q"
		enterkeyhint="search"
		placeholder="Search blog articles…"
		autofocus
		aria-describedby="search-hint"
		aria-controls="search-region"
	/>
	<p id="search-hint" class="VisuallyHidden">
		Results appear in the search results section as you type.
	</p>
</form>

Note that:

The results container

Finally, we need a place to render the result of our search.

<section
	id="search-region"
	aria-labelledby="search-title"
	aria-busy="true"
>
	<h2 id="search-title">
		Search results
	</h2>
	<ul
		id="search-results"
		role="list"
		aria-live="polite"
		aria-relevant="additions removals"
	>
		<!-- Results will be inserted here -->
	</ul>
	<p id="search-empty" hidden role="status">
		Unfortunately, no results were found for your search.
	</p>
	<p id="search-error" hidden role="alert">
		The search did not work: refresh the page and try again.
	</p>
</section>

Again, note:

Wiring it all up with JS

On the JavaScript side, there are quite a few things to do:

  1. Fetch all the data from the JSON endpoint, and remove the aria-busy attribute once ready, or display the error message if fetching failed.
  2. Bind a listener to the field to filter our results as we type (and prevent submitting the form from triggering a page reload).
  3. Render the results as desired, or display the empty message if no results were found.
document.addEventListener('DOMContentLoaded', () => {
	// Query all relevant elements
	const $searchRegion = document.querySelector('#search-region')
	const $searchResults = document.querySelector('#search-results')
	const $searchInput = document.querySelector('#search-input')
	const $searchEmpty = document.querySelector('#search-empty')
	const $searchError = document.querySelector('#search-error')

// Fetch the data fetch('/blog/search/data.json') .then(response => { if (!response.ok) throw new Error('Fetching data source failed.') else return response.json() }) .then(data => { $searchInput.addEventListener('input', event => handleSearch(data, event.target.value)) // Perform URL sync if desired (see below) }) .catch(error => { console.error(error) $searchError.removeAttribute('hidden') }) .finally(() => { $searchRegion.removeAttribute('aria-busy') $searchInput.closest('form')?.addEventListener('submit', event => event.preventDefault()) })

function resetState() { $searchError.setAttribute('hidden', '') $searchEmpty.setAttribute('hidden', '') $searchResults.replaceChildren() }

function handleSearch(data, value) { resetState()

const results = filterData(data, value)

if (value && results.length === 0) $searchEmpty.removeAttribute('hidden') else $searchResults.innerHTML = results.map(renderResult).join('\n') }

function filterData(data, value) { if (!value) return [] // The filtering logic can check more posts and be as simple // or complicated as desired return data.filter(post => post.title.toLowerCase().includes(value.toLowerCase())) }

function renderResult (result) { /* Generate the relevant HTML for a result */ } })

Here is a small pen to showcase the core functionality in action. Try searching for “Next”, or “Accessible” to produce results, or just gibberish to see the empty state.

See the Pen yyVYNXX by @KittyGiraudel on CodePen.

Synchronizing the URL

If we want to be able to link to the search page with a pre-populated search, we can read the query parameter on load and kick off a search based on its value.

const params = new URLSearchParams(window.location.search)
const query = (params.get('q') ?? '').trim()

$searchInput.value = query updateURLFromQuery(query) handleSearch(data, query)

function updateURLFromQuery(value) { const url = new URL(window.location.href)

if (value) url.searchParams.set('q', value) else url.searchParams.delete('q')

if (url.toString() !== window.location.href) window.history.replaceState({}, '', url) }

Going further

It works, it’s accessible, but it’s quite rudimentary. There are a few things we can do to make it nicer:

Anyway, this introduces the core accessible search engine. Up to you to make it yours. :)

Scroll to top