Piano
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Press and glide across the keys to play a little tune.
Code
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { Surface } from "../ui";
const WHITE = ["C", "D", "E", "F", "G", "A", "B"];
const BLACK_AFTER = new Set(["C", "D", "F", "G", "A"]);
const SEMI: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
type Key = { id: string; label: string; midi: number; black: boolean; slot: number };
const KEYS: Key[] = (() => {
const keys: Key[] = [];
for (let o = 0; o < 2; o++) {
WHITE.forEach((n, i) => {
const slot = o * 7 + i;
const midi = 60 + o * 12 + SEMI[n];
keys.push({ id: `${n}${4 + o}`, label: `${n}${4 + o}`, midi, black: false, slot });
if (BLACK_AFTER.has(n)) keys.push({ id: `${n}#${4 + o}`, label: `${n}♯${4 + o}`, midi: midi + 1, black: true, slot });
});
}
keys.push({ id: "C6", label: "C6", midi: 84, black: false, slot: 14 });
return keys;
})();
const WHITES = KEYS.filter((k) => !k.black);
const BLACKS = KEYS.filter((k) => k.black);
export default function Piano() {
const [down, setDown] = useState<Set<string>>(new Set());
const [last, setLast] = useState<{ label: string; n: number } | null>(null);
const audio = useRef<AudioContext | null>(null);
const held = useRef(false);
useEffect(() => () => void audio.current?.close(), []);
const play = (k: Key) => {
try {
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (!audio.current) audio.current = new Ctx();
const ac = audio.current;
const f = 440 * 2 ** ((k.midi - 69) / 12);
const now = ac.currentTime;
const g = ac.createGain();
g.gain.setValueAtTime(0, now);
g.gain.linearRampToValueAtTime(0.18, now + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, now + 1.4);
g.connect(ac.destination);
for (const [type, mult, vol] of [
["triangle", 1, 1],
["sine", 2, 0.3],
] as const) {
const o = ac.createOscillator();
const og = ac.createGain();
og.gain.value = vol;
o.type = type;
o.frequency.value = f * mult;
o.connect(og).connect(g);
o.start(now);
o.stop(now + 1.5);
}
} catch {
/* silent if audio is unavailable */
}
};
const press = (k: Key) => {
setDown((s) => new Set(s).add(k.id));
setLast((l) => ({ label: k.label, n: (l?.n ?? 0) + 1 }));
play(k);
};
const release = (k: Key) =>
setDown((s) => {
const n = new Set(s);
n.delete(k.id);
return n;
});
const handlers = (k: Key) => ({
onPointerDown: (e: React.PointerEvent<HTMLElement>) => {
e.preventDefault();
// let the pointer glide across keys
e.currentTarget.releasePointerCapture?.(e.pointerId);
held.current = true;
press(k);
},
onPointerEnter: () => held.current && press(k),
onPointerLeave: () => release(k),
onPointerUp: () => {
held.current = false;
release(k);
},
});
const W = 100 / WHITES.length;
return (
<Surface>
<div className="flex flex-col items-center gap-[1em]" onPointerLeave={() => (held.current = false)} onPointerUp={() => (held.current = false)}>
<div className="relative h-[1.6em]">
<AnimatePresence mode="popLayout">
{last && (
<motion.span
key={last.n}
initial={{ y: 10, opacity: 0, scale: 0.8 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: -10, opacity: 0 }}
className="block font-mono text-[1em]"
>
{last.label}
</motion.span>
)}
</AnimatePresence>
{!last && <span className="font-mono text-[0.75em] text-[#8a8984]">press and glide</span>}
</div>
<div className="relative h-[9em] w-[28em] max-w-[92cqw] touch-none rounded-[0.8em] bg-[#151515] p-[0.35em] pt-[0.8em] shadow-[0_16px_36px_-18px_rgba(0,0,0,0.6)]">
<div className="relative h-full w-full">
{WHITES.map((k, i) => {
const on = down.has(k.id);
return (
<motion.div
key={k.id}
{...handlers(k)}
className="absolute top-0 h-full cursor-pointer px-[0.08em]"
style={{ left: `${i * W}%`, width: `${W}%` }}
>
<motion.div
animate={{ y: on ? 3 : 0, backgroundColor: on ? "#e8ecfb" : "#ffffff" }}
transition={{ duration: 0.08 }}
className="relative h-full w-full rounded-b-[0.4em] shadow-[inset_0_-0.35em_0_#dcdbd6]"
>
{k.label.startsWith("C") && <span className="absolute bottom-[0.6em] left-1/2 -translate-x-1/2 font-mono text-[0.5em] text-[#8a8984]">{k.label}</span>}
</motion.div>
</motion.div>
);
})}
{BLACKS.map((k) => {
const on = down.has(k.id);
return (
<motion.div
key={k.id}
{...handlers(k)}
animate={{ y: on ? 2 : 0, backgroundColor: on ? "#2B46B8" : "#151515" }}
transition={{ duration: 0.08 }}
className="absolute top-0 z-10 h-[60%] cursor-pointer rounded-b-[0.3em] shadow-[inset_0_-0.3em_0_rgba(255,255,255,0.12)]"
style={{ left: `${(k.slot + 1) * W - W * 0.3}%`, width: `${W * 0.6}%` }}
/>
);
})}
</div>
</div>
</div>
</Surface>
);
}
Prompt
Build "Piano", a other animation. What it does: Press and glide across the keys to play a little tune. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using motion. Technique: an HTML canvas, the Motion library, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).