Search

js/search.js

v2.0.0 Stable (breaking: `rd-js-` hook) | Download download

If you need search on a static website, the easiest and most reliable option is to use a ready-made library instead of building your own search from scratch. It works out of the box, is free to use, and has already been tested on many real websites. One such library — and the one used on this site — is Pagefind.

Pagefind is a fast and privacy-friendly search engine designed specifically for static sites. It scans the already-built HTML pages and creates a small client-side index, so search works instantly in the browser without any backend or third-party service. On Eleventy sites, Pagefind integrates right after the build step and indexes only what is actually deployed to production.

Installation

  1. Run the installation command from the root directory of your project. This will add Pagefind to your development dependencies.
npm i -D pagefind
  1. Update the build and start scripts. Open your package.json file and modify the “scripts” section so that Pagefind runs automatically after Eleventy builds the site, and so that both can be run together during local development.
"scripts": {
    "start": "eleventy --serve & pagefind --source _site",
    "build": "eleventy",
    "postbuild": "pagefind --source _site"
},
  1. Verify that the dependencies are listed in package.json. In the same file, scroll down to the “devDependencies” section and make sure that both @11ty/eleventy and pagefind are present with valid version numbers.
"devDependencies": {
    "@11ty/eleventy": "^3.1.0",
    "pagefind": "^1.1.0"
}

If the installation was successful, they should appear there automatically.

Adding the Search UI to the Website

First, we need an element that will allow users to open the search interface. In this example, we place a button search in the website header next to the hamburger menu:

<div class="header-icons">
  <button class="icon-search no-decoration rd-js-search" aria-label="Поиск"></button>
  <button class="hamburger no-decoration rd-js-hamburger" aria-label="Toggle navigation">
      <span class="hamburger__icon-menu"></span>
      <span class="hamburger__icon-close"></span>
  </button>
</div>

The search button is empty on purpose: the glyph is baked into .icon-search by CSS (: before with a Material Symbols ligature), so markup carries no vendor icon class and no ligature text.

Next, create a container that will hold both the search input and the search results. It is hidden by default in CSS (.searchbar { display: none; }) and shown by the rd-is-open state class the script toggles. The script hooks the button via rd-js-search, the bar via rd-js-search-bar, and the Pagefind mount via rd-js-search-ui; open/closed state is mirrored to aria-expanded on the button.

<div class="search-wrapper">
  <div id="searchbar" class="searchbar rd-js-search-bar">
    <div id="search" class="search-input rd-js-search-ui"></div>
  </div>
</div>

All necessary styles are already included in Rare Styles. Pagefind ships its own default styles (pagefind-ui.css) and JavaScript (pagefind-ui.js) — those are what render the actual search box.

The Rule: Pagefind must be loaded before the script mounts it. The search script below does not load Pagefind; it only calls new PagefindUI() once window.PagefindUI exists. If Pagefind is never loaded, clicking the icon opens an empty box and nothing happens. Wire it one of the two ways below — and if your site already uses the lazy loader, never overwrite the search file with a generic copy that drops it.

Option A — eager (simplest). Include Pagefind’s assets in the <head> of your layout. They load on every page (~100 KB + a render-blocking stylesheet), even for visitors who never search:

<link rel="stylesheet" href="/pagefind/pagefind-ui.css">
<script src="/pagefind/pagefind-ui.js" defer></script>

Option B — lazy (recommended for performance). Do NOT add the <head> includes; let the script pull Pagefind’s CSS + JS the first time search is opened, so the ~100 KB of search assets never touch a page until someone actually searches. Add this loader at the top of your search file:

// Lazy-load Pagefind's CSS + JS on first open. Returns a promise that
// resolves once PagefindUI is available.
var pagefindPromise = null;
function loadPagefind() {
    if (pagefindPromise) return pagefindPromise;
    pagefindPromise = new Promise(function (resolve, reject) {
        var css = document.createElement('link');
        css.rel = 'stylesheet';
        css.href = '/pagefind/pagefind-ui.css';
        document.head.appendChild(css);

        var js = document.createElement('script');
        js.src = '/pagefind/pagefind-ui.js';
        js.onload = function () { resolve(window.PagefindUI); };
        js.onerror = reject;
        document.head.appendChild(js);
    });
    return pagefindPromise;
}

…then wrap the new PagefindUI (…) call inside openSearch () with loadPagefind ().then (function () {… }). This is exactly the pattern schnellreich.ru uses.

Now add the small JavaScript file that initializes Pagefind UI and shows/hides the search panel when the user clicks the button. The version below uses Option A (it assumes window.PagefindUI is already loaded); for Option B, wrap the marked new PagefindUI block in loadPagefind ().then (…):

function initSearchUI() {
    if (document.body.classList.contains('page-search')) return;
    var btn = document.querySelector('.rd-js-search');
    var bar = document.querySelector('.rd-js-search-bar');
    var container = document.querySelector('.rd-js-search-ui');
    if (!btn || !bar) return;
    var initialized = false;

    btn.setAttribute('aria-expanded', 'false');
    if (bar.id) btn.setAttribute('aria-controls', bar.id);

    function openSearch() {
        bar.classList.add('rd-is-open');
        btn.setAttribute('aria-expanded', 'true');

        // Option B: wrap this block in loadPagefind().then(function () { ... })
        if (!initialized && window.PagefindUI && container) {
            if (!container.id) container.id = 'rd-search-ui';
            new PagefindUI({
                element: '#' + container.id,
                showSubResults: true,
                translations: { placeholder: 'Search…' }
            });
            initialized = true;
        }

        setTimeout(function() {
            if (container) {
                var input = container.querySelector('input[type="text"]') || container.querySelector('input');
                if (input && input.focus) input.focus();
            }
        }, 50);

        document.addEventListener('keydown', escListener);
    }

    function closeSearch() {
        bar.classList.remove('rd-is-open');
        btn.setAttribute('aria-expanded', 'false');
        document.removeEventListener('keydown', escListener);
    }

    function escListener(e) {
        if (e.key === 'Escape') closeSearch();
    }

    btn.addEventListener('click', function(e) {
        e.preventDefault();
        if (bar.classList.contains('rd-is-open')) {
            closeSearch();
        } else {
            openSearch();
        }
    });
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initSearchUI);
} else {
    initSearchUI();
}

