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
- Paste the snippet just before
</head>. - Wrap a search input in an element with
data-search-expand. - Size it with
font-size: closed, it’s a 2.75em circle.
Options
Add any of these attributes to the same element.
data-width | Width when open, any CSS length. Never wider than its container. Default 16em. |
data-color | Icon colour. Default #52525b. |
data-open | Start open. |
data-autoplay | It opens, types and closes again, over and over while in view. It stops as soon as you use it. |
data-text | What autoplay types. Default animations. |
The button has aria-expanded. Listen for the toggle event: e.detail.open is true or false.
<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><form action="/search">
<div data-search-expand data-width="20em">
<input type="search" name="q" placeholder="Search">
</div>
</form>