Kbd
Key caps that go down when you press the real keys: the cap drops a pixel, its bottom edge collapses and the legend takes the accent, then it springs back and cools on release.
import { useEffect, useMemo, useRef } from 'react';
import { SPRING, Spring, createLoop, onReducedMotionChange, prefersReducedMotion } from '../shared/spring';
import { resolveAccent, type Accent } from '../shared/accent';
import '../shared/ember.css';
import './kbd.css';
export interface KbdProps {
/** A combination, keys separated by spaces: "⌘ K", "Shift Enter", "Esc". */
keys: string;
/** Press the caps when the matching physical keys go down. */
listen?: boolean;
size?: 'sm' | 'md';
accent?: Accent;
className?: string;
}
type Modifier = 'Meta' | 'Control' | 'Alt' | 'Shift';
interface Cap {
legend: string;
/** The KeyboardEvent.key this cap answers to. */
key: string;
modifier: Modifier | null;
spoken: string;
}
const ALIAS: Record<string, string> = {
'⌘': 'Meta',
cmd: 'Meta',
command: 'Meta',
meta: 'Meta',
'⌃': 'Control',
ctrl: 'Control',
control: 'Control',
'⌥': 'Alt',
alt: 'Alt',
option: 'Alt',
opt: 'Alt',
'⇧': 'Shift',
shift: 'Shift',
'↵': 'Enter',
'⏎': 'Enter',
enter: 'Enter',
return: 'Enter',
esc: 'Escape',
escape: 'Escape',
space: ' ',
tab: 'Tab',
'⇥': 'Tab',
'⌫': 'Backspace',
backspace: 'Backspace',
'↑': 'ArrowUp',
'↓': 'ArrowDown',
'←': 'ArrowLeft',
'→': 'ArrowRight',
};
const SPOKEN: Record<string, string> = {
Meta: 'Command',
Control: 'Control',
Alt: 'Option',
Shift: 'Shift',
Enter: 'Enter',
Escape: 'Escape',
' ': 'Space',
};
const MODIFIERS: readonly string[] = ['Meta', 'Control', 'Alt', 'Shift'];
export function parseKeys(keys: string): Cap[] {
return keys
.trim()
.split(/\s+/)
.filter(Boolean)
.map((legend) => {
const key = ALIAS[legend] ?? ALIAS[legend.toLowerCase()] ?? legend;
const modifier = MODIFIERS.includes(key) ? (key as Modifier) : null;
return { legend, key, modifier, spoken: SPOKEN[key] ?? legend };
});
}
function matches(cap: Cap, e: KeyboardEvent): boolean {
if (cap.key.length === 1) {
const k = cap.key.toLowerCase();
if (e.key.toLowerCase() === k) return true;
// Option and Shift change the character; the physical key is still the same.
if (/^[a-z]$/.test(k) && e.code === `Key${k.toUpperCase()}`) return true;
if (/^[0-9]$/.test(k) && e.code === `Digit${k}`) return true;
return false;
}
return e.key.toLowerCase() === cap.key.toLowerCase();
}
const MOD_FLAG: Record<Modifier, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'> = {
Meta: 'metaKey',
Control: 'ctrlKey',
Alt: 'altKey',
Shift: 'shiftKey',
};
export function Kbd({ keys, listen = true, size = 'md', accent: accentProp, className }: KbdProps) {
const accent = resolveAccent(accentProp);
const caps = useMemo(() => parseKeys(keys), [keys]);
const capRefs = useRef<(HTMLSpanElement | null)[]>([]);
const sim = useRef({
springs: [] as Spring[],
down: [] as boolean[],
reduced: false,
});
if (sim.current.springs.length !== caps.length) {
sim.current.springs = caps.map(() => new Spring(0, SPRING.loose));
sim.current.down = caps.map(() => false);
}
const capsRef = useRef(caps);
capsRef.current = caps;
const loopRef = useRef<ReturnType<typeof createLoop> | null>(null);
const apply = () => {
const s = sim.current;
s.springs.forEach((sp, i) => {
const el = capRefs.current[i];
if (!el) return;
const p = sp.value;
// The face drops by p; the darker bottom edge collapses by the same amount.
el.style.top = Math.abs(p) < 0.001 ? '' : `${p.toFixed(3)}px`;
el.style.setProperty('--em-kbd-drop', `${Math.abs(p) < 0.001 ? 0 : p.toFixed(3)}px`);
const down = s.down[i] ? 'true' : 'false';
if (el.dataset.down !== down) el.dataset.down = down;
});
};
const applyRef = useRef(apply);
applyRef.current = apply;
useEffect(() => {
const s = sim.current;
s.reduced = prefersReducedMotion();
const loop = createLoop((dt) => {
let moving = false;
for (const sp of s.springs) {
// A 1px travel is visually still long before the spring's own threshold; stop there.
if (sp.step(dt) && (Math.abs(sp.value - sp.target) > 0.01 || Math.abs(sp.velocity) > 0.3)) moving = true;
else sp.set(sp.target);
}
applyRef.current();
return moving;
});
loopRef.current = loop;
const unsub = onReducedMotionChange((r) => {
s.reduced = r;
if (r) {
for (const sp of s.springs) sp.set(sp.target);
applyRef.current();
}
});
return () => {
loop.stop();
unsub();
};
}, []);
useEffect(() => {
const s = sim.current;
const setDown = (i: number, down: boolean) => {
if (s.down[i] === down) return;
s.down[i] = down;
const sp = s.springs[i];
sp.config = down ? SPRING.snappy : SPRING.loose;
sp.target = down ? 1 : 0;
if (s.reduced) sp.set(sp.target);
};
const releaseAll = () => {
s.down.forEach((_, i) => setDown(i, false));
applyRef.current();
loopRef.current?.wake();
};
if (!listen) {
releaseAll();
return;
}
const syncModifiers = (e: KeyboardEvent) => {
capsRef.current.forEach((cap, i) => {
if (cap.modifier) setDown(i, e[MOD_FLAG[cap.modifier]]);
});
};
const onDown = (e: KeyboardEvent) => {
capsRef.current.forEach((cap, i) => {
if (!cap.modifier && matches(cap, e)) setDown(i, true);
});
syncModifiers(e);
applyRef.current();
loopRef.current?.wake();
};
const onUp = (e: KeyboardEvent) => {
// While Command is held, macOS sends no keyup for the other keys; letting go of it releases them.
const metaUp = e.key === 'Meta';
capsRef.current.forEach((cap, i) => {
if (!cap.modifier && (metaUp || matches(cap, e))) setDown(i, false);
});
syncModifiers(e);
applyRef.current();
loopRef.current?.wake();
};
const onVisibility = () => {
if (document.visibilityState === 'hidden') releaseAll();
};
window.addEventListener('keydown', onDown);
window.addEventListener('keyup', onUp);
window.addEventListener('blur', releaseAll);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.removeEventListener('keydown', onDown);
window.removeEventListener('keyup', onUp);
window.removeEventListener('blur', releaseAll);
document.removeEventListener('visibilitychange', onVisibility);
releaseAll();
};
}, [listen, caps]);
return (
<span className={`ember em-kbd${className ? ` ${className}` : ''}`} data-accent={accent} data-size={size}>
{caps.map((cap, i) => (
<span key={`${cap.legend}-${i}`} className="em-kbd__slot" aria-hidden="true">
<kbd
ref={(el) => {
capRefs.current[i] = el;
}}
className="em-kbd__cap"
data-down="false"
>
{cap.legend}
</kbd>
</span>
))}
<span className="em-sr">{caps.map((c) => c.spoken).join(' ')}</span>
</span>
);
}
export default Kbd;
Customize
sm
md
Hold a whole combination and every cap in it stays down.
Install
npx shadcn@latest add https://obsydeui.com/r/kbd.jsonUsage
Import it from where the CLI puts it, or copy the files below by hand.
import { Kbd } from '@/components/obsyde/ember/kbd/Kbd';
export function Example() {
return (
<Kbd keys="⌘ K, Shift Enter, Esc" />
);
}
Files
Plain React, TypeScript and CSS. No dependencies. The download keeps these paths.
ember/kbd/Kbd.tsxcomponentember/kbd/kbd.cssstylesember/kbd/KbdDemo.tsxdemoember/shared/accent.tssharedember/shared/spring.tssharedember/shared/ember.cssshared
API
| Prop | Type | Default |
|---|---|---|
accentAccent | 'ember' | 'volt' | 'arc' | 'ember' |
keysKeys | string | '⌘ K, Shift Enter, Esc' |
listenListen | boolean | true |
sizeSize | 'sm' | 'md' | 'md' |