Buttons
ToneKey
One ivory piano key between two black ones. Each press dips the key and plays the next note, made live with Web Audio. No sound files.
Download
- React
- Tailwind CSS v4
- motion
components/ui/tone-key.tsx
"use client";
import { useRef, useState, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
type ToneKeyProps = {
children: ReactNode;
className?: string;
onClick?: () => void;
/** Notes played in turn, one per press. Letter, optional #, octave. */
notes?: string[];
/** Keeps the key silent. */
muted?: boolean;
};
type Glyph = { id: number; drift: number };
const STEPS: Record<string, number> = { C: -9, D: -7, E: -5, F: -4, G: -2, A: 0, B: 2 };
function toHz(note: string) {
const m = /^([A-G])(#?)(\d)$/.exec(note);
if (!m) return 440;
const fromA4 = STEPS[m[1]] + (m[2] ? 1 : 0) + (Number(m[3]) - 4) * 12;
return 440 * 2 ** (fromA4 / 12);
}
let audio: AudioContext | undefined;
function play(hz: number) {
audio ??= new AudioContext();
const now = audio.currentTime;
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.type = "triangle";
osc.frequency.value = hz;
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.22, now + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.9);
osc.connect(gain).connect(audio.destination);
osc.start(now);
osc.stop(now + 0.92);
void audio.resume();
}
/* ToneKey: one ivory piano key as a button. Each press dips the key and plays the next note. */
export function ToneKey({
children,
className = "min-w-44 rounded-b-lg px-8 pb-4 pt-12 text-[15px] font-semibold text-[#2a2620]",
onClick,
notes = ["C4", "E4", "G4", "C5"],
muted = false,
}: ToneKeyProps) {
const reduce = useReducedMotion();
const [down, setDown] = useState(false);
const [glyphs, setGlyphs] = useState<Glyph[]>([]);
const turn = useRef(0);
const next = useRef(0);
const strike = () => {
if (!muted && notes.length) play(toHz(notes[turn.current++ % notes.length]));
if (reduce) return;
const id = next.current++;
setGlyphs((g) => [...g, { id, drift: id % 2 ? 18 : -18 }]);
};
return (
<span className="relative inline-block [perspective:500px]">
<span aria-hidden className="absolute inset-x-[-10px] -top-2.5 z-10 h-3 rounded-t-md border-b-2 border-[#8c2f2f] bg-[#1b1512]" />
<span aria-hidden className="absolute -left-2 top-0 z-10 h-[58%] w-5 rounded-b bg-gradient-to-b from-[#26211e] to-[#0c0a09]" />
<span aria-hidden className="absolute -right-2 top-0 z-10 h-[58%] w-5 rounded-b bg-gradient-to-b from-[#26211e] to-[#0c0a09]" />
{glyphs.map((g) => (
<motion.span
key={g.id}
aria-hidden
className="pointer-events-none absolute left-1/2 top-0 z-20 text-xl text-[#f2c14e]"
initial={{ x: 0, y: 0, opacity: 1 }}
animate={{ x: g.drift, y: -56, opacity: 0, rotate: g.drift }}
transition={{ duration: 0.9, ease: "easeOut" }}
onAnimationComplete={() => setGlyphs((all) => all.filter((o) => o.id !== g.id))}
>
♪
</motion.span>
))}
<motion.button
type="button"
onPointerDown={() => {
setDown(true);
strike();
}}
onPointerUp={() => setDown(false)}
onPointerLeave={() => setDown(false)}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && setDown(true)}
onKeyUp={() => setDown(false)}
onBlur={() => setDown(false)}
onClick={(e) => {
// detail 0 = Enter or Space. A pointer already struck on the way down.
if (e.detail === 0) strike();
onClick?.();
}}
animate={{
rotateX: down && !reduce ? -7 : 0,
boxShadow: down ? "0 2px 0 #b9b19e, 0 3px 6px rgba(0,0,0,0.4)" : "0 7px 0 #b9b19e, 0 12px 16px rgba(0,0,0,0.45)",
}}
transition={{ type: "spring", stiffness: 800, damping: 30 }}
className={`relative block origin-top bg-gradient-to-b from-[#f7f2e6] to-[#ebe3d0] ${className}`}
>
{children}
</motion.button>
</span>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/tone-key.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/tone-key-demo.tsx
import { ToneKey } from "@/components/ui/tone-key"; export default function ToneKeyExample() { return ( <div className="grid h-full place-items-center bg-[radial-gradient(circle_at_50%_40%,#2a2118,#0d0c14_70%)]"> <ToneKey>Press for a note</ToneKey> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Label on the key. |
| onClick | () => void | — | Click handler. |
| notes | string[] | ["C4", "E4", "G4", "C5"] | Played in turn, one per press. Letter, optional #, octave. |
| muted | boolean | false | Keeps the key silent. |
| className | string | ivory key | Size and text. The ivory face stays. |