← Typing

Query Results

A database query types itself into an editor with its SQL colour-coded. Run is pressed, a spinner ticks for a moment, and the results table fills in row by row, each new row glowing briefly, with the row count and timing above.

namecitymrr
NorthwindLeeds4,820
Bluebird CoBristol3,975
FieldnoteGlasgow3,140
Paper LaneCardiff2,610
KestrelYork2,385

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-query-results to a container holding a <table>, and put the SQL in data-query. The table’s first row is the header.

Options

Add any of these attributes to the same element.

data-queryThe SQL to type.
data-titleThe tab name above the editor. Default query.sql.
data-timeThe milliseconds shown in the status line. Default 12.
data-speedTyping time per character. Default 34 ms.
data-staggerGap between rows arriving. Default 160 ms.
data-loopClear and run the query again after a pause.
data-holdHow long the results stay before looping. Default 2600 ms.

The table stays a real table, so screen readers can read the results. The query box keeps its finished height from the start.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Query Results: a database query types into an editor, runs with a
    // spinner, and the results table fills in row by row with a row count and
    // timing.
    // Usage:
    //   <div data-query-results data-query="SELECT name, plan FROM users LIMIT 3;">
    //     <table>
    //       <tr><th>name</th><th>plan</th></tr>
    //       <tr><td>Ada</td><td>pro</td></tr>
    //       <tr><td>Linus</td><td>free</td></tr>
    //     </table>
    //   </div>
    // The first row of the table is the header; the rest are results.
    // Optional: data-query (the SQL to type, default "SELECT * FROM results;"),
    // data-title (tab name, default "query.sql"), data-time (ms shown in the
    // status line, default 12), data-speed (ms per character, default 34),
    // data-stagger (ms between rows, default 160), data-loop (clear and run
    // again), data-hold (ms the results stay before looping, default 2600).
    var DEFAULTS = { query: 'SELECT * FROM results;', title: 'query.sql', time: 12, speed: 34, stagger: 160, hold: 2600 };
    var SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
    var KEYWORDS = /^(select|from|where|and|or|order|by|group|limit|as|join|left|right|inner|on|desc|asc|count|sum|avg|having|insert|into|values|update|set|delete|distinct|not|in|is|null|like)$/i;

    var css =
      '.qry{background:#101015;color:#e4e4e7;border-radius:12px;overflow:hidden;text-align:left;box-shadow:0 18px 40px rgba(10,10,20,.28)}' +
      '.qry-bar{display:flex;align-items:center;gap:0.6em;padding:0.55em 0.8em;background:#1b1b22;font-size:0.85em;color:#a1a1aa}' +
      '.qry-tab{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}' +
      '.qry-run{flex:none;display:inline-flex;align-items:center;gap:0.4em;padding:0.3em 0.8em;border-radius:6px;background:#22c55e;color:#06260f;font-weight:700}' +
      '.qry-run i{width:0;height:0;border-style:solid;border-width:0.35em 0 0.35em 0.55em;border-color:transparent transparent transparent currentColor}' +
      '.qry-sql{margin:0;padding:0.8em 1em;min-height:1.6em;line-height:1.6;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word;border-bottom:1px solid #26262e}' +
      '.qry-k{color:#c084fc;font-weight:600}.qry-s{color:#86efac}.qry-n{color:#fbbf24}.qry-p{color:#71717a}' +
      '.qry-status{display:flex;align-items:center;gap:0.5em;padding:0.45em 1em;font-size:0.82em;color:#8a8a93;min-height:1.5em;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}' +
      '.qry-spin{color:#c084fc}.qry-ok{color:#4ade80}' +
      '.qry-out{padding:0 0.6em 0.7em;overflow:hidden}' +
      '.qry-out table{width:100%;border-collapse:separate;border-spacing:0;table-layout:fixed;font-variant-numeric:tabular-nums}' +
      '.qry-out th,.qry-out td{padding:0.4em 0.5em;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-bottom:1px solid #22222a}' +
      '.qry-out th{font-weight:600;color:#a1a1aa;font-size:0.85em;text-transform:none;border-bottom-color:#33333d}' +
      '.qry-out tr.qry-new td{background:rgba(192,132,252,.16)}' +
      '.qry-out td{transition:background-color .7s}' +
      '.qry-caret{display:inline-block;width:0.55em;height:1.15em;vertical-align:-0.2em;background:#e4e4e7;margin-left:1px}' +
      '.qry-caret.qry-blink{animation:qry-blink 1.06s steps(1) infinite}@keyframes qry-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 sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
    function opt(d, k) { return d[k] !== undefined && d[k] !== '' ? d[k] : DEFAULTS[k]; }
    function esc(t) { return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }

    // A tiny SQL colourer: keywords, strings, numbers and punctuation.
    function colour(src) {
      return src.replace(/('[^']*'?)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_]+)|([(),;*=<>])|([^'A-Za-z_\d(),;*=<>]+)/g, function (m, s, n, w, p, rest) {
        if (s) return '<span class="qry-s">' + esc(s) + '</span>';
        if (n) return '<span class="qry-n">' + n + '</span>';
        if (w) return KEYWORDS.test(w) ? '<span class="qry-k">' + w + '</span>' : w;
        if (p) return '<span class="qry-p">' + esc(p) + '</span>';
        return esc(rest);
      });
    }

    function setup(el) {
      var d = el.dataset;
      var table = el.querySelector('table');
      var rows = table ? Array.from(table.querySelectorAll('tr')) : [];
      var head = rows.shift();
      el.textContent = '';
      el.classList.add('qry');
      var bar = document.createElement('div');
      bar.className = 'qry-bar';
      bar.setAttribute('aria-hidden', 'true');
      bar.innerHTML = '<span class="qry-tab"></span><span class="qry-run"><i></i>Run</span>';
      bar.firstChild.textContent = opt(d, 'title');
      var sql = document.createElement('pre');
      sql.className = 'qry-sql';
      var status = document.createElement('div');
      status.className = 'qry-status';
      status.setAttribute('aria-live', 'off');
      var out = document.createElement('div');
      out.className = 'qry-out';
      if (table) {
        // Rebuild a clean table so the original markup's own styles don't fight ours.
        var t = document.createElement('table');
        var thead = t.createTHead();
        if (head) thead.appendChild(head);
        var tbody = t.createTBody();
        rows.forEach(function (r) { tbody.appendChild(r); });
        out.appendChild(t);
      }
      el.appendChild(bar);
      el.appendChild(sql);
      el.appendChild(status);
      el.appendChild(out);
      var caret = document.createElement('span');
      caret.className = 'qry-caret';
      caret.setAttribute('aria-hidden', 'true');
      return { el: el, query: opt(d, 'query'), sql: sql, status: status, run: bar.querySelector('.qry-run'), head: head, rows: rows, caret: caret, visible: false, wake: null, spinT: 0 };
    }

    function statusDone(s) {
      var n = s.rows.length;
      s.status.innerHTML = '<span class="qry-ok">✓</span><span></span>';
      s.status.lastChild.textContent = n + (n === 1 ? ' row' : ' rows') + ' · ' + opt(s.el.dataset, 'time') + ' ms';
    }

    function finished(s) {
      s.sql.innerHTML = colour(s.query);
      statusDone(s);
    }

    function blank(s) {
      s.sql.textContent = '';
      s.status.textContent = '';
      if (s.head) anime.utils.set(s.head, { opacity: 0 });
      s.rows.forEach(function (r) { anime.utils.set(r, { opacity: 0 }); r.classList.remove('qry-new'); });
    }

    function whenVisible(s) {
      return s.visible ? Promise.resolve() : new Promise(function (r) { s.wake = r; });
    }

    function type(s, speed) {
      return new Promise(function (done) {
        var n = 0;
        s.caret.classList.remove('qry-blink');
        (function tick() {
          s.sql.innerHTML = colour(s.query.slice(0, n));
          s.sql.appendChild(s.caret);
          if (n++ >= s.query.length) { done(); return; }
          var ch = s.query.charAt(n - 1);
          setTimeout(tick, speed * (0.5 + Math.random()) + (ch === ' ' ? speed : 0));
        })();
      });
    }

    function play(s) {
      var d = s.el.dataset;
      var stagger = +opt(d, 'stagger');
      blank(s);
      sleep(400).then(function () {
        return type(s, +opt(d, 'speed'));
      }).then(function () {
        s.caret.classList.add('qry-blink');
        return sleep(450);
      }).then(function () {
        anime.animate(s.run, { scale: [1, 0.88, 1], duration: 260, ease: 'inOut(2)' });
        s.caret.remove();
        s.status.innerHTML = '<span class="qry-spin"></span><span>Running query…</span>';
        var spin = s.status.firstChild, i = 0;
        spin.textContent = SPIN[0];
        s.spinT = setInterval(function () { spin.textContent = SPIN[++i % SPIN.length]; }, 80);
        return sleep(900 + Math.random() * 300);
      }).then(function () {
        clearInterval(s.spinT);
        statusDone(s);
        if (s.head) anime.animate(s.head, { opacity: [0, 1], duration: 300, ease: 'out(2)' });
        s.rows.forEach(function (r, i) {
          setTimeout(function () {
            r.classList.add('qry-new');
            anime.animate(r, { opacity: [0, 1], duration: 260, ease: 'out(2)' });
            anime.animate(r.children, { y: [-6, 0], duration: 320, ease: 'out(3)' });
            setTimeout(function () { r.classList.remove('qry-new'); }, 350);
          }, 200 + i * stagger);
        });
        return sleep(200 + s.rows.length * stagger + 400);
      }).then(function () {
        if (d.loop === undefined || d.loop === 'false') return;
        return sleep(+opt(d, 'hold')).then(function () { return whenVisible(s); }).then(function () {
          var all = s.rows.slice();
          if (s.head) all.unshift(s.head);
          anime.animate(all.concat([s.sql, s.status]), { opacity: 0, duration: 300, ease: 'in(2)' });
          return sleep(330);
        }).then(function () {
          anime.utils.set([s.sql, s.status], { opacity: 1 });
          play(s);
        });
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._qry;
          s.visible = e.isIntersecting;
          if (s.visible && s.wake) { var w = s.wake; s.wake = null; w(); }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-query-results]').forEach(function (el) {
        if (el._qry) return;
        var s = (el._qry = setup(el));
        finished(s);
        if (reduced()) return;
        // Keep the finished query's height so the window never grows while it types.
        var fs = parseFloat(getComputedStyle(s.sql).fontSize) || 16;
        if (s.sql.offsetHeight) s.sql.style.minHeight = (s.sql.offsetHeight / fs) + 'em';
        io.observe(el);
        whenVisible(s).then(function () { play(s); });
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-query-results data-query="SELECT name, plan FROM users;">
  <table>
    <tr><th>name</th><th>plan</th></tr>
    <tr><td>Ada</td><td>pro</td></tr>
    <tr><td>Linus</td><td>free</td></tr>
  </table>
</div>