Digital Piano with HTML, CSS & JavaScript
20 DAYS 20 PROJECT CHALLENGE
Day #05
Project Overview
The Digital Piano is an interactive beginner-friendly project built using HTML, CSS, and JavaScript. It creates a virtual piano that allows users to play musical notes by clicking the piano keys or pressing the mapped keys on their computer keyboard.
Unlike projects that depend on external audio files, this piano generates sounds directly in the browser using the Web Audio API. JavaScript creates audio oscillators and gain nodes to produce each note, while an ADSR-style envelope controls how the sound starts and stops. It also has controls to change the octave, waveform, and volume. This makes it a useful project for learning Web Audio API, keyboard events, mouse interactions, DOM manipulation, and responsive CSS.
Key Features
- Clickable Piano Keys: Users can click the virtual piano keys to play different musical notes. Each key changes its background color when clicked or pressed and plays a particular musical note.
- Keyboard Controls: The computer keyboard keys control the piano. The computer keys used are A, W, S, E, D, and others. However, one can customize their computer keys to play the piano. Therefore, there is no need to click every key to play a musical note.
- Web Audio API: The piano uses Web Audio API to generate sounds in the browser. It does not use external audio files but creates sound with the AudioContext, oscillator, and gain node. These nodes create an audio context that produces musical notes when pressed and stops when released.
- Wave Selector: Users can select different musical waveforms and use them to play musical notes. The application has options such as sine, triangle, sawtooth, and square waveforms.
- Volume Range: The application has a range slider that controls the master volume of the piano. The slider changes the overall volume of the piano. It allows users to increase or decrease the volume level as they play the musical notes.
- Sustain Like Behavior: The piano has a sustain-like behavior to allow users to play musical notes continuously. The application does this by allowing users to hold down the Ctrl key or click and hold the mouse. However, the sustain behavior ends on a keypress event.
- Active Piano Key Behavior: The piano key pressed gets an active class. This class changes the background color of a piano key as the keyboard key is held down or the mouse is clicked on the piano key. The behavior allows the user to know which key is being pressed.
- Responsive Piano Keyboard: The application has a responsive piano keyboard that works on mobile phones and computers. The piano keys on the mobile screen are shorter and narrower than the desktop version because it is easier to tap on a smaller screen.
What You’ll Learn
- Understand how HTML creates the structure of a digital piano.
- Learn how JavaScript dynamically creates piano keys.
- Explore the basics of the Web Audio API.
- Practice working with
AudioContext, oscillators, and gain nodes. - Understand how MIDI note numbers can be converted into frequencies.
- Learn how keyboard events can trigger musical notes.
- Practice handling mouse and touch interactions.
- Explore how audio volume and waveform controls work.
- Understand how CSS creates white and black piano keys.
- Learn how responsive CSS adapts the piano for smaller screens.
HTML Code
The HTML code creates the basic structure and controls for the Digital Piano. The main application is contained inside the .card element, while the header displays the project title and a short description. The .controls-row contains the octave buttons, waveform selector, and volume slider. The #octDown and #octUp buttons allow users to move between octaves, while #waveSelect lets them choose the oscillator waveform. The #volume range input controls the master volume.
The .piano section uses id="piano" as a container for the piano keys. The HTML does not manually create every key. Instead, JavaScript generates the keys dynamically based on the NOTE_NAMES array. Finally, the legend explains the keyboard mapping and gives users a tip about holding keys to sustain notes. The script.js file is loaded at the bottom of the <body> so JavaScript can access the page elements.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Day 5 — Digital Piano</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="card" role="main">
<header>
<div class="logo">DP</div>
<div>
<h1>Day 5: Digital Piano</h1>
<p class="lead">Play notes by clicking keys or using the keyboard. Built with the Web Audio API and keyboard events.</p>
</div>
</header>
<section class="controls-row">
<div class="controls-left">
<button id="octDown" class="small-btn">Octave −</button>
<div id="octaveLabel" class="muted">Octave: 4</div>
<button id="octUp" class="small-btn">Octave +</button>
</div>
<div class="controls-right">
<label class="small">Wave:
<select id="waveSelect">
<option value="sine">sine</option>
<option value="triangle">triangle</option>
<option value="sawtooth">sawtooth</option>
<option value="square">square</option>
</select>
</label>
<label class="small">Volume:
<input id="volume" type="range" min="0" max="1" step="0.01" value="0.4">
</label>
</div>
</section>
<section class="piano" id="piano" aria-label="Digital piano">
<!-- Keys are generated by JS for clarity; placeholder for progressive enhancement -->
</section>
<section class="legend">
<div class="muted">Keyboard mapping (default): A W S E D F T G Y H U J K</div>
<div class="muted">Tip: Hold key to sustain. Use Octave +/- to move up/down.</div>
</section>
<details style="margin-top:12px">
<summary>How it works (short)</summary>
<p class="small">JavaScript creates an AudioContext and maps notes to frequencies. When a key is pressed/clicked it creates an oscillator + gain node and applies a short ADSR envelope. Key release stops the note and cleans up nodes.</p>
</details>
</main>
<script src="script.js"></script>
</body>
</html>
CSS Code
The CSS code creates the dark interface and the visual appearance of the piano. The :root section defines reusable variables for the background, card, accent, muted text, and other colors. The .card class controls the main application’s width, background, padding, rounded corners, border, and shadow. Next, the .piano class creates the main piano area using Flexbox, while .key provides the basic styling shared by the piano keys.
The .key.white class creates the white keys, while .key.black styles the smaller black keys and positions them over the white keys. The .active class moves and highlights a key while its note is playing. In addition, .black-positions creates an absolute layer for positioning the black keys over the white keys. Finally, the @media (max-width:720px) rule reduces the piano height and black-key dimensions on smaller screens.
:root {
--bg: #081222;
--card: #0b1220;
--accent: #8b5cf6;
--muted: #9aa4b2;
--white: #e6eef6;
font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, Arial;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: #002252;
color: var(--white);
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
}
.card {
width: min(960px, 96%);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.01));
border-radius: 12px;
padding: 18px;
box-shadow: 0 10px 40px rgba(2, 6, 23, 0.6);
border: 1px solid rgba(255, 255, 255, 0.03);
}
header {
display: flex;
gap: 14px;
align-items: center;
}
.logo {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), #22c1c3);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
h1 {
margin: 0;
font-size: 18px;
}
.lead {
margin: 4px 0 12px;
color: var(--muted);
font-size: 14px;
}
.controls-row {
display: flex;
justify-content: space-between;
align-items: center;
margin: 12px 0;
gap: 12px;
flex-wrap: wrap;
}
.small-btn {
padding: 8px 10px;
border-radius: 8px;
border: 0;
background: linear-gradient(90deg, var(--accent), #22c1c3);
color: white;
font-weight: 600;
cursor: pointer
}
.muted {
color: var(--muted);
font-size: 13px;
margin: 15px 0px;
}
.piano {
display: flex;
position: relative;
height: 220px;
user-select: none;
margin-top: 8px;
}
.key {
position: relative;
flex: 1 1 0;
border: 1px solid rgba(255, 255, 255, 0.04);
margin: 0 1px;
border-radius: 6px;
background: linear-gradient(180deg, #fff0 0%, rgba(255, 255, 255, 0.02));
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 14px;
box-shadow: inset 0 -6px 30px rgba(0, 0, 0, 0.3);
cursor: pointer;
color: var(--muted);
font-family: ui-monospace, monospace;
font-size: 13px;
}
.key .label {
pointer-events: none;
}
.key.white {
background: #f7f7f8;
color: #0b1220;
}
.key.black {
background: #111216;
width: 44px;
flex: 0 0 44px;
height: 140px;
margin: 0 -22px;
z-index: 2;
color: var(--white);
border-radius: 6px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.6);
align-self: flex-start;
padding-bottom: 8px;
}
.key.active {
outline: 3px solid rgba(99, 102, 241, 0.14);
transform: translateY(-3px);
}
.piano-row {
position: relative;
display: flex;
width: 100%;
}
/* ensure black keys are positioned over whites */
.black-positions {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 80px;
display: flex;
justify-content: space-between;
pointer-events: none;
padding: 0 12px;
}
.black-positions .key.black {
pointer-events: auto;
}
/* small screen adjustments */
@media (max-width:720px) {
.piano {
height: 160px;
}
.key.black {
width: 36px;
margin: -18px;
height: 100px;
}
.key {
font-size: 12px;
}
} Javascript Code
The JavaScript code provides the main functionality of the Digital Piano. First, the NOTE_NAMES array defines each piano note, its MIDI number, key color, and keyboard mapping. The midiToFreq() function then converts MIDI values into frequencies that the browser can use to produce sound. Next, buildKeyboard() dynamically creates the piano keys and adds them to the #piano container. The changeOctave() function changes the pitch by shifting the notes by 12 semitones per octave.
The ensureAudio() function creates the AudioContext and master gain node. When a user plays a key, playNote() creates an oscillator and gain node, applies an ADSR-style envelope, and starts the sound. After that, stopNote() applies the release effect and stops the oscillator.
Finally, keyboard events and pointer events connect user interactions to playNote() and stopNote(). The code also handles waveform changes, volume updates, octave controls, and cleanup when the page becomes hidden.
// Digital Piano — Web Audio API + keyboard events
// NOTE: For best UX, user must interact (click) before AudioContext can be resumed in some browsers.
// We'll resume context on first user gesture.
const NOTE_NAMES = [
// white and black layout for C4..C5 (12 semitones + C5)
// We'll create a structure describing keys in left-to-right order
{name:'C', midi:60, color:'white', key:'a'},
{name:'C#', midi:61, color:'black', key:'w'},
{name:'D', midi:62, color:'white', key:'s'},
{name:'D#', midi:63, color:'black', key:'e'},
{name:'E', midi:64, color:'white', key:'d'},
{name:'F', midi:65, color:'white', key:'f'},
{name:'F#', midi:66, color:'black', key:'t'},
{name:'G', midi:67, color:'white', key:'g'},
{name:'G#', midi:68, color:'black', key:'y'},
{name:'A', midi:69, color:'white', key:'h'},
{name:'A#', midi:70, color:'black', key:'u'},
{name:'B', midi:71, color:'white', key:'j'},
{name:'C5', midi:72, color:'white', key:'k'}
];
// Basic MIDI to frequency
function midiToFreq(m) {
return 440 * Math.pow(2, (m - 69) / 12);
}
// Audio setup
let audioCtx = null;
const master = { volume: 0.4, wave: 'sine', octaveShift: 0 }; // octaveShift in semitones (multiples of 12)
const activeVoices = new Map(); // map by identifier (keyboard key or element id)
// Create UI keys
const pianoEl = document.getElementById('piano');
const octaveLabel = document.getElementById('octaveLabel');
function buildKeyboard() {
// White keys container (we will create whites in order and overlay blacks)
const whiteContainer = document.createElement('div');
whiteContainer.className = 'piano-row';
pianoEl.appendChild(whiteContainer);
// We'll also create an absolute layer for black keys to position them
const blackLayer = document.createElement('div');
blackLayer.className = 'black-positions';
pianoEl.appendChild(blackLayer);
NOTE_NAMES.forEach((n, i) => {
const el = document.createElement('div');
el.className = 'key ' + (n.color === 'white' ? 'white' : 'black');
el.dataset.midi = n.midi;
el.dataset.key = n.key;
el.dataset.idx = i;
el.innerHTML = `<div class="label">${n.name}<div style="font-size:11px;color:inherit;margin-top:6px">${n.key.toUpperCase()}</div></div>`;
// Append to appropriate container
if(n.color === 'white') whiteContainer.appendChild(el);
else blackLayer.appendChild(el);
// mouse events
attachPointerHandlers(el);
});
}
buildKeyboard();
// UI controls
const waveSelect = document.getElementById('waveSelect');
const volumeInput = document.getElementById('volume');
const octUp = document.getElementById('octUp');
const octDown = document.getElementById('octDown');
waveSelect.addEventListener('change', e => master.wave = e.target.value);
volumeInput.addEventListener('input', e => master.volume = parseFloat(e.target.value));
octUp.addEventListener('click', () => changeOctave(1));
octDown.addEventListener('click', () => changeOctave(-1));
function changeOctave(delta) {
master.octaveShift = Math.max(-2, Math.min(2, master.octaveShift + delta)); // limit -2..+2
const baseOct = 4 + master.octaveShift;
octaveLabel.textContent = `Octave: ${baseOct}`;
}
// Helper: ensure audio context ready
function ensureAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Create a master gain node — we create per-voice gain nodes as children
audioCtx.masterGain = audioCtx.createGain();
audioCtx.masterGain.gain.value = master.volume;
audioCtx.masterGain.connect(audioCtx.destination);
}
// sync master volume
if (audioCtx.masterGain) audioCtx.masterGain.gain.value = master.volume;
}
// Play a note (create oscillator + gain with ADSR)
function playNote(midi, id) {
ensureAudio();
if (!audioCtx) return;
const now = audioCtx.currentTime;
const freq = midiToFreq(midi + master.octaveShift * 12);
// If voice for this id already exists, ignore (prevents retrigger)
if (activeVoices.has(id)) return;
const osc = audioCtx.createOscillator();
osc.type = master.wave || 'sine';
osc.frequency.value = freq;
const gain = audioCtx.createGain();
gain.gain.value = 0.0001; // start almost silent
osc.connect(gain);
gain.connect(audioCtx.masterGain);
// ADSR envelope (attack, decay, sustain, release in seconds)
const attack = 0.01;
const decay = 0.12;
const sustain = 0.6;
const release = 0.3;
// schedule envelope
gain.gain.cancelScheduledValues(now);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(1.0, now + attack);
gain.gain.exponentialRampToValueAtTime(sustain, now + attack + decay);
osc.start(now);
activeVoices.set(id, { osc, gain, release, started: now, midi });
// Visual
const keyEl = findKeyElementByMidi(midi);
if (keyEl) keyEl.classList.add('active');
}
// Stop a note — release envelope then stop
function stopNote(id) {
if (!audioCtx) return;
const voice = activeVoices.get(id);
if (!voice) return;
const now = audioCtx.currentTime;
const { gain, osc, release } = voice;
// ramp down
gain.gain.cancelScheduledValues(now);
gain.gain.setValueAtTime(gain.gain.value, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + release);
// stop oscillator after release
osc.stop(now + release + 0.05);
// cleanup after delay
setTimeout(() => {
try { gain.disconnect(); osc.disconnect(); } catch (e) {}
activeVoices.delete(id);
}, (release + 0.1) * 1000);
// Visual
const keyEl = findKeyElementById(id);
if (keyEl) keyEl.classList.remove('active');
}
// Utility to find DOM key element by midi
function findKeyElementByMidi(midi) {
return pianoEl.querySelector(`.key[data-midi="${midi}"]`);
}
function findKeyElementById(id) {
// id format: 'kbd_h' or 'mouse_3' etc
if (id.startsWith('kbd_')) {
const keyChar = id.slice(4);
return pianoEl.querySelector(`.key[data-key="${keyChar}"]`);
} else if (id.startsWith('mouse_')) {
const idx = id.slice(6); // index used in dataset idx
return pianoEl.querySelector(`.key[data-idx="${idx}"]`);
}
return null;
}
// Keyboard handling: map characters to midi via NOTE_NAMES
const keyToMidi = {};
NOTE_NAMES.forEach(n => keyToMidi[n.key] = n.midi);
// Track which keyboard keys are down to avoid repeats
const keysDown = new Set();
document.addEventListener('keydown', (e) => {
// ignore when focus in input elements (none here, but safe)
if (e.repeat) return; // ignore repeat events — we want hold behavior via keydown once
const k = e.key.toLowerCase();
if (keyToMidi[k] !== undefined) {
// resume audio context on first user action if needed
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
const midi = keyToMidi[k];
const id = 'kbd_' + k;
playNote(midi, id);
keysDown.add(k);
e.preventDefault();
}
});
document.addEventListener('keyup', (e) => {
const k = e.key.toLowerCase();
if (keyToMidi[k] !== undefined) {
const id = 'kbd_' + k;
stopNote(id);
keysDown.delete(k);
e.preventDefault();
}
});
// Pointer (mouse/touch) handlers for clickable keys
function attachPointerHandlers(keyEl) {
// Each key element has dataset.idx and dataset.midi
const idx = keyEl.dataset.idx;
const midi = parseInt(keyEl.dataset.midi, 10);
// For identification we use mouse_<idx>
const id = 'mouse_' + idx;
const start = (ev) => {
// resume audio if needed
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
playNote(midi, id);
ev.preventDefault();
};
const end = (ev) => {
stopNote(id);
ev.preventDefault();
};
keyEl.addEventListener('mousedown', start);
keyEl.addEventListener('touchstart', start, {passive:false});
// stop on mouseup anywhere (in case pointer leaves key)
document.addEventListener('mouseup', end);
document.addEventListener('touchend', end);
document.addEventListener('touchcancel', end);
}
// allow clicking anywhere first to unlock audio in mobile browsers
document.addEventListener('pointerdown', () => {
if (!audioCtx) ensureAudio();
}, { once: true });
// Keyboard legend for accessibility: show active when audio context suspended/resumed
// (no-op function here but kept for extension)
function updateMasterGain() {
if (audioCtx && audioCtx.masterGain) audioCtx.masterGain.gain.value = master.volume;
}
volumeInput.addEventListener('input', () => {
master.volume = parseFloat(volumeInput.value);
if (audioCtx) updateMasterGain();
});
// Clean up voices on visibility change to avoid stuck notes
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// stop all voices fast
for (const id of Array.from(activeVoices.keys())) stopNote(id);
if (audioCtx && audioCtx.state === 'running') audioCtx.suspend();
} else {
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}
});
// initial octave label
changeOctave(0);
// Extra: allow clicking a key by mouse to focus resume audio
pianoEl.addEventListener('click', (e) => {
if (!audioCtx) ensureAudio();
});
Related Projects
Day 3 : Form Validation
Validates a signup/login form for email, password, etc., before submission.
Concepts: Regular expressions, DOM events.
Day 7 : Expense Tracker
Track income and expenses, and calculate the total balance.
Concepts: LocalStorage, array methods (map, reduce).
Day 8 : Movie Search App
Search movies and display results with posters using an API.
Concepts: API integration, async/await.