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 fileEvery 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>