← Navigation

Search Overlay

Tap the search button and the page dims and blurs as a search box drops in over it, then the suggestions cascade in underneath, one after another. Type and they filter as you go, with the matching letters picked out. The arrow keys move through them, and Escape or a tap outside closes it all.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap a search button and a list of suggestions in an element with data-search-overlay.
  3. The button opens the overlay and the list becomes the suggestions. Each <li> can hold a link.

Options

Add any of these attributes to the same element.

data-placeholderPlaceholder text in the search box. Default “Search…”.
data-headingThe small heading above the suggestions. Default “Suggestions”.
data-emptyWhat it says when nothing matches. Default “No matches”.
data-colorColour of the icon, the highlight and the matched letters. Default #6a0dad.
data-containCover the nearest positioned parent instead of the whole window. Handy for previews.
data-autoplayIt opens, types a search and closes by itself while in view, pausing when someone taps. Handy for demos.
data-queryWhat autoplay types. Default the first few letters of the second suggestion.

The overlay is a modal dialog: the page stops scrolling behind it, focus goes to the search box, and closing it hands focus back to the button. Enter follows the highlighted suggestion. It fires a toggle event (e.detail.open), and animacious.searchOverlay(el) opens or closes it from code. Filtering only searches the list you give it; listen to the box’s input event to fetch results of your own.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Search Overlay: tap the search button and the page dims and blurs as a
    // search box drops in over it, then the suggestions cascade in beneath
    // one after another. Type to filter them (matches are highlighted), use
    // the arrow keys to pick one, and Escape or a tap outside closes it all.
    // Usage: <div data-search-overlay>
    //          <button aria-label="Search">…icon…</button>
    //          <ul>
    //            <li><a href="/buttons">Buttons</a></li>
    //            <li><a href="/cards">Cards</a></li> …
    //          </ul>
    //        </div>
    // The button opens it and the list becomes the suggestions.
    // Optional: data-placeholder (default "Search…"), data-heading (above the
    // suggestions, default "Suggestions"), data-empty (shown when nothing
    // matches, default "No matches"), data-color (highlight colour, default
    // #6a0dad), data-contain (cover the nearest positioned parent instead of
    // the whole window: for demos and previews), data-autoplay (it opens,
    // types a search and closes by itself while in view; handy for demos),
    // data-query (what autoplay types, default the first few letters of the
    // second suggestion).
    // Listen for the "toggle" event: e.detail.open is true or false.
    // Script use: animacious.searchOverlay(el) opens or closes it.
    var DEFAULTS = { placeholder: 'Search…', heading: 'Suggestions', empty: 'No matches', color: '#6a0dad', stagger: 45 };
    var uid = 0;

    var ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>';

    var css =
      '.sro-overlay{position:fixed;inset:0;z-index:60;display:flex;justify-content:center;align-items:flex-start;padding:min(14vh,96px) 16px 16px;box-sizing:border-box;visibility:hidden;text-align:left}' +
      '.sro-contain .sro-overlay{position:absolute;padding:min(10%,40px) 12px 12px}' +
      '.sro-backdrop{position:absolute;inset:0;background:rgba(24,12,40,.42);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);opacity:0}' +
      '.sro-panel{position:relative;width:min(560px,100%);max-height:100%;display:flex;flex-direction:column;border-radius:16px;background:#fff;color:#2a1f3d;box-shadow:0 24px 60px rgba(20,8,40,.3);overflow:hidden;opacity:0}' +
      '.sro-field{flex:none;display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid #eee7f5}' +
      '.sro-field svg{flex:none;width:20px;height:20px;color:var(--sro-color)}' +
      '.sro-input{flex:1;min-width:0;border:0;outline:0;background:none;font:inherit;font-size:16px;color:inherit;padding:2px 0}' +
      'span.sro-input:empty::before{content:attr(data-ph);color:#9a8fab}' +
      'span.sro-input::after{content:"";display:inline-block;width:2px;height:1.1em;margin-left:1px;vertical-align:-.2em;background:var(--sro-color);animation:sro-caret 1s steps(1) infinite}' +
      '@keyframes sro-caret{50%{opacity:0}}' +
      '.sro-esc{flex:none;padding:3px 7px;border:1px solid #e4dbf0;border-radius:6px;background:#faf7fd;color:#7a6d8c;font:inherit;font-size:11px;font-weight:600;cursor:pointer}' +
      '.sro-head{flex:none;padding:12px 16px 4px;font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#8b7f9c}' +
      '.sro-list{list-style:none;margin:0;padding:4px 8px 10px;overflow-y:auto}' +
      '.sro-list>li{margin:0}' +
      '.sro-list>li>*{display:block;padding:9px 10px;border-radius:10px;color:inherit;text-decoration:none}' +
      '.sro-list>li.sro-on>*{background:color-mix(in srgb,var(--sro-color) 9%,#fff);color:var(--sro-color)}' +
      '.sro-list mark{background:none;color:var(--sro-color);font-weight:700}' +
      '.sro-none{padding:10px 18px 16px;color:#8b7f9c}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function esc(t) { return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }

    function query(s) { return (s.input.value !== undefined ? s.input.value : s.input.textContent).trim().toLowerCase(); }

    function setActive(s, k) {
      s.active = k;
      s.items.forEach(function (it) { it.li.classList.toggle('sro-on', it === s.shown[k]); });
    }

    // Show only the suggestions that match, highlighting the match; ones that
    // have just come back cascade in.
    function filter(s, animate) {
      var q = query(s);
      var back = [];
      s.shown = [];
      s.items.forEach(function (it) {
        var i = q ? it.text.toLowerCase().indexOf(q) : 0;
        var ok = i >= 0;
        if (it.plain) {
          it.label.innerHTML = q && ok ? esc(it.text.slice(0, i)) + '<mark>' + esc(it.text.slice(i, i + q.length)) + '</mark>' + esc(it.text.slice(i + q.length)) : esc(it.text);
        }
        if (ok && it.li.hidden) back.push(it.li);
        it.li.hidden = !ok;
        if (ok) s.shown.push(it);
      });
      s.none.hidden = s.shown.length > 0;
      setActive(s, s.shown.length ? 0 : -1);
      if (animate && back.length && !reduced()) {
        anime.animate(back, { opacity: [0, 1], y: [8, 0], duration: 320, delay: anime.stagger(35), ease: 'out(3)' });
      }
    }

    function toggle(el, user) {
      var s = el._sro;
      var open = (s.open = !s.open);
      el.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { open: open } }));
      if (s.a11y) s.trigger.setAttribute('aria-expanded', open);
      if (!s.contain) document.documentElement.style.overflow = open ? 'hidden' : '';
      var lis = s.items.map(function (it) { return it.li; });
      anime.utils.remove([s.backdrop, s.panel, s.head].concat(lis));
      var still = reduced();
      if (open) {
        if (s.input.value !== undefined) s.input.value = ''; else s.input.textContent = '';
        filter(s, false);
        s.overlay.style.visibility = 'visible';
        if (s.a11y) s.overlay.removeAttribute('inert');
        if (user && s.input.focus) s.input.focus({ preventScroll: true });
        if (still) {
          anime.utils.set([s.backdrop, s.panel, s.head], { opacity: 1 });
          anime.utils.set(lis, { opacity: 1, y: 0 });
          return;
        }
        anime.animate(s.backdrop, { opacity: [0, 1], duration: 320, ease: 'out(2)' });
        anime.animate(s.panel, { opacity: [0, 1], y: [-24, 0], scale: [0.97, 1], duration: 460, ease: 'out(4)' });
        anime.animate(s.head, { opacity: [0, 1], duration: 300, delay: 160, ease: 'out(2)' });
        anime.animate(lis, { opacity: [0, 1], y: [14, 0], duration: 420, delay: anime.stagger(s.stagger, { start: 200 }), ease: 'out(3)' });
      } else {
        var hide = function () { if (!s.open) { s.overlay.style.visibility = 'hidden'; if (s.a11y) s.overlay.setAttribute('inert', ''); } };
        if (s.a11y && s.overlay.contains(document.activeElement)) s.trigger.focus({ preventScroll: true });
        if (still) { anime.utils.set([s.backdrop, s.panel], { opacity: 0 }); hide(); return; }
        anime.animate(lis, { opacity: 0, duration: 150, ease: 'in(2)' });
        anime.animate(s.panel, { opacity: 0, y: -14, scale: 0.98, duration: 260, delay: 60, ease: 'in(2)' });
        anime.animate(s.backdrop, { opacity: 0, duration: 300, delay: 100, ease: 'in(2)', onComplete: hide });
      }
    }

    function setup(el) {
      var d = el.dataset;
      var list = el.querySelector('ul,ol');
      var trigger = el.querySelector('[data-trigger]') || el.querySelector('button') || el.firstElementChild;
      if (!list || !trigger || trigger === list) return;
      var a11y = !el.closest('[aria-hidden="true"]');
      var contain = d.contain !== undefined && d.contain !== 'false';
      el.classList.add('sro');
      if (contain) el.classList.add('sro-contain');
      el.style.setProperty('--sro-color', d.color || DEFAULTS.color);

      var overlay = document.createElement('div');
      overlay.className = 'sro-overlay';
      var backdrop = document.createElement('div');
      backdrop.className = 'sro-backdrop';
      var panel = document.createElement('div');
      panel.className = 'sro-panel';
      var field = document.createElement('div');
      field.className = 'sro-field';
      field.innerHTML = ICON;
      var input, close;
      if (a11y) {
        overlay.setAttribute('role', 'dialog');
        overlay.setAttribute('aria-modal', 'true');
        overlay.setAttribute('aria-label', 'Search');
        overlay.setAttribute('inert', '');
        input = document.createElement('input');
        input.type = 'search';
        input.placeholder = d.placeholder || DEFAULTS.placeholder;
        input.setAttribute('aria-label', 'Search');
        input.setAttribute('autocomplete', 'off');
        close = document.createElement('button');
        close.type = 'button';
        close.setAttribute('aria-label', 'Close search');
      } else {
        input = document.createElement('span');
        input.setAttribute('data-ph', d.placeholder || DEFAULTS.placeholder);
        close = document.createElement('span');
      }
      input.className = 'sro-input';
      close.className = 'sro-esc';
      close.textContent = 'Esc';
      field.appendChild(input);
      field.appendChild(close);
      var head = document.createElement('div');
      head.className = 'sro-head';
      head.setAttribute('aria-hidden', 'true');
      head.textContent = d.heading || DEFAULTS.heading;
      var none = document.createElement('div');
      none.className = 'sro-none';
      none.textContent = d.empty || DEFAULTS.empty;
      none.hidden = true;
      list.classList.add('sro-list');
      list.id = list.id || 'search-overlay-list-' + (++uid);
      panel.appendChild(field);
      panel.appendChild(head);
      panel.appendChild(list);
      panel.appendChild(none);
      overlay.appendChild(backdrop);
      overlay.appendChild(panel);
      el.appendChild(overlay);

      var items = Array.prototype.map.call(list.children, function (li) {
        var label = li.firstElementChild || li;
        return { li: li, label: label, text: label.textContent.trim(), plain: label.children.length === 0 };
      });
      var s = el._sro = {
        overlay: overlay, backdrop: backdrop, panel: panel, head: head, input: input, none: none, trigger: trigger,
        items: items, shown: items.slice(), active: -1, open: false, a11y: a11y, contain: contain, stagger: DEFAULTS.stagger
      };
      if (a11y) {
        trigger.setAttribute('aria-expanded', 'false');
        trigger.setAttribute('aria-haspopup', 'dialog');
        if (trigger.tagName === 'BUTTON' && !trigger.getAttribute('type')) trigger.type = 'button';
      }

      trigger.addEventListener('click', function () { if (!s.open) toggle(el, true); });
      close.addEventListener('click', function () { if (s.open) toggle(el); });
      backdrop.addEventListener('click', function () { if (s.open) toggle(el); });
      if (a11y) input.addEventListener('input', function () { filter(s, true); });
      list.addEventListener('click', function (e) {
        var a = e.target.closest('a');
        if (a && a.getAttribute('href') === '#') e.preventDefault();
        if (s.open) toggle(el);
      });
      document.addEventListener('keydown', function (e) {
        if (!s.open) return;
        if (e.key === 'Escape') { e.preventDefault(); toggle(el); }
        else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && s.shown.length) {
          e.preventDefault();
          var n = s.shown.length;
          setActive(s, (s.active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n);
          s.shown[s.active].li.scrollIntoView({ block: 'nearest' });
        } else if (e.key === 'Enter' && document.activeElement === input && s.shown[s.active]) {
          e.preventDefault();
          var t = s.shown[s.active].label;
          if (t.click) t.click();
        }
      });

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
        var q = d.query || (items[1] || items[0]).text.slice(0, 3).toLowerCase();
        var timer = null, idleUntil = 0, step = 0;
        el.addEventListener('pointerdown', function () { idleUntil = Date.now() + 9000; });
        overlay.addEventListener('pointerdown', function () { idleUntil = Date.now() + 9000; });
        // Open, pause, type the query a letter at a time, pause, close.
        var tick = function () {
          var wait = 140;
          var busy = Date.now() < idleUntil || (a11y && overlay.contains(document.activeElement));
          if (busy) { timer = setTimeout(tick, 600); return; }
          if (step === 0) { if (!s.open) toggle(el); wait = 1400; }
          else if (step <= q.length) {
            if (input.value !== undefined) input.value = q.slice(0, step); else input.textContent = q.slice(0, step);
            filter(s, true);
            wait = step === q.length ? 2000 : 160;
          } else if (step === q.length + 1) {
            if (s.shown.length > 1) setActive(s, 1);
            wait = 900;
          } else { if (s.open) toggle(el); step = -1; wait = 1500; }
          step++;
          timer = setTimeout(tick, wait);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(tick, 700);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-search-overlay]').forEach(function (el) {
        if (el._sro) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.searchOverlay = function (el) { if (el._sro) toggle(el); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-search-overlay>
  <button>Search</button>
  <ul>
    <li><a href="/buttons">Buttons</a></li>
    <li><a href="/cards">Cards</a></li>
    <li><a href="/menus">Menus</a></li>
  </ul>
</div>