← All gadgets

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 file

Every 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 &middot;
    <kbd>W</kbd><kbd>E</kbd><kbd>T</kbd>… black &middot;
    <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>