Searching Sources
A row of little site icons hops in a wave while a search runs, and every so often the first site slips away to the left as a new one slides in from the right. A shine runs along the “Searching the web” label. When the search is done the icons huddle into an overlapping stack and the label changes to say how many sources were found.
Is it true that octopuses have three hearts?
Searching the web
How to use it
- Paste the snippet just before
</head>. - Add
data-searching-sourcesto an element holding the label, with the site names indata-sources. Each becomes a coloured letter badge. - Or put the sites’ favicons inside it as
<img>tags and they’re used as the icons instead.
Options
Add any of these attributes to the same element.
data-sources | Site names, separated by |. |
data-done | Label once the search is done; {n} is the number of sources. Default “Found {n} sources”. |
data-visible | How many icons show in the row at once. Default 5. |
data-duration | How long the search runs. Default 4200 ms. |
data-loop | Search again after a pause. |
data-hold | How long the result shows before looping. Default 2400 ms. |
To match a real search, leave out data-loop and call animacious.searchingSources(el) each time one starts.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Searching Sources: a row of little site icons hops in a wave while a
// search runs, with new sites sliding in from the right as old ones leave.
// When the search is done the icons huddle into an overlapping stack and the
// label changes to say how many sources were found.
// Usage: <div data-searching-sources data-sources="Wikipedia|BBC|Nature|GitHub">Searching the web</div>
// or put <img> favicons inside: <div data-searching-sources><img src="…" alt="BBC">…</div>
// Optional: data-sources (site names separated by |; each becomes a coloured
// letter badge), data-done (label when finished, {n} is the number of
// sources; default "Found {n} sources"), data-visible (icons in the row,
// default 5), data-duration (ms the search runs, default 4200), data-loop
// (search again), data-hold (ms the result shows before looping, default 2400).
// Script use: animacious.searchingSources(el) runs it again.
var DEFAULTS = { visible: 5, duration: 4200, hold: 2400, done: 'Found {n} sources' };
var COLOURS = ['#2563eb', '#dc2626', '#059669', '#7c3aed', '#ea580c', '#0891b2', '#db2777', '#475569', '#ca8a04', '#4f46e5'];
var css =
'.sso{display:inline-flex;align-items:center;flex-wrap:wrap;justify-content:center;gap:.5em .7em;max-width:100%}' +
'.sso-row{position:relative;flex:none;height:calc(var(--sso-s) * 1.55);overflow:hidden}' +
'.sso-ico{position:absolute;left:0;bottom:3px;width:var(--sso-s);height:var(--sso-s);border-radius:30%;display:grid;' +
'place-items:center;color:#fff;font-weight:800;font-size:calc(var(--sso-s) * .5);line-height:1;overflow:hidden;' +
'box-shadow:0 0 0 2px var(--bg-card,#fff),0 2px 6px rgba(20,10,40,.18)}' +
'.sso-ico img{width:100%;height:100%;object-fit:cover;display:block;background:#fff}' +
'.sso-label{display:inline-grid;font-weight:600;white-space:nowrap}' +
'.sso-label>span{grid-area:1/1}' +
'.sso-run{background:linear-gradient(90deg,currentColor 0 35%,color-mix(in srgb,currentColor 35%,transparent) 50%,currentColor 65% 100%);' +
'background-size:300% 100%;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;animation:sso-shine 1.6s linear infinite}' +
'.sso-ok{opacity:0}' +
'@keyframes sso-shine{from{background-position:100% 0}to{background-position:0 0}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<'); }
function setup(el) {
var d = el.dataset;
var imgs = [].slice.call(el.querySelectorAll('img'));
var sources = imgs.length ? imgs.map(function (img) { return { img: img }; }) :
(d.sources || 'Wikipedia|BBC|Nature|GitHub|Reddit|NASA|Guardian|Medium').split('|').map(function (s) { return { name: s.trim() }; });
var label = el.textContent.trim() || 'Searching the web';
var n = sources.length;
var done = (d.done || DEFAULTS.done).replace('{n}', n);
var visible = Math.min(n, +d.visible || DEFAULTS.visible);
el.classList.add('sso');
el.setAttribute('role', 'status');
el.innerHTML = '<span class="sso-row" aria-hidden="true"></span>' +
'<span class="sso-label"><span class="sso-run">' + esc(label) + '</span><span class="sso-ok">' + esc(done) + '</span></span>';
var row = el.firstChild;
var icons = sources.map(function (s, i) {
var ico = document.createElement('span');
ico.className = 'sso-ico';
if (s.img) { s.img.alt = s.img.alt || ''; ico.appendChild(s.img); }
else { ico.textContent = s.name.charAt(0).toUpperCase(); ico.style.background = COLOURS[i % COLOURS.length]; }
row.appendChild(ico);
return ico;
});
el._sso = { icons: icons, row: row, visible: visible, run: 0, timers: [], anims: [],
runL: el.querySelector('.sso-run'), okL: el.querySelector('.sso-ok') };
measure(el);
}
// Icon size and step come from the font size, so the row scales with the text.
function measure(el) {
var st = el._sso;
var fs = parseFloat(getComputedStyle(el).fontSize) || 14;
st.size = Math.round(fs * 1.6);
st.step = st.size + Math.round(fs * 0.4);
el.style.setProperty('--sso-s', st.size + 'px');
st.row.style.width = (st.visible * st.step - (st.step - st.size) + 4) + 'px';
}
function stack(st) {
// The finished look: icons overlap like an avatar stack.
var show = Math.min(st.icons.length, st.visible);
st.row.style.width = (st.size + (show - 1) * st.size * 0.62 + 4) + 'px';
st.icons.forEach(function (ico, i) {
anime.utils.set(ico, { x: i < show ? 2 + i * st.size * 0.62 : -st.size * 2, y: 0, opacity: i < show ? 1 : 0, scale: 1 });
ico.style.zIndex = show - i;
});
st.runL.style.opacity = 0;
st.okL.style.opacity = 1;
}
function play(el) {
var st = el._sso, d = el.dataset;
var duration = +d.duration || DEFAULTS.duration;
var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
var run = ++st.run;
st.timers.forEach(clearTimeout);
st.anims.forEach(function (a) { a.pause(); });
st.timers = [];
st.anims = [];
function later(fn, ms) { st.timers.push(setTimeout(function () { if (run === st.run) fn(); }, ms)); }
function anim(t, p) { var a = anime.animate(t, p); st.anims.push(a); return a; }
measure(el);
var n = st.icons.length, vis = st.visible, step = st.step;
var first = 0; // index of the icon in slot 0
st.runL.style.opacity = 1;
st.okL.style.opacity = 0;
anime.utils.set(st.runL, { y: 0 });
st.icons.forEach(function (ico, i) {
ico.style.zIndex = '';
anime.utils.set(ico, { x: (i < vis ? i : vis) * step + 2, y: 0, scale: 1, opacity: i < vis ? 0 : 0 });
});
// The first icons pop in one by one.
for (var k = 0; k < vis; k++) {
anim(st.icons[k], { opacity: [0, 1], scale: [0.4, 1], duration: 380, delay: k * 70, ease: 'outBack(2)' });
}
var start = performance.now();
function wave() {
// Each visible icon hops in turn…
for (var s = 0; s < vis; s++) {
var ico = st.icons[(first + s) % n];
anim(ico, { y: [0, -st.size * 0.38, 0], duration: 420, delay: s * 110, ease: 'inOut(2)' });
}
var waveTime = (vis - 1) * 110 + 420;
later(function () {
if (performance.now() - start + 600 > duration || n <= vis) {
if (performance.now() - start + 600 > duration) return finish();
return later(wave, 120);
}
// …then the row shifts: the first site leaves, a new one slides in.
var out = st.icons[first];
var inc = st.icons[(first + vis) % n];
anime.utils.set(inc, { x: vis * step + 2, y: 0, opacity: 0, scale: 0.6 });
anim(out, { x: '-=' + step, opacity: 0, scale: 0.6, duration: 380, ease: 'inOut(2)' });
for (var s2 = 1; s2 < vis; s2++) anim(st.icons[(first + s2) % n], { x: '-=' + step, duration: 380, ease: 'inOut(2)' });
anim(inc, { x: (vis - 1) * step + 2, opacity: 1, scale: 1, duration: 380, ease: 'inOut(2)' });
first = (first + 1) % n;
later(wave, 440);
}, waveTime + 80);
}
later(wave, vis * 70 + 300);
function finish() {
// Huddle the visible icons into an overlapping stack.
var show = Math.min(n, vis);
var stackW = st.size + (show - 1) * st.size * 0.62 + 4;
for (var s = 0; s < show; s++) {
var ico = st.icons[(first + s) % n];
ico.style.zIndex = show - s;
anim(ico, { x: 2 + s * st.size * 0.62, y: 0, duration: 520, delay: s * 30, ease: 'outBack(1.4)' });
}
anim(st.row, { width: stackW, duration: 520, ease: 'out(3)' });
anim(st.runL, { opacity: 0, y: -6, duration: 240, ease: 'in(2)' });
anim(st.okL, { opacity: [0, 1], y: [6, 0], duration: 380, delay: 160, ease: 'out(3)' });
if (d.loop !== undefined) later(function () {
anim(st.icons, { opacity: 0, scale: 0.6, duration: 300, delay: anime.stagger(40), ease: 'in(2)' });
anim(st.okL, { opacity: 0, duration: 300, ease: 'in(2)' });
later(function () {
st.row.style.width = '';
play(el);
}, 420);
}, hold + 520);
}
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-searching-sources]').forEach(function (el) {
if (el._sso) return;
setup(el);
if (reduced()) stack(el._sso);
else {
el._sso.icons.forEach(function (ico) { anime.utils.set(ico, { opacity: 0 }); });
io.observe(el);
}
});
}
window.animacious = window.animacious || {};
window.animacious.searchingSources = function (el) { if (el && el._sso) play(el); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-searching-sources data-sources="Wikipedia|BBC|Nature|GitHub|Reddit">
Searching the web
</div>