← All gadgets

Keyboard

Your keystrokes, lit up -- or pin a shortcut for the docs

Running live from https://fun.whitneys.co, cross-origin and sandboxed.

Put it on your site

<iframe
  src="https://fun.whitneys.co/g/keyboard"
  width="620"
  height="250"
  title="Keyboard"
  sandbox="allow-scripts allow-popups-to-escape-sandbox"
  loading="lazy"
  style="border:0"></iframe>

An iframe, not a script tag -- so this gadget cannot touch the page you paste it into, and neither can we. Your settings above are baked into the URL.

Optional: let it resize itself to fit
<script>
addEventListener('message', function (e) {
  var d = e.data;
  if (!d || d.type !== 'fwf:height') return;
  if (typeof d.height !== 'number' || !isFinite(d.height)) return;
  document.querySelectorAll('iframe').forEach(function (f) {
    // Identity by source window, not by origin -- sandboxed frames report
    // their origin as "null". Do not "fix" this into an origin check.
    if (f.contentWindow !== e.source) return;
    f.style.height = Math.max(40, Math.min(2000, Math.ceil(d.height))) + 'px';
  });
});
</script>

The whole thing

Raw file

Every gadget is one file, and this is it -- all 10.0 kB. Take it, change it, ship it. Just keep the credit.

