← Typing

Keystroke Overlay

Pressed keys pop up as keycaps over the screen, like the key display in a tutorial video. Shortcuts show as a row of caps, plain typing gathers into one capsule, and older presses drift up and fade. Play a scripted sequence, show the visitor’s own key presses, or both.

Press any keys

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-keystroke-overlay to the area the keys should appear over.
  3. List a sequence in data-keys and add data-autoplay to play it, and/or data-listen to show real key presses.

Options

Add any of these attributes to the same element.

data-keysThe sequence: steps separated by |, keys in a shortcut joined by +. A step starting with ~ types its text letter by letter, e.g. ⌘+K|~deploy|↵.
data-autoplayPlay the sequence on a loop while it is on screen.
data-listenShow the visitor’s own key presses while it is on screen. The sequence rests while they type.
data-positionbottom (default) or top.
data-intervalTime between steps of the sequence. Default 1000 ms.
data-holdHow long a press stays before it fades. Default 1800 ms.
data-maxHow many presses show at once. Default 3.

Key names like Cmd, Shift and Enter are drawn as their symbols (⌘, ⇧, ↵). From a script, animacious.keystrokeOverlay(el, ['⌘', 'S']) shows a single press.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Keystroke Overlay: pressed keys pop up as keycaps over the screen, like the
    // key display in a tutorial video. Shortcuts show as a row of keycaps; plain
    // typing gathers into one capsule. Older presses drift up and fade away.
    // Usage:
    //   <div data-keystroke-overlay data-autoplay data-keys="⌘+K|~deploy|↵|Esc">
    //     …anything: the "screen" the keys appear over…
    //   </div>
    // Optional: data-keys (a sequence to play: steps separated by |, keys in a
    // shortcut joined by +, and a step starting with ~ types its text letter by
    // letter), data-autoplay (play the sequence on a loop while in view),
    // data-listen (show the visitor's own key presses while in view), data-position
    // ("bottom", default, or "top"), data-interval (ms between steps, default 1000),
    // data-hold (ms a press stays before fading, default 1800), data-max (presses
    // shown at once, default 3).
    // Script use: animacious.keystrokeOverlay(el, ['⌘', 'K']) shows one press.
    var DEFAULTS = { position: 'bottom', interval: 1000, hold: 1800, max: 3 };
    var MAC = /Mac|iP(hone|ad|od)/.test(navigator.platform || navigator.userAgent);
    var NAMES = {
      Meta: MAC ? '⌘' : 'Win', Control: MAC ? '⌃' : 'Ctrl', Alt: MAC ? '⌥' : 'Alt', Shift: '⇧',
      Enter: '↵', Backspace: '⌫', Delete: 'Del', Tab: '⇥', Escape: 'Esc', ' ': 'Space',
      ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→', CapsLock: '⇪'
    };
    var MODS = ['Control', 'Alt', 'Shift', 'Meta'];

    var css =
      '.kso-layer{position:absolute;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:0.45em;pointer-events:none;z-index:5;padding:0 0.8em}' +
      '.kso-bottom{bottom:1.1em}.kso-top{top:1.1em}' +
      '.kso-group{display:flex;gap:0.3em;padding:0.35em;border-radius:0.75em;background:rgba(18,18,26,.78);' +
      '-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);box-shadow:0 10px 30px rgba(0,0,0,.25);max-width:100%}' +
      '.kso-cap{min-width:2.1em;height:2.1em;padding:0 0.55em;display:grid;place-items:center;border-radius:0.5em;' +
      'background:linear-gradient(#3a3a46,#2a2a34);color:#fff;font-weight:600;font-size:1em;line-height:1;white-space:nowrap;' +
      'box-shadow:inset 0 1px 0 rgba(255,255,255,.14),0 2px 0 #111118;font-family:inherit}' +
      '.kso-text{display:flex;align-items:center;min-width:0;justify-content:flex-start;overflow:hidden;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:500;letter-spacing:.02em}' +
      '.kso-text i{font-style:normal;display:inline-block;white-space:pre}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function opt(d, k) { return d[k] !== undefined && d[k] !== '' ? d[k] : DEFAULTS[k]; }

    function label(k) {
      if (NAMES[k]) return NAMES[k];
      var low = k.toLowerCase();
      if (low === 'cmd' || low === 'command') return '⌘';
      if (low === 'opt' || low === 'option') return '⌥';
      if (low === 'plus') return '+';
      return k.length === 1 ? k.toUpperCase() : k;
    }

    function setup(el) {
      var d = el.dataset;
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
      var layer = document.createElement('div');
      layer.className = 'kso-layer kso-' + (opt(d, 'position') === 'top' ? 'top' : 'bottom');
      layer.setAttribute('aria-hidden', 'true');
      el.appendChild(layer);
      var steps = (d.keys || '').split('|').filter(Boolean).map(function (s) {
        return s.charAt(0) === '~' ? { text: s.slice(1) } : { keys: s.split(/\+(?!$)/).map(function (k) { return k.trim(); }) };
      });
      return { el: el, layer: layer, steps: steps, groups: [], visible: false, timer: 0, idleUntil: 0, last: null };
    }

    // Move older groups smoothly when a new one joins (FLIP).
    function addGroup(s, g) {
      var before = s.groups.map(function (o) { return o.el.getBoundingClientRect().top; });
      if (s.layer.classList.contains('kso-top')) s.layer.insertBefore(g.el, s.layer.firstChild);
      else s.layer.appendChild(g.el);
      if (s.still) { s.groups.push(g); return; }
      s.groups.forEach(function (o, i) {
        var dy = before[i] - o.el.getBoundingClientRect().top;
        if (dy) anime.animate(o.el, { y: [dy, 0], duration: 320, ease: 'out(3)' });
      });
      s.groups.push(g);
      var max = +opt(s.el.dataset, 'max');
      while (s.groups.length > max) drop(s, s.groups[0]);
      anime.animate(g.el, { opacity: [0, 1], scale: [0.7, 1], y: [10, 0], duration: 420, ease: 'outBack(1.8)' });
    }

    function drop(s, g) {
      var i = s.groups.indexOf(g);
      if (i < 0) return;
      s.groups.splice(i, 1);
      clearTimeout(g.timer);
      anime.animate(g.el, {
        opacity: 0, scale: 0.85, y: s.layer.classList.contains('kso-top') ? 8 : -8, duration: 300, ease: 'in(2)',
        onComplete: function () { g.el.remove(); }
      });
    }

    function keepAlive(s, g) {
      clearTimeout(g.timer);
      g.timer = setTimeout(function () { drop(s, g); }, +opt(s.el.dataset, 'hold'));
    }

    function pop(s, node) {
      if (s.still) return;
      anime.animate(node, { scale: [1.35, 1], y: [-3, 0], duration: 260, ease: 'out(3)' });
    }

    // Show a shortcut as keycaps.
    function showCombo(s, keys) {
      var g = { el: document.createElement('div') };
      g.el.className = 'kso-group';
      keys.forEach(function (k) {
        var cap = document.createElement('span');
        cap.className = 'kso-cap';
        cap.textContent = label(k);
        g.el.appendChild(cap);
      });
      addGroup(s, g);
      if (!s.still) anime.animate(g.el.children, { y: [0, 2, 0], duration: 220, delay: anime.stagger(50, { start: 120 }), ease: 'inOut(2)' });
      keepAlive(s, g);
      s.last = null;
    }

    // Show a typed character, joining the previous capsule if it was typing too.
    function showChar(s, ch) {
      var g = s.last && s.groups.indexOf(s.last) >= 0 ? s.last : null;
      if (!g) {
        g = { el: document.createElement('div'), text: document.createElement('span') };
        g.el.className = 'kso-group';
        g.text.className = 'kso-cap kso-text';
        g.el.appendChild(g.text);
        addGroup(s, g);
        s.last = g;
      }
      var i = document.createElement('i');
      i.textContent = ch;
      g.text.appendChild(i);
      // Keep the capsule short: only the latest letters stay.
      while (g.text.children.length > 16) g.text.firstChild.remove();
      pop(s, i);
      keepAlive(s, g);
    }

    function schedule(s, ms) {
      clearTimeout(s.timer);
      s.timer = setTimeout(function () { tick(s); }, ms);
    }

    function tick(s) {
      s.timer = 0;
      if (!s.visible || !s.steps.length) return;
      if (Date.now() < s.idleUntil) { schedule(s, s.idleUntil - Date.now()); return; }
      var interval = +opt(s.el.dataset, 'interval');
      s.pos = s.pos || { i: 0, c: 0 };
      var step = s.steps[s.pos.i % s.steps.length];
      if (step.text !== undefined) {
        showChar(s, step.text.charAt(s.pos.c++));
        if (s.pos.c < step.text.length) { schedule(s, 90 + Math.random() * 90); return; }
      } else {
        showCombo(s, step.keys);
      }
      s.pos.c = 0;
      s.pos.i++;
      // A longer rest after the whole sequence, before it starts over.
      schedule(s, s.pos.i % s.steps.length === 0 ? interval + +opt(s.el.dataset, 'hold') : interval);
    }

    function listen(list) {
      window.addEventListener('keydown', function (e) {
        if (e.repeat || MODS.indexOf(e.key) >= 0 || e.key === 'Dead' || e.key === 'Unidentified') return;
        list.forEach(function (s) {
          if (!s.visible) return;
          s.idleUntil = Date.now() + 4000;   // the visitor is typing: rest the demo
          var mods = MODS.filter(function (m) { return e.getModifierState(m); });
          var plain = !mods.filter(function (m) { return m !== 'Shift'; }).length;
          var typing = s.last && s.groups.indexOf(s.last) >= 0;
          if (plain && e.key.length === 1 && (e.key !== ' ' || typing)) showChar(s, e.key);
          else showCombo(s, mods.concat(e.key));
        });
      });
    }

    function init() {
      var listeners = [];
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._kso;
          s.visible = e.isIntersecting;
          if (s.visible && s.autoplay && !s.timer) schedule(s, 500);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-keystroke-overlay]').forEach(function (el) {
        if (el._kso) return;
        var s = (el._kso = setup(el));
        var d = el.dataset;
        s.autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
        if (d.listen !== undefined && d.listen !== 'false') listeners.push(s);
        if (reduced()) {
          // A single still press, no motion.
          s.still = true;
          s.autoplay = false;
          if (s.steps.length && s.steps[0].keys) {
            showCombo(s, s.steps[0].keys);
            s.groups.forEach(function (g) { clearTimeout(g.timer); });
          }
        }
        io.observe(el);
      });
      if (listeners.length) listen(listeners);
    }

    window.animacious = window.animacious || {};
    window.animacious.keystrokeOverlay = function (el, keys) {
      if (el && el._kso) showCombo(el._kso, keys);
    };

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-keystroke-overlay data-autoplay data-keys="⌘+K|~deploy|↵|Esc">
  <!-- your screen, video or screenshot -->
</div>