Keys
One octave as flat hue pads. Press sinks a pad, a ring leaves it and its note rings; drag across the pads to glide, or type a to k while the pointer or focus is on the block.
Code
1. Install
# No packages beyond React.
2. Files
"use client";
// Keys: one octave as flat hue pads. Press sinks a pad, a ring leaves it and its note rings; drag across the pads to glide,
// or type a to k while the pointer or focus is on the block. Each press rings a sine note from the block itself (Web Audio).
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent as RPE } from "react";
export type KeysProps = {
/** Semitones above the root for each pad, left to right. Default is a C major scale. */
notes?: number[];
/** One keyboard key per pad, in order (typing them plays the pad). */
keys?: string;
/** Called with the semitone of each struck note. */
onNote?: (semitone: number) => void;
/** Silence the built-in tone (pads still animate and onNote still fires). */
muted?: boolean;
/** Aria label of the group. */
label?: string;
className?: string;
style?: CSSProperties;
};
export const DEFAULT_NOTES = [0, 2, 4, 5, 7, 9, 11, 12];
export function Keys({ notes = DEFAULT_NOTES, keys = "asdfghjk", onNote, muted = false, label = "One octave; press or type a to k", className, style }: KeysProps) {
const [hits, setHits] = useState<number[]>([]);
const [down, setDown] = useState<number | null>(null);
const inside = useRef(false);
const last = useRef<number | null>(null);
const hover = useRef(false);
const focus = useRef(false);
const actx = useRef<AudioContext | null>(null);
const live = useRef({ notes, keys, onNote, muted });
live.current = { notes, keys, onNote, muted };
useEffect(() => () => void (actx.current?.close().catch(() => {}), (actx.current = null)), []);
const ring = (semi: number) => {
try {
actx.current ??= new AudioContext();
const a = actx.current;
const t = a.currentTime;
const o = a.createOscillator();
const g = a.createGain();
o.type = "sine";
o.frequency.value = 261.63 * Math.pow(2, semi / 12);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.12, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.7);
o.connect(g).connect(a.destination);
o.start(t);
o.stop(t + 0.72);
} catch {}
};
const hit = (i: number) => {
const { notes: ns, onNote: on, muted: mu } = live.current;
if (i >= ns.length) return;
if (!mu) ring(ns[i]);
on?.(ns[i]);
setHits((h) => {
const c = h.slice();
c[i] = (c[i] ?? 0) + 1;
return c;
});
setDown(i);
};
const padAt = (x: number, y: number) => {
const el = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-pad]");
return el ? Number(el.dataset.pad) : null;
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!inside.current || e.repeat || e.metaKey || e.ctrlKey || e.altKey) return;
const i = live.current.keys.indexOf(e.key.toLowerCase());
if (i >= 0) (e.preventDefault(), hit(i));
};
const onUp = () => setDown(null);
window.addEventListener("keydown", onKey);
window.addEventListener("keyup", onUp);
return () => (window.removeEventListener("keydown", onKey), window.removeEventListener("keyup", onUp));
}, []);
const sync = () => (inside.current = hover.current || focus.current);
return (
<div
className={className ? `key-root ${className}` : "key-root"}
style={style}
role="group"
aria-label={label}
tabIndex={0}
onMouseEnter={() => ((hover.current = true), sync())}
onMouseLeave={() => ((hover.current = false), sync())}
onFocus={() => ((focus.current = true), sync())}
onBlur={() => ((focus.current = false), sync())}
onPointerDown={(e: RPE<HTMLDivElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
const i = padAt(e.clientX, e.clientY);
last.current = i;
if (i !== null) hit(i);
}}
onPointerMove={(e: RPE<HTMLDivElement>) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
const i = padAt(e.clientX, e.clientY);
if (i !== null && i !== last.current) (hit(i), (last.current = i));
}}
onPointerUp={() => ((last.current = null), setDown(null))}
>
{notes.map((_, i) => (
<span key={i} className="key-pad" data-pad={i} data-down={down === i || undefined} style={{ "--key-h": (i * 42 + 10) % 360 } as CSSProperties}>
<i key={hits[i] ?? 0} className={hits[i] ? "key-ring" : undefined} />
<kbd>{keys[i]}</kbd>
</span>
))}
</div>
);
}
/* Keys. Prefix key-. */
.key-root, .key-root * { box-sizing: border-box; }
.key-root { --key-ease: cubic-bezier(0.22, 1, 0.36, 1); position: relative; margin: 0 auto; font-family: var(--ts-font, system-ui, sans-serif); font-size: 13px; letter-spacing: -0.01em; color: var(--ts-fg, #14161b); font-variant-numeric: tabular-nums; }
:where(.key-root) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; transition: scale 0.22s var(--key-ease); }
:where(.key-root) button:active { scale: 0.96; }
:where(.key-root) button:focus-visible, .key-root [tabindex]:focus-visible { outline: 2px solid var(--ts-focus, #3446eb); outline-offset: 2px; }
.key-root {
display: flex;
gap: 5px;
width: 300px;
height: 120px;
padding: 10px 0;
touch-action: none;
user-select: none;
}
.key-pad {
position: relative;
flex: 1;
border-radius: 10px;
--key-tint: oklch(68% 0.13 calc(var(--key-h) * 1deg));
background: color-mix(in oklab, var(--key-tint) 38%, var(--ts-sunken));
display: grid;
place-items: end center;
padding-bottom: 8px;
transition:
background 0.3s var(--key-ease),
scale 0.12s var(--key-ease),
translate 0.12s var(--key-ease);
cursor: pointer;
}
.key-pad kbd {
font: inherit;
font-size: 11px;
color: rgba(var(--ts-fg-rgb), 0.55);
}
.key-pad[data-down] {
background: color-mix(in oklab, var(--key-tint) 85%, var(--ts-sunken));
scale: 0.96;
translate: 0 2px;
}
.key-pad i {
position: absolute;
inset: 0;
border-radius: 10px;
pointer-events: none;
}
.key-ring {
box-shadow: 0 0 0 1px var(--key-tint);
animation: key-ring 0.7s var(--key-ease) both;
}
@keyframes key-ring {
from {
opacity: 0.9;
scale: 1;
}
to {
opacity: 0;
scale: 1.25 1.4;
}
}
@media (prefers-reduced-motion: reduce) { .key-root * { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } }
.key-root:focus-visible { outline: 2px solid var(--ts-focus, #3446eb); outline-offset: 2px; }
.key-pad { --key-h: 0; }
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Keys } from "./Keys";
import "./Keys.css";
export default function Demo() {
return <Keys />;
}