Keycaps for a keyboard shortcut that press down when you hold the real keys, for command menu hints, docs and onboarding.
Try pressing it
Customize
Usage
import { KeyCombo } from "@/components/motionkit/key-combo";
<KeyCombo keys={["⌘", "K"]} onTrigger={() => setOpen(true)} />Motion
The exact curves this component runs, plotted with Motion's own maths.
Key press
A real key is held
Shortcut hints beside a search field or command menu, keyboard reference pages, docs and onboarding tours that teach power-user shortcuts.
Each key is a kbd element styled as a keycap, with a thicker bottom border for depth. With listen on, keydown and keyup listeners on the window check each label: modifiers such as ⌘, Ctrl and Shift read the event's modifier flags, named keys such as Enter and the arrows match event.key, and letters match case-insensitively. Held keys lose their bottom border and drop 2px, so they look pressed. When every key is held at once, onTrigger fires once, and the caps release if the window loses focus.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://motionkitui.com/r/key-combo.jsonnpm install motionSave as components/motionkit/key-combo.tsx.
"use client";
import { useEffect, useRef, useState } from "react";
type KeyComboProps = {
/** The keys, in order, e.g. ["⌘", "K"] or ["Ctrl", "Shift", "P"]. */
keys: string[];
/** Press the keycaps down when the real keys are held. */
listen?: boolean;
/** Called when the whole combination is pressed. */
onTrigger?: () => void;
/** Keycap size. */
size?: "sm" | "md" | "lg";
className?: string;
};
// what each label means on a keyboard event, and how screen readers should say it
const keyMap: Record<string, { match: (event: KeyboardEvent) => boolean; spoken: string }> = {
"⌘": { match: (e) => e.metaKey, spoken: "Command" },
Cmd: { match: (e) => e.metaKey, spoken: "Command" },
Ctrl: { match: (e) => e.ctrlKey, spoken: "Control" },
"⌃": { match: (e) => e.ctrlKey, spoken: "Control" },
Shift: { match: (e) => e.shiftKey, spoken: "Shift" },
"⇧": { match: (e) => e.shiftKey, spoken: "Shift" },
Alt: { match: (e) => e.altKey, spoken: "Alt" },
"⌥": { match: (e) => e.altKey, spoken: "Option" },
};
const named: Record<string, string> = { Enter: "Enter", "↵": "Enter", Esc: "Escape", "⌫": "Backspace", Tab: "Tab", Space: " ", "↑": "ArrowUp", "↓": "ArrowDown", "←": "ArrowLeft", "→": "ArrowRight" };
const sizes = { sm: "min-w-6 h-6 px-1.5 text-[11px]", md: "min-w-8 h-8 px-2 text-xs", lg: "min-w-11 h-11 px-3 text-sm" };
/** Keycaps for a keyboard shortcut. With `listen`, each cap presses down while you hold its real key. */
export function KeyCombo({ keys, listen = true, onTrigger, size = "md", className = "" }: KeyComboProps) {
const [held, setHeld] = useState<boolean[]>(() => keys.map(() => false));
const fired = useRef(false);
useEffect(() => {
if (!listen) return;
const isHeld = (label: string, event: KeyboardEvent) =>
keyMap[label]?.match(event) ?? (named[label] ? event.key === named[label] : event.key.toLowerCase() === label.toLowerCase());
function update(event: KeyboardEvent) {
// a key that's being released no longer counts, even though the event still names it
const next = keys.map((label) => (event.type === "keyup" && !keyMap[label] && isHeld(label, event) ? false : isHeld(label, event)));
setHeld(next);
if (next.every(Boolean) && !fired.current) {
fired.current = true;
onTrigger?.();
}
if (!next.every(Boolean)) fired.current = false;
}
const clear = () => setHeld(keys.map(() => false));
window.addEventListener("keydown", update);
window.addEventListener("keyup", update);
window.addEventListener("blur", clear);
return () => {
window.removeEventListener("keydown", update);
window.removeEventListener("keyup", update);
window.removeEventListener("blur", clear);
};
}, [keys, listen, onTrigger]);
const spoken = keys.map((label) => keyMap[label]?.spoken ?? named[label] ?? label).join(" ");
return (
<kbd aria-label={spoken} className={`inline-flex items-center gap-1 font-sans ${className}`}>
{keys.map((label, index) => (
<kbd
key={`${label}-${index}`}
aria-hidden
className={`inline-flex items-center justify-center rounded-md border border-white/15 bg-[#1D2727] font-medium text-[#DCE3E2] transition-all duration-75 ${sizes[size]} ${
held[index] ? "translate-y-[2px] border-b border-[#5FAEB4]/60 text-[#F6F1E8] shadow-none" : "border-b-[3px] shadow-[0_2px_0_rgb(0_0_0/0.4)]"
}`}
>
{label}
</kbd>
))}
</kbd>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| keys | string[] | — | The keys in order, e.g. ["⌘", "K"] or ["Ctrl", "Shift", "P"]. |
| listen | boolean | true | Press the keycaps when the real keys are held. |
| onTrigger | () => void | — | Called when the whole combination is pressed. |
| size | "sm" | "md" | "lg" | "md" | Keycap size. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Nothing beyond React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://motionkitui.com/r/key-combo.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
The press is a 75ms, 2px shift that mirrors your own key press, so it isn't motion you didn't cause.
It shows exactly the keys you pass. To switch by platform, check navigator.platform or userAgentData once on the client and pass ["⌘", "K"] or ["Ctrl", "K"].
Liquid Toggle
A switch whose knob stretches like liquid on its way across, then settles.
Code Input
One-time-code boxes where each digit pops in, with paste and phone autofill built in.
Elastic Slider
A range slider with give: the value bubble leans as you drag, and pulling past the ends stretches the track like rubber.