Show source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="application/json" id="gadget-manifest">
{
  "name": "Keyboard",
  "tagline": "Your keystrokes, lit up -- or pin a shortcut for the docs",
  "category": "tools",
  "tags": ["keyboard", "shortcuts", "docs", "typing"],
  "order": 4,
  "author": "rwhitney",
  "background": "transparent",
  "size": { "w": 620, "h": 250, "resizable": true },
  "config": {
    "pin":    { "type": "text",  "label": "Pin a shortcut", "max": 32, "default": "Ctrl+Shift+P" },
    "accent": { "type": "color", "label": "Press colour", "default": "#A6E22E" },
    "pinned": { "type": "color", "label": "Pin colour",   "default": "#F92672" },
    "labels": { "type": "bool",  "label": "Show legends", "default": true },
    "trail":  { "type": "bool",  "label": "Keep keys lit after release", "default": false }
  },
  "permissions": []
}
</script>
<style>
  :root { --ink:#F8F8F2; --dim:#A59F85; --line:rgb(248 248 242 / .14); }
  html, body {
    margin:0; background:transparent; color:var(--ink);
    font:14px/1.4 ui-sans-serif, system-ui, sans-serif;
    user-select:none; -webkit-user-select:none;
  }
  body { padding:12px 12px 14px; }

  #board {
    max-width:640px; margin:0 auto;
    display:flex; flex-direction:column; gap:clamp(2px, .6vw, 5px);
    padding:clamp(5px, 1.4vw, 10px);
    background:rgb(248 248 242 / .04);
    border:1px solid var(--line);
    border-radius:9px;
  }
  .row { display:flex; gap:clamp(2px, .6vw, 5px); }

  .k {
    /* flex-basis 0 with grow = the unit width, so every row totals the same
       15u and the columns line up down the board. */
    flex:var(--u, 1) 1 0;
    min-width:0;
    height:clamp(22px, 5.2vw, 38px);
    display:flex; align-items:center; justify-content:center;
    padding:0 2px;
    font:inherit;
    font-size:clamp(7px, 1.5vw, 10px);
    font-family:ui-monospace, Menlo, Consolas, monospace;
    font-weight:700;
    color:#cfcabb;
    background:linear-gradient(#42413a, #2f2e28);
    border:1px solid #55544a;
    border-bottom-width:2px;
    border-radius:5px;
    box-shadow:0 1px 0 rgb(0 0 0 / .5);
    overflow:hidden; white-space:nowrap; text-overflow:clip;
    cursor:pointer;
    transition:background 60ms linear, color 60ms linear,
               transform 60ms ease, border-color 60ms linear;
  }

  /* Currently held, or left lit by trail mode. */
  .k.on {
    background:var(--accent, #A6E22E);
    border-color:var(--accent, #A6E22E);
    color:#1d1e18;
    transform:translateY(1px);
    border-bottom-width:1px;
  }

  /* Part of the pinned shortcut. Outlined rather than filled, so a live
     press on a pinned key still reads as a press. */
  .k.pin {
    color:var(--pinned, #F92672);
    border-color:var(--pinned, #F92672);
    box-shadow:0 0 0 1px var(--pinned, #F92672) inset, 0 1px 0 rgb(0 0 0 / .5);
  }
  .k.pin.on { color:#1d1e18; }

  .k:focus { outline:none; }
  .k:focus-visible { outline:2px solid #66D9EF; outline-offset:2px; z-index:2; }

  #read {
    margin:10px 0 0; text-align:center; min-height:1.5em;
    font-family:ui-monospace, Menlo, Consolas, monospace;
    font-size:12px; font-weight:700; letter-spacing:.08em;
    color:var(--accent, #A6E22E);
  }
  #note { margin:4px 0 0; text-align:center; font-size:10.5px; color:var(--dim); }
  .sr { position:absolute; width:1px; height:1px; overflow:hidden;
        clip-path:inset(50%); white-space:nowrap; }
</style>
</head>
<body>
  <div id="board" role="group" aria-label="Computer keyboard"></div>
  <p id="read" aria-hidden="true"></p>
  <p id="note">Type anywhere to light it up</p>
  <div id="say" class="sr" role="status" aria-live="polite"></div>

<script>
(function () {
  var cfg = window.gadget ? window.gadget.config : {};
  var showLabels = cfg.labels !== false;
  var trail = cfg.trail === true;
  document.documentElement.style.setProperty('--accent', cfg.accent || '#A6E22E');
  document.documentElement.style.setProperty('--pinned', cfg.pinned || '#F92672');

  /* ANSI 60% layout. [KeyboardEvent.code, legend, width in units].
     Every row sums to 15u, which is what keeps the columns aligned. */
  var ROWS = [
    [['Backquote','`',1],['Digit1','1',1],['Digit2','2',1],['Digit3','3',1],
     ['Digit4','4',1],['Digit5','5',1],['Digit6','6',1],['Digit7','7',1],
     ['Digit8','8',1],['Digit9','9',1],['Digit0','0',1],['Minus','-',1],
     ['Equal','=',1],['Backspace','Bksp',2]],

    [['Tab','Tab',1.5],['KeyQ','Q',1],['KeyW','W',1],['KeyE','E',1],
     ['KeyR','R',1],['KeyT','T',1],['KeyY','Y',1],['KeyU','U',1],
     ['KeyI','I',1],['KeyO','O',1],['KeyP','P',1],['BracketLeft','[',1],
     ['BracketRight',']',1],['Backslash','\\',1.5]],

    [['CapsLock','Caps',1.75],['KeyA','A',1],['KeyS','S',1],['KeyD','D',1],
     ['KeyF','F',1],['KeyG','G',1],['KeyH','H',1],['KeyJ','J',1],
     ['KeyK','K',1],['KeyL','L',1],['Semicolon',';',1],['Quote',"'",1],
     ['Enter','Enter',2.25]],

    [['ShiftLeft','Shift',2.25],['KeyZ','Z',1],['KeyX','X',1],['KeyC','C',1],
     ['KeyV','V',1],['KeyB','B',1],['KeyN','N',1],['KeyM','M',1],
     ['Comma',',',1],['Period','.',1],['Slash','/',1],['ShiftRight','Shift',2.75]],

    [['ControlLeft','Ctrl',1.25],['MetaLeft','Meta',1.25],['AltLeft','Alt',1.25],
     ['Space','',6.25],['AltRight','Alt',1.25],['MetaRight','Meta',1.25],
     ['ContextMenu','Menu',1.25],['ControlRight','Ctrl',1.25]]
  ];

  /* Readable names for the live readout and for screen readers -- "Backquote"
     is the spec name, not what anyone calls the key. */
  var SPOKEN = {
    Backquote:'Backtick', Minus:'Minus', Equal:'Equals', Backspace:'Backspace',
    BracketLeft:'Left bracket', BracketRight:'Right bracket', Backslash:'Backslash',
    CapsLock:'Caps Lock', Semicolon:'Semicolon', Quote:'Quote', Enter:'Enter',
    ShiftLeft:'Left Shift', ShiftRight:'Right Shift', Comma:'Comma',
    Period:'Period', Slash:'Slash', ControlLeft:'Left Ctrl',
    ControlRight:'Right Ctrl', MetaLeft:'Left Meta', MetaRight:'Right Meta',
    AltLeft:'Left Alt', AltRight:'Right Alt', ContextMenu:'Menu',
    Space:'Space', Tab:'Tab'
  };

  function spoken(code) {
    if (SPOKEN[code]) return SPOKEN[code];
    if (code.indexOf('Key') === 0) return code.slice(3);
    if (code.indexOf('Digit') === 0) return code.slice(5);
    return code;
  }

  var board = document.getElementById('board');
  var readEl = document.getElementById('read');
  var byCode = {};

  ROWS.forEach(function (row) {
    var r = document.createElement('div');
    r.className = 'row';
    row.forEach(function (spec) {
      var b = document.createElement('button');
      b.type = 'button';
      b.className = 'k';
      b.style.setProperty('--u', spec[2]);
      b.dataset.code = spec[0];
      if (showLabels) b.textContent = spec[1];
      b.setAttribute('aria-label', spoken(spec[0]));
      byCode[spec[0]] = b;
      r.appendChild(b);
    });
    board.appendChild(r);
  });

  /* ---- pinned shortcut ------------------------------------------------
     Config text is attacker-controlled, so it is only ever matched against
     this fixed table -- nothing from the string reaches the DOM. */
  var ALIAS = {
    ctrl:'ControlLeft', control:'ControlLeft', ctl:'ControlLeft',
    shift:'ShiftLeft', alt:'AltLeft', option:'AltLeft',
    cmd:'MetaLeft', command:'MetaLeft', meta:'MetaLeft', win:'MetaLeft',
    super:'MetaLeft', tab:'Tab', enter:'Enter', return:'Enter',
    space:'Space', backspace:'Backspace', bksp:'Backspace', caps:'CapsLock'
  };

  function codeFor(token) {
    var t = token.trim().toLowerCase();
    if (!t) return null;
    if (ALIAS[t]) return ALIAS[t];
    if (t.length === 1) {
      if (t >= 'a' && t <= 'z') return 'Key' + t.toUpperCase();
      if (t >= '0' && t <= '9') return 'Digit' + t;
      var punct = { '`':'Backquote', '-':'Minus', '=':'Equal', '[':'BracketLeft',
                    ']':'BracketRight', '\\':'Backslash', ';':'Semicolon',
                    "'":'Quote', ',':'Comma', '.':'Period', '/':'Slash' };
      return punct[t] || null;
    }
    return null;
  }

  String(cfg.pin || '').split('+').forEach(function (tok) {
    var code = codeFor(tok);
    if (code && byCode[code]) byCode[code].classList.add('pin');
  });

  /* ---- lighting -------------------------------------------------------- */
  var down = {};

  function light(code, on) {
    var el = byCode[code];
    if (!el) return;
    el.classList.toggle('on', on);
    if (on) {
      readEl.textContent = spoken(code);
      document.getElementById('say').textContent = spoken(code);
    }
  }

  addEventListener('keydown', function (e) {
    // Deliberately NOT preventDefault: this is a visualiser sitting inside
    // someone else's page. Swallowing Tab would trap focus in the frame and
    // swallowing Space would break scrolling -- both worse than the gadget
    // missing a keystroke.
    if (down[e.code]) return;
    down[e.code] = 1;
    light(e.code, true);
  });

  addEventListener('keyup', function (e) {
    delete down[e.code];
    if (!trail) light(e.code, false);
  });

  // Pointer and touch, so it is not dead on a phone.
  board.addEventListener('pointerdown', function (e) {
    var el = e.target.closest ? e.target.closest('.k') : null;
    if (!el) return;
    e.preventDefault();
    var code = el.dataset.code;
    if (trail) light(code, !el.classList.contains('on'));
    else light(code, true);
  });

  board.addEventListener('pointerup', function (e) {
    if (trail) return;
    var el = e.target.closest ? e.target.closest('.k') : null;
    if (el) light(el.dataset.code, false);
  });

  // Enter/Space on a Tab-focused key, for keyboard-only users. Space is
  // preventDefault'd here only because focus is already on the key.
  board.addEventListener('keydown', function (e) {
    if (e.key !== 'Enter' && e.key !== ' ') return;
    var el = e.target.closest ? e.target.closest('.k') : null;
    if (!el) return;
    e.preventDefault();
    light(el.dataset.code, true);
  });

  // A key held while the frame loses focus would otherwise stay lit forever.
  addEventListener('blur', function () {
    if (trail) return;
    Object.keys(down).forEach(function (c) { light(c, false); });
    down = {};
  });
})();
</script>
</body>
</html>