Piano Keys
A playable keyboard -- mouse, touch, or your own keys
Running live from https://fun.whitneys.co,
cross-origin and sandboxed.
Put it on your site
<iframe
src="https://fun.whitneys.co/g/piano-keys"
width="470"
height="250"
title="Piano Keys"
sandbox="allow-scripts allow-popups-to-escape-sandbox"
allow="autoplay"
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 12.5 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": "Piano Keys",
"tagline": "A playable keyboard -- mouse, touch, or your own keys",
"category": "media",
"tags": ["music", "piano", "audio", "synth"],
"order": 5,
"author": "rwhitney",
"background": "transparent",
"size": { "w": 470, "h": 250, "resizable": true },
"config": {
"octaves": { "type": "range", "label": "Octaves", "min": 1, "max": 3, "step": 1, "default": 2 },
"start": { "type": "select", "label": "Starting octave", "options": ["C2", "C3", "C4", "C5"], "default": "C4" },
"wave": { "type": "select", "label": "Tone", "options": ["sine", "triangle", "square", "saw"], "default": "triangle" },
"release": { "type": "range", "label": "Release (ms)", "min": 50, "max": 1200, "step": 50, "default": 320 },
"accent": { "type": "color", "label": "Highlight", "default": "#66D9EF" },
"labels": { "type": "bool", "label": "Show note names", "default": true }
},
"permissions": ["audio"]
}
</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.45 ui-sans-serif, system-ui, sans-serif;
user-select:none; -webkit-user-select:none;
touch-action:manipulation;
}
body { padding:14px 14px 16px; }
#board {
position:relative;
display:flex;
height:clamp(110px, 30vw, 160px);
margin:0 auto;
border-radius:0 0 8px 8px;
/* The felt strip along the top is what makes a row of rectangles read
as a keyboard rather than a bar chart. */
border-top:5px solid var(--accent, #66D9EF);
box-shadow:0 8px 22px rgb(0 0 0 / .45);
overflow:hidden;
}
.key {
font:inherit; padding:0; margin:0; cursor:pointer;
display:flex; align-items:flex-end; justify-content:center;
box-sizing:border-box;
}
.white {
flex:1 1 0; min-width:0;
background:linear-gradient(#FFFDF8 88%, #E8E4D8);
border:1px solid #B9B4A4;
border-top:0;
border-radius:0 0 5px 5px;
color:#7a7565;
font-size:9px; font-weight:700; letter-spacing:.04em;
padding-bottom:7px;
}
.white + .white { border-left:0; }
.white.on {
background:linear-gradient(#E4F6FB 60%, var(--accent, #66D9EF));
color:#2b2b2b;
}
.black {
position:absolute; top:0;
width:8.2%; height:62%;
background:linear-gradient(#4a4940 70%, #22231d);
border:1px solid #14150f;
border-top:0;
border-radius:0 0 4px 4px;
color:#c9c4b4;
font-size:8px; font-weight:700;
padding-bottom:5px;
box-shadow:0 3px 6px rgb(0 0 0 / .5);
z-index:2;
}
.black.on {
background:linear-gradient(#3d5f68 40%, var(--accent, #66D9EF));
color:#14150f;
}
.key:focus { outline:none; }
.key:focus-visible {
outline:2px solid var(--accent, #66D9EF);
outline-offset:-3px;
z-index:3;
}
#hint { margin:12px 0 0; text-align:center; font-size:11px; color:var(--dim); }
#hint kbd {
font:inherit; font-size:10px; padding:1px 5px; border-radius:4px;
border:1px solid var(--line); background:rgb(248 248 242 / .05);
color:var(--ink);
}
#now {
text-align:center; font-size:12px; font-weight:700; min-height:1.4em;
color:var(--accent, #66D9EF); font-variant-numeric:tabular-nums;
margin:8px 0 0; letter-spacing:.06em;
}
.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="Piano keyboard"></div>
<p id="now" aria-hidden="true"></p>
<p id="hint">
<kbd>A</kbd><kbd>S</kbd><kbd>D</kbd>… white ·
<kbd>W</kbd><kbd>E</kbd><kbd>T</kbd>… black ·
<kbd>Z</kbd>/<kbd>X</kbd> octave
</p>
<div id="say" class="sr" role="status" aria-live="polite"></div>
<script>
(function () {
var cfg = window.gadget ? window.gadget.config : {};
var octaves = typeof cfg.octaves === 'number' ? cfg.octaves : 2;
var wave = ({ saw: 'sawtooth' })[cfg.wave] || cfg.wave || 'triangle';
var release = (typeof cfg.release === 'number' ? cfg.release : 320) / 1000;
var showLabels = cfg.labels !== false;
document.documentElement.style.setProperty('--accent', cfg.accent || '#66D9EF');
var NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
var WHITE_STEPS = [0, 2, 4, 5, 7, 9, 11];
// Which white key each black key sits after, within an octave.
var BLACKS = [
{ step: 1, after: 0 }, { step: 3, after: 1 },
{ step: 6, after: 3 }, { step: 8, after: 4 }, { step: 10, after: 5 }
];
var startOctave = parseInt(String(cfg.start || 'C4').slice(1), 10);
if (!isFinite(startOctave)) startOctave = 4;
var shift = 0; // live octave shift, Z / X
/* ---- audio ----------------------------------------------------------
Created lazily on the first real interaction. Browsers suspend an
AudioContext made without a user gesture, so building it up front would
produce a keyboard that silently does nothing until something happens to
resume it. */
var ctx = null, master = null;
function audio() {
if (!ctx) {
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
ctx = new AC();
// A compressor so a fistful of keys does not clip. Polyphony is
// unbounded by design -- you should be able to lean on the thing.
var comp = ctx.createDynamicsCompressor();
master = ctx.createGain();
master.gain.value = 0.7;
master.connect(comp).connect(ctx.destination);
}
if (ctx.state === 'suspended') ctx.resume();
return ctx;
}
var voices = {}; // midi -> { osc, gain }
function freq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
function noteOn(midi) {
var c = audio();
if (!c || voices[midi]) return;
var osc = c.createOscillator();
var g = c.createGain();
osc.type = wave;
osc.frequency.value = freq(midi);
// Ramp rather than jump: a gain that steps from 0 to full produces an
// audible click at the start of every note.
g.gain.setValueAtTime(0.0001, c.currentTime);
g.gain.exponentialRampToValueAtTime(0.28, c.currentTime + 0.012);
osc.connect(g).connect(master);
osc.start();
voices[midi] = { osc: osc, gain: g };
paint(midi, true);
}
function noteOff(midi) {
var v = voices[midi];
if (!v) return;
delete voices[midi];
var t = ctx.currentTime;
v.gain.gain.cancelScheduledValues(t);
v.gain.gain.setValueAtTime(Math.max(v.gain.gain.value, 0.0001), t);
v.gain.gain.exponentialRampToValueAtTime(0.0001, t + release);
v.osc.stop(t + release + 0.03);
paint(midi, false);
}
/* ---- keys ------------------------------------------------------------ */
var board = document.getElementById('board');
var nowEl = document.getElementById('now');
var byMidi = {};
function label(midi) {
return NAMES[midi % 12] + (Math.floor(midi / 12) - 1);
}
function makeKey(midi, cls, leftPct) {
var k = document.createElement('button');
k.type = 'button';
k.className = 'key ' + cls;
k.dataset.midi = midi;
k.setAttribute('aria-label', label(midi));
if (leftPct !== null) k.style.left = leftPct + '%';
if (showLabels) k.textContent = label(midi);
board.appendChild(k);
(byMidi[midi] = byMidi[midi] || []).push(k);
return k;
}
function build() {
board.innerHTML = '';
byMidi = {};
var base = (startOctave + 1 + shift) * 12; // MIDI for C of first octave
var whiteCount = octaves * 7;
for (var o = 0; o < octaves; o++) {
for (var w = 0; w < 7; w++) makeKey(base + o * 12 + WHITE_STEPS[w], 'white', null);
}
for (o = 0; o < octaves; o++) {
for (var b = 0; b < BLACKS.length; b++) {
var whiteIndex = o * 7 + BLACKS[b].after;
// Sit the black key over the gap between two whites: one white width
// per key, offset by half a black key.
var left = ((whiteIndex + 1) / whiteCount) * 100 - 4.1 / octaves;
makeKey(base + o * 12 + BLACKS[b].step, 'black', left);
}
}
// Black keys are a fixed % of the whole board, so they must shrink as
// octaves are added or they would swallow the whites.
[].forEach.call(board.querySelectorAll('.black'), function (el) {
el.style.width = (8.2 / octaves) + '%';
});
// Cap the board so keys keep a believable width. White keys are flex:1,
// so without this the keyboard stretches to fill whatever container it
// is dropped into -- at full browser width that is 100px+ per key, which
// stops looking like a piano and starts looking like a bar chart.
board.style.maxWidth = (whiteCount * 46) + 'px';
}
function paint(midi, on) {
(byMidi[midi] || []).forEach(function (k) { k.classList.toggle('on', on); });
var live = Object.keys(voices).map(Number).sort(function (a, b) { return a - b; });
nowEl.textContent = live.map(label).join(' ');
if (on) document.getElementById('say').textContent = label(midi);
}
/* ---- pointer --------------------------------------------------------
No setPointerCapture: capture would route every later event to the key
you first pressed, which kills glissando. Instead track the button state
globally and start notes on pointerenter while held. */
var down = false;
addEventListener('pointerdown', function () { down = true; });
addEventListener('pointerup', function () { down = false; allOff('pointer'); });
addEventListener('pointercancel', function () { down = false; allOff('pointer'); });
var pointerNotes = {};
function keyFromEvent(e) {
var el = e.target.closest ? e.target.closest('.key') : null;
return el ? Number(el.dataset.midi) : null;
}
function allOff(source) {
if (source === 'pointer') {
Object.keys(pointerNotes).forEach(function (m) { noteOff(Number(m)); });
pointerNotes = {};
}
}
board.addEventListener('pointerdown', function (e) {
var m = keyFromEvent(e);
if (m === null) return;
e.preventDefault();
down = true;
pointerNotes[m] = 1;
noteOn(m);
});
board.addEventListener('pointerover', function (e) {
if (!down) return;
var m = keyFromEvent(e);
if (m === null || pointerNotes[m]) return;
pointerNotes[m] = 1;
noteOn(m);
});
board.addEventListener('pointerout', function (e) {
var m = keyFromEvent(e);
if (m === null || !pointerNotes[m]) return;
delete pointerNotes[m];
noteOff(m);
});
/* ---- computer keyboard ----------------------------------------------
The standard virtual-piano layout: home row is white, the row above
holds the sharps where they physically sit on a real keyboard. */
var MAP = {
a: 0, w: 1, s: 2, e: 3, d: 4, f: 5, t: 6,
g: 7, y: 8, h: 9, u: 10, j: 11, k: 12, o: 13, l: 14
};
var held = {};
addEventListener('keydown', function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
var key = e.key.toLowerCase();
if (key === 'z' || key === 'x') {
e.preventDefault();
var next = shift + (key === 'x' ? 1 : -1);
if (startOctave + next < 0 || startOctave + next + octaves > 8) return;
shift = next;
Object.keys(held).forEach(function (k) { noteOff(held[k]); });
held = {};
build();
document.getElementById('say').textContent =
'Octave ' + (startOctave + shift);
return;
}
if (!(key in MAP) || e.repeat || held[key] !== undefined) return;
e.preventDefault();
var midi = (startOctave + 1 + shift) * 12 + MAP[key];
held[key] = midi;
noteOn(midi);
});
addEventListener('keyup', function (e) {
var key = e.key.toLowerCase();
if (held[key] === undefined) return;
noteOff(held[key]);
delete held[key];
});
// Enter/Space on a focused key, for anyone navigating by Tab.
board.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
var m = keyFromEvent(e);
if (m === null || e.repeat) return;
e.preventDefault();
noteOn(m);
});
board.addEventListener('keyup', function (e) {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
var m = keyFromEvent(e);
if (m !== null) noteOff(m);
});
// Losing focus with keys down would leave notes ringing forever.
addEventListener('blur', function () {
Object.keys(held).forEach(function (k) { noteOff(held[k]); });
held = {};
down = false;
allOff('pointer');
});
build();
})();
</script>
</body>
</html>