← Forms

Search Expand

A round search button stretches open into a text field. The magnifier gives a little turn, the field fades in ready to type and a clear button pops up at the end. Press Escape, clear it, or leave it empty and it shrinks back into the button.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap a search input in an element with data-search-expand.
  3. Size it with font-size: closed, it’s a 2.75em circle.

Options

Add any of these attributes to the same element.

data-widthWidth when open, any CSS length. Never wider than its container. Default 16em.
data-colorIcon colour. Default #52525b.
data-openStart open.
data-autoplayIt opens, types and closes again, over and over while in view. It stops as soon as you use it.
data-textWhat autoplay types. Default animations.

The button has aria-expanded. Listen for the toggle event: e.detail.open is true or false.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Search Expand: a round search button stretches open into a text field.
    // Tap the magnifier and the pill grows sideways, the magnifier gives a
    // little turn, the field fades in ready to type and a clear button pops
    // up at the end. Press Escape, clear it, or leave it empty and it shrinks
    // back into the button.
    // Usage: <div data-search-expand><input type="search" placeholder="Search"></div>
    // Size it with font-size: closed it's 2.75em round.
    // Optional: data-width (open width, any CSS length, default 16em; never
    // wider than its container), data-color (icon colour, default #52525b),
    // data-open (start open), data-autoplay (it opens, types data-text and
    // closes again, over and over while in view; it stops as soon as you use
    // it), data-text (what autoplay types, default "animations").
    // Listen for the "toggle" event: e.detail.open is true or false.
    var DEFAULTS = { width: '16em', color: '#52525b', text: 'animations' };

    var ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5L20 20"/></svg>';
    var CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17"/></svg>';

    var css =
      '.srx{position:relative;display:inline-block;box-sizing:border-box;width:2.75em;max-width:100%;height:2.75em;border-radius:1.375em;' +
      'background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,0.08),0 4px 14px rgba(15,15,30,0.08);overflow:hidden;vertical-align:middle;text-align:left}' +
      '.srx-btn,.srx-x{position:absolute;top:0;display:grid;place-items:center;width:2.75em;height:2.75em;margin:0;padding:0;border:0;border-radius:50%;' +
      'background:none;color:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
      '.srx-btn{left:0;z-index:1}.srx-x{right:0;opacity:0;visibility:hidden}' +
      '.srx-btn:focus-visible,.srx-x:focus-visible{outline:2px solid currentColor;outline-offset:-4px}' +
      '.srx-btn svg{width:1.2em;height:1.2em}.srx-x svg{width:1em;height:1em}' +
      '.srx-field{position:absolute;inset:0;box-sizing:border-box;width:100%;height:100%;margin:0;padding:0 2.6em 0 2.6em;border:0;border-radius:inherit;' +
      'background:transparent;color:inherit;font:inherit;line-height:2.75em;outline:none;opacity:0;visibility:hidden;white-space:pre;overflow:hidden;-webkit-appearance:none;appearance:none}' +
      '.srx-field::-webkit-search-cancel-button{-webkit-appearance:none;display:none}' +
      '.srx-field[data-placeholder]:not(.srx-filled)::before{content:attr(data-placeholder);color:#a1a1aa}' +
      '.srx-caret{display:inline-block;width:1.5px;height:1.1em;margin-left:1px;vertical-align:-0.2em;background:currentColor;animation:srx-blink 1s steps(1) infinite}' +
      '@keyframes srx-blink{50%{opacity:0}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }

    function setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var input = mock ? null : el.querySelector('input');
      var placeholder = (input && input.getAttribute('placeholder')) || 'Search';
      el.classList.add('srx');
      el.style.color = d.color || DEFAULTS.color;

      var btn = document.createElement(mock ? 'span' : 'button');
      btn.className = 'srx-btn';
      btn.innerHTML = ICON;
      var x = document.createElement(mock ? 'span' : 'button');
      x.className = 'srx-x';
      x.innerHTML = CLOSE;
      var field, text = null, caret = null;
      if (input) {
        field = input;
        if (!input.getAttribute('aria-label')) input.setAttribute('aria-label', placeholder);
        btn.type = x.type = 'button';
        btn.setAttribute('aria-label', 'Open search');
        btn.setAttribute('aria-expanded', 'false');
        x.setAttribute('aria-label', 'Close search');
      } else {
        el.textContent = '';
        field = document.createElement('span');
        field.setAttribute('data-placeholder', placeholder);
        text = document.createElement('span');
        field.appendChild(text);
      }
      field.classList.add('srx-field');
      el.appendChild(field);
      el.appendChild(btn);
      el.appendChild(x);
      var get = function () { return input ? input.value : text.textContent; };
      var set = function (v) {
        if (input) input.value = v;
        else {
          text.textContent = v;
          field.classList.toggle('srx-filled', !!v);
          // Only show the caret while typing (the placeholder shows otherwise).
          if (v && !caret) { caret = document.createElement('span'); caret.className = 'srx-caret'; field.appendChild(caret); }
          if (!v && caret) { caret.remove(); caret = null; }
        }
      };

      var closedW = 0, open = false;
      function target() {
        el.style.width = d.width || DEFAULTS.width;
        var w = el.offsetWidth;
        el.style.width = (open ? w : closedW) + 'px';
        return w;
      }
      closedW = el.offsetWidth;

      function toggle(next, focus) {
        if (next === open) return;
        open = next;
        var w = target();
        if (input) btn.setAttribute('aria-expanded', open);
        el.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { open: open } }));
        if (open) {
          field.style.visibility = x.style.visibility = 'visible';
          if (focus && input) input.focus();
        } else {
          set('');
          if (input && document.activeElement === input) input.blur();
        }
        var fin = function () {
          if (!open) field.style.visibility = x.style.visibility = 'hidden';
          el.style.width = (open ? w : closedW) + 'px';
        };
        if (reduced()) {
          anime.utils.set(field, { opacity: open ? 1 : 0 });
          anime.utils.set(x, { opacity: open ? 1 : 0, scale: 1 });
          fin();
          return;
        }
        el.style.width = (open ? closedW : w) + 'px';
        if (open) {
          // A springy overshoot, unless that would poke out of the container.
          var room = el.parentNode.clientWidth > w * 1.1;
          anime.animate(el, { width: [closedW, w], duration: room ? 650 : 450, ease: room ? 'outElastic(1, .85)' : 'out(4)', onComplete: fin });
          anime.animate(btn.firstChild, { rotate: [0, -90, 0], scale: [1, 0.85, 1], duration: 500, ease: 'inOut(2)' });
          anime.animate(field, { opacity: [0, 1], duration: 300, delay: 150, ease: 'out(2)' });
          anime.animate(x, { opacity: [0, 1], scale: [0.4, 1], rotate: [-90, 0], duration: 420, delay: 250, ease: 'outBack(2)' });
        } else {
          anime.animate(x, { opacity: 0, scale: 0.4, duration: 180, ease: 'in(2)' });
          anime.animate(field, { opacity: 0, duration: 160, ease: 'in(2)' });
          anime.animate(el, { width: closedW, duration: 420, delay: 90, ease: 'inOut(3)', onComplete: fin });
          anime.animate(btn.firstChild, { scale: [1, 1.2, 1], duration: 400, delay: 300, ease: 'out(2)' });
        }
      }

      if (input) {
        btn.addEventListener('click', function () { stop(); if (open) input.focus(); else toggle(true, true); });
        x.addEventListener('click', function () { stop(); toggle(false); btn.focus(); });
        input.addEventListener('focus', stop);
        input.addEventListener('keydown', function (e) { if (e.key === 'Escape') { toggle(false); btn.focus(); } });
        input.addEventListener('blur', function () {
          setTimeout(function () { if (open && !get() && !el.contains(document.activeElement)) toggle(false); }, 120);
        });
        el.addEventListener('pointerdown', stop);
      }
      // Refit when the container changes width (rotating a phone, say).
      var lastW = 0;
      if (window.ResizeObserver) new ResizeObserver(function (entries) {
        var w = entries[0].contentRect.width;
        if (w === lastW) return;
        lastW = w;
        requestAnimationFrame(function () { if (open) el.style.width = target() + 'px'; });
      }).observe(el.parentNode);
      if (on(d.open)) toggle(true, false);

      var timer = null, stopped = false;
      function stop() { stopped = true; clearTimeout(timer); }
      if (on(d.autoplay) && !reduced()) {
        var word = d.text || DEFAULTS.text;
        var steps = [[function () { toggle(true, false); }, 900]];
        for (var i = 1; i <= word.length; i++) {
          (function (n) { steps.push([function () { set(word.slice(0, n)); }, 70 + Math.random() * 80]); })(i);
        }
        steps.push([function () {}, 1500]);
        steps.push([function () { toggle(false); }, 1400]);
        var run = function (i) {
          if (stopped) return;
          steps[i][0]();
          timer = setTimeout(function () { run((i + 1) % steps.length); }, steps[i][1]);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (stopped) return;
          if (entries[0].isIntersecting) timer = setTimeout(function () { run(open ? steps.length - 1 : 0); }, 500);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-search-expand]').forEach(function (el) {
        if (el._srx) return;
        el._srx = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<form action="/search">
  <div data-search-expand data-width="20em">
    <input type="search" name="q" placeholder="Search">
  </div>
</form>