Teseria
All elements

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

Terminal
# No packages beyond React.

2. Files

Keys.tsx111 lines
"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.css63 lines
/* 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; }
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { Keys } from "./Keys";
import "./Keys.css";

export default function Demo() {
  return <Keys />;
}