← Typing

Code Editor Typing

Code types itself into an editor window, colour-coded as it goes. Line numbers appear as each line starts, indentation snaps in like a real editor’s, and the current line is highlighted. Loop it and the code is selected, cleared and typed again.

import { animate } from 'animejs';

// Spin the logo on load
animate('.logo', {
  rotate: '1turn',
  scale: [0.8, 1],
  duration: 1200,
  ease: 'outElastic(1, .6)',
});

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-code-editor-typing to a <pre> holding your code. It is built into an editor window and typed out line by line.

Options

Add any of these attributes to the same element.

data-fileThe file name on the editor’s tab. Default index.js.
data-speedTyping time per character. Default 38 ms.
data-loopSelect all, clear and type it again after a pause.
data-holdHow long the finished code stays before looping. Default 2800 ms.
data-theme="light"A light editor instead of the dark one.

The colouring is a small built-in highlighter for JavaScript-style code: keywords, strings, numbers, comments, function calls and object keys. Every line’s space is kept from the start, so the window never grows while it types.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Code Editor Typing: code types itself into an editor window, with syntax
    // colours, line numbers, auto-indent and a highlighted current line.
    // Usage: <pre data-code-editor-typing>const answer = 42;</pre>
    // Optional: data-file (the tab's file name, default "index.js"), data-speed
    // (ms per character, default 38), data-loop (select all, clear and type it
    // again), data-hold (ms the finished code stays before looping, default
    // 2800), data-theme="light" (a light editor).
    var DEFAULTS = { file: 'index.js', speed: 38, hold: 2800 };
    var KEYWORDS = ('const let var function return if else for while do switch case break continue ' +
      'import from export default new await async class extends try catch finally throw typeof ' +
      'instanceof in of true false null undefined this yield').split(' ');
    var TOKEN = new RegExp(
      '(\\/\\/.*)' +                                                   // 1 comment
      '|(\'(?:[^\'\\\\]|\\\\.)*\'?|"(?:[^"\\\\]|\\\\.)*"?|`(?:[^`\\\\]|\\\\.)*`?)' + // 2 string
      '|(\\b\\d+(?:\\.\\d+)?\\b)' +                                    // 3 number
      '|\\b(' + KEYWORDS.join('|') + ')\\b' +                          // 4 keyword
      '|([A-Za-z_$][\\w$]*)(?=\\s*\\()' +                              // 5 function call
      '|([A-Za-z_$][\\w$]*)(?=\\s*:)', 'g');                           // 6 object key
    var KINDS = [null, 'com', 'str', 'num', 'kw', 'fn', 'prop'];

    var css =
      '.cet{--cet-bg:#13131a;--cet-bar:#1c1c25;--cet-text:#e4e4e7;--cet-ln:#52525b;--cet-line:rgba(255,255,255,.05);' +
      '--cet-kw:#c084fc;--cet-str:#86efac;--cet-num:#fdba74;--cet-com:#71717a;--cet-fn:#7dd3fc;--cet-prop:#f9a8d4;--cet-sel:rgba(125,211,252,.22);' +
      'margin:0;white-space:normal;text-align:left;background:var(--cet-bg);color:var(--cet-text);border-radius:12px;overflow:hidden;' +
      'box-shadow:0 18px 40px rgba(10,10,20,.28);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}' +
      '.cet-light{--cet-bg:#fff;--cet-bar:#f4f4f5;--cet-text:#27272a;--cet-ln:#a1a1aa;--cet-line:rgba(106,13,173,.06);' +
      '--cet-kw:#7c3aed;--cet-str:#15803d;--cet-num:#c2410c;--cet-com:#a1a1aa;--cet-fn:#0369a1;--cet-prop:#be185d;--cet-sel:rgba(106,13,173,.16);' +
      'box-shadow:0 18px 40px rgba(106,13,173,.14)}' +
      '.cet-bar{display:flex;align-items:center;gap:6px;padding:0 0.9em;background:var(--cet-bar);height:2.5em}' +
      '.cet-bar i{width:0.75em;height:0.75em;border-radius:50%;background:#ff5f57;flex:none}' +
      '.cet-bar i:nth-child(2){background:#febc2e}.cet-bar i:nth-child(3){background:#28c840}' +
      '.cet-tab{margin-left:0.8em;align-self:flex-end;padding:0.45em 0.9em;border-radius:7px 7px 0 0;background:var(--cet-bg);font-size:0.85em;opacity:.85;' +
      'white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.cet-body{padding:0.7em 0 0.9em;overflow:hidden}' +
      '.cet-line{display:flex;line-height:1.6;min-height:1.6em}' +
      '.cet-line.cet-on{background:var(--cet-line)}' +
      '.cet-line.cet-sel .cet-code{background:var(--cet-sel)}' +
      '.cet-ln{flex:none;width:2.6em;padding-right:1em;text-align:right;color:var(--cet-ln);user-select:none}' +
      '.cet-on .cet-ln{color:var(--cet-text)}' +
      '.cet-code{white-space:pre;padding-right:1em}' +
      '.cet-kw{color:var(--cet-kw)}.cet-str{color:var(--cet-str)}.cet-num{color:var(--cet-num)}' +
      '.cet-com{color:var(--cet-com);font-style:italic}.cet-fn{color:var(--cet-fn)}.cet-prop{color:var(--cet-prop)}' +
      '.cet-caret{display:inline-block;width:2px;height:1.2em;margin-right:-2px;vertical-align:-0.25em;background:var(--cet-fn)}' +
      '.cet-caret.cet-blink{animation:cet-blink 1.06s steps(1) infinite}' +
      '@keyframes cet-blink{50%{opacity:0}}' +
      '.cet-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    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 esc(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }

    // Strip the blank first/last lines and the indentation the HTML added.
    function clean(src) {
      var lines = src.replace(/\t/g, '  ').replace(/^\s*\n/, '').replace(/\s+$/, '').split('\n');
      var pad = Infinity;
      lines.forEach(function (l) { if (l.trim()) pad = Math.min(pad, l.match(/^ */)[0].length); });
      return lines.map(function (l) { return l.slice(pad === Infinity ? 0 : pad); });
    }

    // Split a line into coloured segments: [{t: text, c: kind or ''}].
    function tokenise(line) {
      var segs = [], last = 0, m;
      TOKEN.lastIndex = 0;
      while ((m = TOKEN.exec(line))) {
        if (!m[0]) { TOKEN.lastIndex++; continue; }
        if (m.index > last) segs.push({ t: line.slice(last, m.index), c: '' });
        for (var k = 1; k < KINDS.length; k++) if (m[k] !== undefined) break;
        segs.push({ t: m[0], c: KINDS[k] });
        last = m.index + m[0].length;
      }
      if (last < line.length) segs.push({ t: line.slice(last), c: '' });
      return segs;
    }

    function html(segs, n) {
      var out = '';
      for (var i = 0; i < segs.length && n > 0; i++) {
        var t = esc(segs[i].t.slice(0, n));
        n -= segs[i].t.length;
        out += segs[i].c ? '<span class="cet-' + segs[i].c + '">' + t + '</span>' : t;
      }
      return out;
    }

    function setup(el) {
      var d = el.dataset;
      var src = clean(el.textContent);
      el.textContent = '';
      el.classList.add('cet');
      if (d.theme === 'light') el.classList.add('cet-light');
      var sr = document.createElement('span');
      sr.className = 'cet-sr';
      sr.textContent = src.join('\n');
      el.appendChild(sr);
      var bar = document.createElement('div');
      bar.className = 'cet-bar';
      bar.setAttribute('aria-hidden', 'true');
      bar.innerHTML = '<i></i><i></i><i></i><span class="cet-tab"></span>';
      bar.lastChild.textContent = d.file || DEFAULTS.file;
      el.appendChild(bar);
      var body = document.createElement('div');
      body.className = 'cet-body';
      body.setAttribute('aria-hidden', 'true');
      el.appendChild(body);
      var caret = document.createElement('span');
      caret.className = 'cet-caret';
      var lines = src.map(function (text, i) {
        var row = document.createElement('div');
        row.className = 'cet-line';
        row.innerHTML = '<span class="cet-ln">' + (i + 1) + '</span><span class="cet-code"></span>';
        body.appendChild(row);
        return { row: row, code: row.lastChild, text: text, segs: tokenise(text) };
      });
      return { el: el, lines: lines, caret: caret, visible: false, wake: null };
    }

    // Every line in its finished state (reduced motion, and the end of a run).
    function showAll(s) {
      s.lines.forEach(function (l) {
        l.code.innerHTML = html(l.segs, l.text.length);
        l.row.style.visibility = '';
        l.row.classList.remove('cet-on', 'cet-sel');
      });
    }

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

    function typeLine(s, l, speed) {
      return new Promise(function (done) {
        var indent = l.text.match(/^ */)[0].length;   // auto-indent: leading spaces arrive at once
        var n = indent;
        s.lines.forEach(function (o) { o.row.classList.remove('cet-on'); });
        l.row.style.visibility = '';
        l.row.classList.add('cet-on');
        s.caret.classList.remove('cet-blink');
        (function tick() {
          l.code.innerHTML = html(l.segs, n);
          l.code.appendChild(s.caret);
          if (n >= l.text.length) { done(); return; }
          var ch = l.text.charAt(n++);
          var wait = speed * (0.55 + Math.random() * 0.9);
          if (/[{(;,]/.test(ch)) wait += speed * 2;
          if (ch === ' ' && Math.random() < 0.15) wait += speed * 3;
          setTimeout(tick, wait);
        })();
      });
    }

    function play(s) {
      var d = s.el.dataset;
      var speed = +d.speed || DEFAULTS.speed;
      var hold = +d.hold || DEFAULTS.hold;
      s.lines.forEach(function (l) {
        l.code.textContent = '';
        l.row.style.visibility = 'hidden';
        l.row.classList.remove('cet-on', 'cet-sel');
      });
      var chain = sleep(350);
      s.lines.forEach(function (l, i) {
        chain = chain.then(function () { return typeLine(s, l, speed); })
          .then(function () { return sleep(i < s.lines.length - 1 ? speed * 5 : 0); });
      });
      chain.then(function () {
        s.caret.classList.add('cet-blink');
        if (d.loop === undefined || d.loop === 'false') return;
        sleep(hold).then(function () { return whenVisible(s); }).then(function () {
          // Select all, then delete, like a person starting over.
          s.lines.forEach(function (l) { if (l.text) l.row.classList.add('cet-sel'); });
          return sleep(450);
        }).then(function () { play(s); });
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._cet;
          s.visible = e.isIntersecting;
          if (s.visible && s.wake) { var w = s.wake; s.wake = null; w(); }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-code-editor-typing]').forEach(function (el) {
        if (el._cet) return;
        var s = (el._cet = setup(el));
        showAll(s);
        if (reduced()) return;
        io.observe(el);
        whenVisible(s).then(function () { play(s); });
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<pre data-code-editor-typing data-file="greet.js">
function greet(name) {
  return `Hi, ${name}!`;
}
</pre>