nexus-labs
componentmac-keyboardsource: ok · hit
Mac Keyboard
Fully interactive Mac-style keyboard with Web Audio synthesised click sounds, Framer Motion keycap animations, and real-time physical keyboard highlighting.
npx shadcn add "https://nexus-ui.com/r/mac-keyboard.json"free
Where it goes
kanban
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
Registry dependencies
https://www.nexus-ui.com/api/registry/nexus-fontutils
Files6 · source cached 10/8/2026, 12:30:47 PM
next-ui/keyboard/index.ts
export { MacKeyboard, usePhysicalKeyboard } from "./keyboard";
export type { KeyboardProps } from "./keyboard";
export { useKeyboardSound } from "./use-keyboard-sound";
export type { SoundType } from "./use-keyboard-sound";
export type { KeyDef, KeyKind, SoundVariant } from "./keyboard-layout";
export { MAC_ROWS, COMPACT_ROWS } from "./keyboard-layout";
next-ui/keyboard/keyboard-key.tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import { cn } from "@/lib/utils";
import type { KeyDef } from "./keyboard-layout";
export interface KeyboardKeyProps {
keyDef: KeyDef;
isActive: boolean;
isFnRow?: boolean;
theme: "dark" | "light";
keyClassName?: string;
onPress: (key: KeyDef) => void;
}
export const KeyboardKey = React.memo(function KeyboardKey({
keyDef,
isActive,
isFnRow = false,
theme,
keyClassName,
onPress,
}: KeyboardKeyProps) {
const reduce = useReducedMotion();
const isDark = theme === "dark";
const isSpace = keyDef.kind === "space";
const isModifier = keyDef.kind === "modifier" || keyDef.kind === "fn" || keyDef.kind === "nav";
const isEnter = keyDef.kind === "enter";
const isDelete = keyDef.kind === "delete";
// ── Visual classes ────────────────────────────────────────────
const baseKey = cn(
// layout
"relative select-none cursor-pointer outline-none",
"flex items-end justify-center pb-1.5",
isFnRow ? "h-7 rounded-[4px]" : "h-10 rounded-[6px]",
// typography
"font-[-apple-system,BlinkMacSystemFont,'SF_Pro_Display',system-ui]",
isSpace ? "text-[0px]"
: isFnRow ? "text-[9px] font-medium tracking-tight"
: isModifier ? "text-[10px] font-medium tracking-tight leading-none"
: "text-[13px] font-medium tracking-tight",
// base colors — overridden by active/pressed variants below
isDark
? "text-neutral-200 bg-gradient-to-b from-[#3e3e42] to-[#2c2c30]"
: "text-neutral-700 bg-gradient-to-b from-[#f5f5f7] to-[#e2e2e5]",
// border / depth shadow that creates the 3-D "key face" look
isDark
? "shadow-[0_1px_0_0_#000,0_2px_4px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.07)]"
: "shadow-[0_1px_0_0_#9e9e9e,0_2px_4px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.9)]",
// active highlight
isActive && (
isDark
? "from-[#0ea5e9] to-[#0284c7] text-white shadow-[0_1px_0_0_#0369a1,0_2px_8px_rgba(14,165,233,0.45),inset_0_1px_0_rgba(255,255,255,0.2)]"
: "from-[#0ea5e9] to-[#0284c7] text-white shadow-[0_1px_0_0_#0369a1,0_2px_8px_rgba(14,165,233,0.35),inset_0_1px_0_rgba(255,255,255,0.3)]"
),
// hover (not applied when active)
!isActive && (isDark ? "hover:from-[#48484d] hover:to-[#363638]" : "hover:from-[#fafafa] hover:to-[#e8e8eb]"),
keyClassName,
);
const pressVariants: Variants = reduce
? {}
: {
initial: { y: 0, scale: 1 },
press: { y: 1, scale: 0.97 },
};
const handleClick = React.useCallback(() => onPress(keyDef), [onPress, keyDef]);
return (
<motion.button
type="button"
aria-label={keyDef.label === "⌫" ? "Backspace" : keyDef.label || keyDef.id}
aria-pressed={isActive}
style={{ flex: keyDef.width }}
animate={isActive && !reduce ? "press" : "initial"}
variants={pressVariants}
transition={{ type: "spring", stiffness: 500, damping: 30, mass: 0.6 }}
whileTap={reduce ? undefined : "press"}
className={baseKey}
onClick={handleClick}
>
{isSpace ? null : (
<span className="relative flex flex-col items-center justify-end gap-0">
{/* shift-layer label */}
{keyDef.sub && !isModifier && !isFnRow && (
<span
className={cn(
"absolute -top-2 left-0 text-[8px] leading-none",
isDark ? "text-neutral-400" : "text-neutral-400",
isActive && "text-sky-100",
)}
>
{keyDef.sub}
</span>
)}
<span>{keyDef.label}</span>
</span>
)}
</motion.button>
);
});
next-ui/keyboard/keyboard-layout.ts
export type KeyKind = "normal" | "modifier" | "space" | "enter" | "delete" | "fn" | "nav";
export type SoundVariant = "normal" | "space" | "enter" | "delete" | "modifier";
export interface KeyDef {
id: string;
label: string;
sub?: string; // shift-layer label shown smaller
code: string; // KeyboardEvent.code
char: string; // character to insert
width: number; // flex units
kind: KeyKind;
sound: SoundVariant;
groupGap?: boolean; // add extra gap before this key (fn row groups)
}
type Row = KeyDef[];
const k = (
id: string,
label: string,
sub: string | undefined,
code: string,
char: string,
width: number,
kind: KeyKind = "normal",
sound: SoundVariant = "normal",
groupGap = false,
): KeyDef => ({ id, label, sub, code, char, width, kind, sound, groupGap });
// Every row except fn sums to 15 flex-units so rows stay aligned.
export const MAC_ROWS: Row[] = [
// ── Function row ─────────────────────────────────────────────
// Esc=1.5u + 12 F-keys=1u each + 3 inter-group gaps → fills naturally via flex
[
k("Escape", "esc", undefined, "Escape", "", 1.5, "modifier", "modifier"),
k("F1", "F1", undefined, "F1", "", 1, "fn", "modifier", true),
k("F2", "F2", undefined, "F2", "", 1, "fn", "modifier"),
k("F3", "F3", undefined, "F3", "", 1, "fn", "modifier"),
k("F4", "F4", undefined, "F4", "", 1, "fn", "modifier"),
k("F5", "F5", undefined, "F5", "", 1, "fn", "modifier", true),
k("F6", "F6", undefined, "F6", "", 1, "fn", "modifier"),
k("F7", "F7", undefined, "F7", "", 1, "fn", "modifier"),
k("F8", "F8", undefined, "F8", "", 1, "fn", "modifier"),
k("F9", "F9", undefined, "F9", "", 1, "fn", "modifier", true),
k("F10", "F10", undefined, "F10", "", 1, "fn", "modifier"),
k("F11", "F11", undefined, "F11", "", 1, "fn", "modifier"),
k("F12", "F12", undefined, "F12", "", 1, "fn", "modifier"),
],
// ── Number row (15u) ─────────────────────────────────────────
[
k("Backquote", "`", "~", "Backquote", "`", 1),
k("Digit1", "1", "!", "Digit1", "1", 1),
k("Digit2", "2", "@", "Digit2", "2", 1),
k("Digit3", "3", "#", "Digit3", "3", 1),
k("Digit4", "4", "$", "Digit4", "4", 1),
k("Digit5", "5", "%", "Digit5", "5", 1),
k("Digit6", "6", "^", "Digit6", "6", 1),
k("Digit7", "7", "&", "Digit7", "7", 1),
k("Digit8", "8", "*", "Digit8", "8", 1),
k("Digit9", "9", "(", "Digit9", "9", 1),
k("Digit0", "0", ")", "Digit0", "0", 1),
k("Minus", "-", "_", "Minus", "-", 1),
k("Equal", "=", "+", "Equal", "=", 1),
k("Backspace", "⌫", undefined, "Backspace", "", 2, "delete", "delete"),
],
// ── QWERTY row (15u) ─────────────────────────────────────────
[
k("Tab", "⇥", undefined, "Tab", "\t", 1.5, "modifier", "modifier"),
k("KeyQ", "Q", undefined, "KeyQ", "q", 1),
k("KeyW", "W", undefined, "KeyW", "w", 1),
k("KeyE", "E", undefined, "KeyE", "e", 1),
k("KeyR", "R", undefined, "KeyR", "r", 1),
k("KeyT", "T", undefined, "KeyT", "t", 1),
k("KeyY", "Y", undefined, "KeyY", "y", 1),
k("KeyU", "U", undefined, "KeyU", "u", 1),
k("KeyI", "I", undefined, "KeyI", "i", 1),
k("KeyO", "O", undefined, "KeyO", "o", 1),
k("KeyP", "P", undefined, "KeyP", "p", 1),
k("BracketLeft", "[", "{", "BracketLeft", "[", 1),
k("BracketRight", "]", "}", "BracketRight", "]", 1),
k("Backslash", "\\", "|", "Backslash", "\\", 1.5, "modifier", "modifier"),
],
// ── ASDF row (15u) ───────────────────────────────────────────
[
k("CapsLock", "caps", undefined, "CapsLock", "", 1.75, "modifier", "modifier"),
k("KeyA", "A", undefined, "KeyA", "a", 1),
k("KeyS", "S", undefined, "KeyS", "s", 1),
k("KeyD", "D", undefined, "KeyD", "d", 1),
k("KeyF", "F", undefined, "KeyF", "f", 1),
k("KeyG", "G", undefined, "KeyG", "g", 1),
k("KeyH", "H", undefined, "KeyH", "h", 1),
k("KeyJ", "J", undefined, "KeyJ", "j", 1),
k("KeyK", "K", undefined, "KeyK", "k", 1),
k("KeyL", "L", undefined, "KeyL", "l", 1),
k("Semicolon",";" , ":", "Semicolon",";" , 1),
k("Quote", "'", "\"", "Quote", "'", 1),
k("Enter", "return", undefined, "Enter", "\n", 2.25, "enter", "enter"),
],
// ── ZXCV row (15u) ───────────────────────────────────────────
[
k("ShiftLeft", "⇧", undefined, "ShiftLeft", "", 2.25, "modifier", "modifier"),
k("KeyZ", "Z", undefined, "KeyZ", "z", 1),
k("KeyX", "X", undefined, "KeyX", "x", 1),
k("KeyC", "C", undefined, "KeyC", "c", 1),
k("KeyV", "V", undefined, "KeyV", "v", 1),
k("KeyB", "B", undefined, "KeyB", "b", 1),
k("KeyN", "N", undefined, "KeyN", "n", 1),
k("KeyM", "M", undefined, "KeyM", "m", 1),
k("Comma", ",", "<", "Comma", ",", 1),
k("Period", ".", ">", "Period", ".", 1),
k("Slash", "/", "?", "Slash", "/", 1),
k("ShiftRight", "⇧", undefined, "ShiftRight", "", 2.75, "modifier", "modifier"),
],
// ── Bottom row (15u) ─────────────────────────────────────────
[
k("Fn", "fn", undefined, "Fn", "", 1, "modifier", "modifier"),
k("ControlLeft", "control", undefined, "ControlLeft", "", 1.5, "modifier", "modifier"),
k("AltLeft", "option", undefined, "AltLeft", "", 1.5, "modifier", "modifier"),
k("MetaLeft", "⌘", undefined, "MetaLeft", "", 1.75, "modifier", "modifier"),
k("Space", "", undefined, "Space", " ", 5, "space", "space"),
k("MetaRight", "⌘", undefined, "MetaRight", "", 1.75, "modifier", "modifier"),
k("AltRight", "option", undefined, "AltRight", "", 1.5, "modifier", "modifier"),
k("ArrowLeft", "←", undefined, "ArrowLeft", "", 1, "nav", "modifier"),
k("ArrowRight", "→", undefined, "ArrowRight", "", 1, "nav", "modifier"),
],
];
export const COMPACT_ROWS: Row[] = MAC_ROWS.slice(1);
// Maps alternate code names browsers emit for the same physical key
export const CODE_ALIASES: Record<string, string> = {
OSLeft: "MetaLeft",
OSRight: "MetaRight",
};
next-ui/keyboard/keyboard.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { KeyboardKey } from "./keyboard-key";
import { useKeyboardSound, type SoundType } from "./use-keyboard-sound";
import { MAC_ROWS, COMPACT_ROWS, CODE_ALIASES, type KeyDef } from "./keyboard-layout";
export interface KeyboardProps {
layout?: "mac" | "compact";
enableSound?: boolean;
volume?: number;
soundType?: SoundType;
/** Keys currently held/highlighted — pass KeyboardEvent.code values */
activeKeys?: string[];
className?: string;
keyClassName?: string;
theme?: "dark" | "light" | "auto";
showFunctionRow?: boolean;
onKeyPress?: (key: KeyDef) => void;
}
export function MacKeyboard({
layout = "mac",
enableSound = true,
volume = 0.6,
soundType = "mechanical",
activeKeys = [],
className,
keyClassName,
theme = "auto",
showFunctionRow = true,
onKeyPress,
}: KeyboardProps) {
const [resolvedTheme, setResolvedTheme] = React.useState<"dark" | "light">("dark");
const activeSet = React.useMemo(() => new Set(activeKeys), [activeKeys]);
const { play } = useKeyboardSound({ enabled: enableSound, volume, soundType });
// Resolve "auto" theme against the document class
React.useEffect(() => {
if (theme !== "auto") { setResolvedTheme(theme); return; }
const update = () =>
setResolvedTheme(document.documentElement.classList.contains("dark") ? "dark" : "light");
update();
const mo = new MutationObserver(update);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => mo.disconnect();
}, [theme]);
const isDark = resolvedTheme === "dark";
const rows = React.useMemo(() => {
const base = layout === "compact" ? COMPACT_ROWS : MAC_ROWS;
return showFunctionRow ? base : base.slice(layout === "mac" ? 1 : 0);
}, [layout, showFunctionRow]);
const handlePress = React.useCallback(
(key: KeyDef) => {
play(key.sound);
onKeyPress?.(key);
},
[play, onKeyPress],
);
return (
<div
role="group"
aria-label="Mac keyboard"
className={cn(
// body shape + material
"w-full max-w-[780px] mx-auto select-none",
"rounded-[14px] p-3",
isDark
? "bg-gradient-to-b from-[#242428] to-[#1a1a1e] shadow-[0_12px_40px_rgba(0,0,0,0.6),0_4px_12px_rgba(0,0,0,0.4),inset_0_1px_0_rgba(255,255,255,0.06)]"
: "bg-gradient-to-b from-[#d8d8dc] to-[#c8c8cc] shadow-[0_12px_40px_rgba(0,0,0,0.18),0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.7)]",
className,
)}
>
<div className="flex flex-col gap-[5px]">
{rows.map((row, rowIdx) => {
const isFnRow = layout === "mac" && showFunctionRow && rowIdx === 0;
return (
<div
key={rowIdx}
className="flex gap-[4px]"
>
{row.map((keyDef) => (
<React.Fragment key={keyDef.id}>
{/* Extra gap for function-key groups */}
{keyDef.groupGap && (
<div className={isFnRow ? "w-[10px] shrink-0" : undefined} />
)}
<KeyboardKey
keyDef={keyDef}
isActive={
activeSet.has(keyDef.code) ||
(keyDef.code === "MetaLeft" && activeSet.has("OSLeft")) ||
(keyDef.code === "MetaRight" && activeSet.has("OSRight"))
}
isFnRow={isFnRow}
theme={resolvedTheme}
keyClassName={keyClassName}
onPress={handlePress}
/>
</React.Fragment>
))}
</div>
);
})}
</div>
</div>
);
}
// ── Hook: sync physical keyboard presses to activeKeys ──────────
export function usePhysicalKeyboard() {
const [pressed, setPressed] = React.useState<Set<string>>(new Set());
React.useEffect(() => {
function onDown(e: KeyboardEvent) {
const code = CODE_ALIASES[e.code] ?? e.code;
setPressed((p) => { const n = new Set(p); n.add(code); return n; });
}
function onUp(e: KeyboardEvent) {
const code = CODE_ALIASES[e.code] ?? e.code;
setPressed((p) => { const n = new Set(p); n.delete(code); return n; });
}
window.addEventListener("keydown", onDown);
window.addEventListener("keyup", onUp);
return () => {
window.removeEventListener("keydown", onDown);
window.removeEventListener("keyup", onUp);
};
}, []);
return Array.from(pressed);
}
next-ui/keyboard/use-keyboard-sound.ts
"use client";
import * as React from "react";
import type { SoundVariant } from "./keyboard-layout";
export type SoundType = "mechanical" | "soft" | "clicky";
// Per-variant parameters tuned for each key type
type VParams = {
// Click layer: the switch mechanism actuating (~first few ms)
ckHz: number; ckQ: number; ckDcy: number; ckGn: number;
// Thud layer: keycap bottom-out impact (~slightly delayed)
tdHz: number; tdQ: number; tdDcy: number; tdGn: number;
tdOff: number; // seconds after the click
// Body-resonance tone
bodyHz: number; bodyGn: number;
};
const VARIANT: Record<SoundVariant, VParams> = {
normal: { ckHz:5400, ckQ:4.2, ckDcy:0.0042, ckGn:0.50, tdHz:400, tdQ:1.1, tdDcy:0.022, tdGn:0.82, tdOff:0.007, bodyHz:280, bodyGn:0.22 },
space: { ckHz:2200, ckQ:2.2, ckDcy:0.010, ckGn:0.28, tdHz:148, tdQ:0.8, tdDcy:0.048, tdGn:1.05, tdOff:0.012, bodyHz:118, bodyGn:0.30 },
enter: { ckHz:3700, ckQ:3.4, ckDcy:0.006, ckGn:0.43, tdHz:275, tdQ:1.0, tdDcy:0.032, tdGn:0.92, tdOff:0.009, bodyHz:205, bodyGn:0.26 },
delete: { ckHz:6100, ckQ:4.8, ckDcy:0.0034, ckGn:0.55, tdHz:510, tdQ:1.3, tdDcy:0.017, tdGn:0.73, tdOff:0.005, bodyHz:345, bodyGn:0.18 },
modifier: { ckHz:3000, ckQ:2.7, ckDcy:0.005, ckGn:0.28, tdHz:210, tdQ:0.9, tdDcy:0.019, tdGn:0.60, tdOff:0.007, bodyHz:158, bodyGn:0.15 },
};
// Per-type multipliers
const TYPE_MULT: Record<SoundType, { ckScale: number; tdScale: number; bodyScale: number; ckQMult: number }> = {
mechanical: { ckScale: 1.00, tdScale: 1.00, bodyScale: 1.00, ckQMult: 1.0 },
soft: { ckScale: 0.28, tdScale: 0.75, bodyScale: 0.60, ckQMult: 0.5 },
clicky: { ckScale: 1.55, tdScale: 0.85, bodyScale: 0.55, ckQMult: 1.9 },
};
function synthesise(ctx: AudioContext, variant: SoundVariant, type: SoundType, volume: number) {
const now = ctx.currentTime;
const sr = ctx.sampleRate;
const vol = Math.max(0, Math.min(1, volume));
const p = VARIANT[variant];
const tm = TYPE_MULT[type];
// Slight per-hit randomisation — keeps rapid typing feeling organic
const jitter = () => 0.92 + Math.random() * 0.16;
function noiseBurst(t: number, hz: number, q: number, decaySec: number, gain: number) {
if (gain < 0.001) return;
const dur = Math.max(decaySec * 6.5, 0.014);
const len = Math.ceil(sr * dur);
const buf = ctx.createBuffer(1, len, sr);
const d = buf.getChannelData(0);
const tc = decaySec * sr;
for (let i = 0; i < len; i++)
d[i] = (Math.random() * 2 - 1) * Math.exp(-i / tc);
const src = ctx.createBufferSource(); src.buffer = buf;
const bp = ctx.createBiquadFilter();
bp.type = "bandpass"; bp.frequency.value = hz * jitter(); bp.Q.value = q;
const gn = ctx.createGain();
gn.gain.setValueAtTime(gain * vol, t);
gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(bp); bp.connect(gn); gn.connect(ctx.destination);
src.start(t); src.stop(t + dur + 0.01);
}
// ── Layer 1: High-frequency click (switch actuation snap) ─────
noiseBurst(now, p.ckHz, p.ckQ * tm.ckQMult, p.ckDcy, p.ckGn * tm.ckScale);
// ── Layer 2: Low-frequency thud (bottom-out impact) ───────────
const t2 = now + p.tdOff;
noiseBurst(t2, p.tdHz * jitter(), p.tdQ, p.tdDcy, p.tdGn * tm.tdScale);
// ── Layer 3: Pitched resonance (case / plate ring) ────────────
const bodyGain = p.bodyGn * tm.bodyScale * vol;
if (bodyGain > 0.01) {
const osc = ctx.createOscillator();
const gn = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(p.bodyHz * 1.35 * jitter(), t2);
osc.frequency.exponentialRampToValueAtTime(p.bodyHz * 0.52, t2 + 0.065);
gn.gain.setValueAtTime(bodyGain, t2);
gn.gain.exponentialRampToValueAtTime(0.0001, t2 + 0.085);
osc.connect(gn); gn.connect(ctx.destination);
osc.start(t2); osc.stop(t2 + 0.1);
}
}
export function useKeyboardSound(opts: {
enabled: boolean;
volume: number;
soundType: SoundType;
}) {
const ctxRef = React.useRef<AudioContext | null>(null);
const optsRef = React.useRef(opts);
optsRef.current = opts;
const getCtx = React.useCallback((): AudioContext | null => {
if (!optsRef.current.enabled) return null;
try {
if (!ctxRef.current || ctxRef.current.state === "closed")
ctxRef.current = new AudioContext();
if (ctxRef.current.state === "suspended")
void ctxRef.current.resume();
return ctxRef.current;
} catch {
return null;
}
}, []);
const play = React.useCallback(
(variant: SoundVariant) => {
const ctx = getCtx();
if (!ctx) return;
try {
synthesise(ctx, variant, optsRef.current.soundType, optsRef.current.volume);
} catch {
// Silently absorb Web Audio errors (policy / SSR)
}
},
[getCtx],
);
React.useEffect(() => () => { ctxRef.current?.close().catch(() => {}); }, []);
return { play };
}
next-ui/mac-keyboard.tsx
export { MacKeyboard, usePhysicalKeyboard } from "./keyboard";
export type { KeyboardProps } from "./keyboard";
export type { KeyDef, KeyKind, SoundVariant } from "./keyboard/keyboard-layout";