Baktiyar Ablaikhan

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).