Finally, make sure this script is included at the end of the page, before </body>:

<script src="/assets/js/search.js"></script>

After you start the site locally and Pagefind finishes indexing (for example by running npm run build from your terminal), the search UI should be fully functional.

You can always customize how the search results look by applying your own styles.

⚠️ Performance tip: For production, download the script and host it on your own server rather than loading it from a CDN.

Indexing Controls

Pagefind gives you several simple HTML-based controls to fine-tune what gets indexed and how search results are ranked. These settings are added directly in your templates using attributes or meta tags. The full list of options is available in the official documentation, below are the ones you will most likely need in practice.

Excluding a page from the index

If a page should never appear in search results (for example, the search page itself or some drafts), set a flag in the template:

{% set pagefind_exclude = true %}

Then in your layout <head> section, add a conditional meta tag:

{% if pagefind_exclude %}
  <meta name="pagefind:exclude" content="true">
{% endif %}

Any page with this flag will be completely removed from the index.

Limiting the indexed area on a page

By default, Pagefind indexes the entire visible HTML. To prevent navigation bars, footers, hidden UI elements or banners from being indexed, you can explicitly mark only the “content” area using data-pagefind-body:

<header> … navigation, logo … </header>

<main data-pagefind-body>
    <!-- only this content will be indexe -->
    <h1>Article title</h1>
    <p>Some text of the article…</p>
</main>

<footer> … contacts, copyright … </footer>

Everything outside the data-pagefind-body container will be ignored during indexing.

Ignoring specific elements

When you do not want to restructure the layout or you only need to ignore a few blocks, you can mark them individually:

<nav data-pagefind-ignore>…</nav>
<div class="sidebar" data-pagefind-ignore>…</div>
<div class="ads" data-pagefind-ignore>…</div>

Ignored nodes and everything inside them will not be indexed.

Prioritizing certain pages in search results

You can give some pages more “weight” so they will appear higher in search results. For example, to boost blog posts over static pages:

<html data-pagefind-weight="10">

And for lower-priority pages:

<html data-pagefind-weight="2">

Higher values result in higher ranking when relevance is equal.

Additional configuration

Pagefind also supports optional configuration for translations, filters, metadata-based filtering, and other advanced options (for example: restricting results by tags, grouping by content type, or customizing UI strings). These features are configured either through HTML attributes or through the PagefindUI initialization options. Refer to the official documentation for the full list of supported options.

Search Page

In addition to the “overlay” search opened from a button in the header, you may also want to have a stand-alone search page at /search/. This is useful when you want a full-page search experience (especially on mobile), or when users expect search to exist as a real page in navigation.

1. Create a new template for the search page.

Create a file such as search.njk with the following front matter and markup:

---
layout: page
title: Search
permalink: /search/
pagefind_exclude: true # do not index this page itself
disable_header_search: true # hide the overlay-search button on this page
body_class: page-search # optional CSS hook
---

<div id="search-page"></div>

<script>
  document.addEventListener('DOMContentLoaded', function () {
    if (window.PagefindUI) {
      new PagefindUI({
        element: '#search-page',   // stand-alone search container
        showSubResults: true,
        translations: {
          placeholder: 'Search…',
          load_more: 'Load more'
        }
      });
    }
  });
</script>

Key points:

  • pagefind_exclude: true ensures this page will not show up in the search results itself.
  • we use a different container (#search-page) so it does not conflict with the overlay search container.
  • we optionally pass disable_header_search: true so that the header button won’t show on this page.

To conditionally style or change behavior on specific pages (for example, on the dedicated search page), you can pass a custom class through front matter and render it on the <body> element.

<body class="{{ body_class }}">

With the example above, the search page will render as:

<body class="page-search">

This makes it possible to target this page in CSS or conditionally skip overlay initialization in JavaScript.

2. Hide the overlay-search button on this page (optional)

In your header template, wrap the search button in a condition:

{% if not disable_header_search %}
  <button class="rd-js-search" ...>…</button>
{% endif %}

Alternatively, you may simply hide it with CSS using .page-search as a body class.

3. Prevent the overlay script from running on the search page

If your overlay is initialized by a global script such as /assets/js/search.js, add a guard at the top:

// Do not initialize the overlay search on the dedicated /search/ page
if (document.body.classList.contains('page-search')) return;

This ensures there will never be two search UIs active on the same page.

At this point, the setup provides two independent search interfaces: an overlay search that is opened from the header on regular pages, and a dedicated /search/ page with a full-page search interface. They do not conflict with each other because they use separate containers and separate initialization logic. The search page itself is excluded from the index so it never appears in search results, which is the recommended practice for dedicated search pages.

Changelog

v2.0.0

  • Breaking: hooks moved to the rd-js contract — rd-js-search (button), rd-js-search-bar (container), rd-js-search-ui (Pagefind mount); the search-button / searchbar / search IDs are no longer read
  • Breaking: visibility is expressed by the rd-is-open state class instead of the hidden attribute
  • Removed the dead has-query class write (it was styled nowhere)
  • Added aria-expanded on the toggle button; guard added for pages without the hooks