Keys
import { useState } from 'react';
const W = 240;
const N = 15;
const KW = W / N;
const KH = 56;
const NOTES = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
// A black key sits after C, D, F, G and A.
const BLACK = Array.from({ length: N - 1 }, (_, i) => i).filter((i) => [0, 1, 3, 4, 5].includes(i % 7));
const note = (i: number) => `${NOTES[i % 7]}${4 + Math.floor(i / 7)}`;
const css = `
.slider-keys { position: relative; width: ${W}px; height: 100px; }
.slider-keys__pill {
position: absolute;
top: 0;
width: 34px;
height: 22px;
margin-left: -17px;
border-radius: 11px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-size: 12px;
font-weight: 600;
line-height: 22px;
text-align: center;
transition: left 0.3s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.slider-keys__board { position: absolute; left: 0; top: 30px; width: ${W}px; height: ${KH}px; }
.slider-keys__white {
position: absolute;
top: 0;
box-sizing: border-box;
width: ${KW}px;
height: ${KH}px;
border: 1px solid light-dark(#cbced3, #393c41);
border-radius: 0 0 4px 4px;
background: light-dark(#ebedf0, #232529);
transition: transform 0.18s cubic-bezier(0.3, 1.5, 0.5, 1), background-color 0.18s;
}
.slider-keys__white--near { background: light-dark(#dbdde1, #2e3035); }
.slider-keys__white--on { background: light-dark(#42454c, #e5e6e9); border-color: light-dark(#42454c, #e5e6e9); }
.slider-keys__black { position: absolute; top: 0; width: 10px; height: 32px; margin-left: -5px; border-radius: 0 0 3px 3px; background: light-dark(#6d7179, #7d8087); pointer-events: none; }
.slider-keys:has(.slider-keys__input:focus-visible) .slider-keys__board { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 4px; }
.slider-keys__input { position: absolute; left: 0; top: 30px; width: 100%; height: ${KH}px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-keys__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${KW}px; height: ${KH}px; }
.slider-keys__input::-moz-range-thumb { width: ${KW}px; height: ${KH}px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-keys__pill, .slider-keys__white { transition: none; }
}
`;
export function KeysSlider() {
const [k, setK] = useState(4);
const [held, setHeld] = useState(false);
return (
<>
<style>
{css}
</style>
<div className="slider-keys">
<span className="slider-keys__pill" style={{ left: (k + 0.5) * KW }} aria-hidden="true">
{note(k)}
</span>
<div className="slider-keys__board" aria-hidden="true">
{Array.from({ length: N }, (_, i) => {
// The chosen key goes down; its neighbours dip with it.
const dip = Math.max(0, 1 - Math.abs(i - k) / 2);
return (
<span
key={i}
className={`slider-keys__white${i === k ? ' slider-keys__white--on' : dip ? ' slider-keys__white--near' : ''}`}
style={{ left: i * KW, transform: `translateY(${dip * (held && i === k ? 4.5 : 3)}px)` }}
/>
);
})}
{BLACK.map((i) => (
<span key={i} className="slider-keys__black" style={{ left: (i + 1) * KW }} />
))}
</div>
<input
className="slider-keys__input"
type="range"
min={0}
max={N - 1}
value={k}
onPointerDown={() => setHeld(true)}
onPointerUp={() => setHeld(false)}
onPointerCancel={() => setHeld(false)}
onChange={(e) => setK(+e.target.value)}
aria-label="Note"
aria-valuetext={note(k)}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderKeys(el) on the mounted element instead.
<div class="slider-keys">
<span class="slider-keys__pill" style="left:72px" aria-hidden="true">G4</span>
<div class="slider-keys__board" aria-hidden="true">
<span class="slider-keys__white" style="left:0;transform:translateY(0px)"></span>
<span class="slider-keys__white" style="left:16px;transform:translateY(0px)"></span>
<span class="slider-keys__white" style="left:32px;transform:translateY(0px)"></span>
<span class="slider-keys__white slider-keys__white--near" style="left:48px;transform:translateY(1.5px)"></span>
<span class="slider-keys__white slider-keys__white--on" style="left:64px;transform:translateY(3px)"></span>
<span class="slider-keys__white slider-keys__white--near" style="left:80px;transform:translateY(1.5px)"></span>
<span class="slider-keys__white" style="left:96px;transform:translateY(0px)"></span>
<span class="slider-keys__white" style="left:112px;transform:translateY(0px)"></span>
<span class="slider-keys__white" style="left:128px;transform:translateY(0px)"></span>
<span class="slider-keys__white" style="left:144px;transform:translateY(0px)"></span>
<span class="slider-keys__white" style="left:160px;transform:translateY(0px)"></span>
<span class="slider-keys__white" style="left:176px;transform:translateY(0px)"></span>
<span class="slider-keys__white" style="left:192px;transform:translateY(0px)"></span>
<span class="slider-keys__white" style="left:208px;transform:translateY(0px)"></span>
<span class="slider-keys__white" style="left:224px;transform:translateY(0px)"></span>
<span class="slider-keys__black" style="left:16px"></span>
<span class="slider-keys__black" style="left:32px"></span>
<span class="slider-keys__black" style="left:64px"></span>
<span class="slider-keys__black" style="left:80px"></span>
<span class="slider-keys__black" style="left:96px"></span>
<span class="slider-keys__black" style="left:128px"></span>
<span class="slider-keys__black" style="left:144px"></span>
<span class="slider-keys__black" style="left:176px"></span>
<span class="slider-keys__black" style="left:192px"></span>
<span class="slider-keys__black" style="left:208px"></span>
</div>
<input class="slider-keys__input" type="range" min="0" max="14" aria-label="Note" aria-valuetext="G4" value="4"/>
</div>.slider-keys { position: relative; width: 240px; height: 100px; }
.slider-keys__pill {
position: absolute;
top: 0;
width: 34px;
height: 22px;
margin-left: -17px;
border-radius: 11px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-size: 12px;
font-weight: 600;
line-height: 22px;
text-align: center;
transition: left 0.3s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.slider-keys__board { position: absolute; left: 0; top: 30px; width: 240px; height: 56px; }
.slider-keys__white {
position: absolute;
top: 0;
box-sizing: border-box;
width: 16px;
height: 56px;
border: 1px solid light-dark(#cbced3, #393c41);
border-radius: 0 0 4px 4px;
background: light-dark(#ebedf0, #232529);
transition: transform 0.18s cubic-bezier(0.3, 1.5, 0.5, 1), background-color 0.18s;
}
.slider-keys__white--near { background: light-dark(#dbdde1, #2e3035); }
.slider-keys__white--on { background: light-dark(#42454c, #e5e6e9); border-color: light-dark(#42454c, #e5e6e9); }
.slider-keys__black { position: absolute; top: 0; width: 10px; height: 32px; margin-left: -5px; border-radius: 0 0 3px 3px; background: light-dark(#6d7179, #7d8087); pointer-events: none; }
.slider-keys:has(.slider-keys__input:focus-visible) .slider-keys__board { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 4px; }
.slider-keys__input { position: absolute; left: 0; top: 30px; width: 100%; height: 56px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-keys__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 56px; }
.slider-keys__input::-moz-range-thumb { width: 16px; height: 56px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-keys__pill, .slider-keys__white { transition: none; }
}function initSliderKeys(root) {
var KW = 16;
var NOTES = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
var input = root.querySelector('.slider-keys__input');
var pill = root.querySelector('.slider-keys__pill');
var whites = root.querySelectorAll('.slider-keys__white');
var held = false;
function note(i) { return NOTES[i % 7] + (4 + Math.floor(i / 7)); }
// The chosen key goes down; its neighbours dip with it, and further while the pointer is held.
function draw() {
var k = +input.value;
pill.style.left = (k + 0.5) * KW + 'px';
pill.textContent = note(k);
input.setAttribute('aria-valuetext', note(k));
whites.forEach(function (w, i) {
var dip = Math.max(0, 1 - Math.abs(i - k) / 2);
w.className = 'slider-keys__white' + (i === k ? ' slider-keys__white--on' : dip ? ' slider-keys__white--near' : '');
w.style.transform = 'translateY(' + dip * (held && i === k ? 4.5 : 3) + 'px)';
});
}
function down() {
held = true;
draw();
}
function up() {
held = false;
draw();
}
input.addEventListener('input', draw);
input.addEventListener('pointerdown', down);
input.addEventListener('pointerup', up);
input.addEventListener('pointercancel', up);
return function () {
input.removeEventListener('input', draw);
input.removeEventListener('pointerdown', down);
input.removeEventListener('pointerup', up);
input.removeEventListener('pointercancel', up);
};
}
document.querySelectorAll('.slider-keys').forEach(initSliderKeys);Goal: Create a "Keys" slider.
Visual behavior: A note picker drawn as a two-octave keyboard: the key under the thumb goes down and darkens, its neighbours dip a little, and the note name rides above.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area one key wide, so each step lands on the middle of a white key, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- White keys translate down by a dip that falls off over two keys either side, through a springy transition; the chosen key goes down further while the pointer is held. Black keys sit over the gaps after C, D, F, G and A and ignore the pointer.
- The note pill slides to the chosen key with an overshoot.
Accessibility: A native range input labelled Note with the note name, like G4, as aria-valuetext; keyboard focus rings the keyboard. Under prefers-reduced-motion the keys and the pill move without transitions.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Keys" slider.
Visual behavior: A note picker drawn as a two-octave keyboard: the key under the thumb goes down and darkens, its neighbours dip a little, and the note name rides above.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area one key wide, so each step lands on the middle of a white key, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- White keys translate down by a dip that falls off over two keys either side, through a springy transition; the chosen key goes down further while the pointer is held. Black keys sit over the gaps after C, D, F, G and A and ignore the pointer.
- The note pill slides to the chosen key with an overshoot.
Accessibility: A native range input labelled Note with the note name, like G4, as aria-valuetext; keyboard focus rings the keyboard. Under prefers-reduced-motion the keys and the pill move without transitions.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A note picker drawn as a two-octave keyboard: the key under the thumb goes down and darkens, its neighbours dip a little, and the note name rides above.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- state change, spring
- Techniques
- css transition, transform only
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input labelled Note with the note name, like G4, as aria-valuetext; keyboard focus rings the keyboard. Under prefers-reduced-motion the keys and the pill move without transitions.