Keyboard Hint Tooltip
Original · freekeyboard shortcut hint tooltips
byWeb InnoventixReact + Tailwind
tipkeyboardhinttooltips
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/tip-keyboard-hint.jsontip-keyboard-hint.tsx
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type ActionId = "bold" | "italic" | "underline" | "strike" | "save" | "undo";
type ToggleId = "bold" | "italic" | "underline" | "strike";
interface Format {
bold: boolean;
italic: boolean;
underline: boolean;
strike: boolean;
}
interface Action {
id: ActionId;
label: string;
keys: string[];
toggle: boolean;
}
const ACTIONS: Action[] = [
{ id: "bold", label: "Bold", keys: ["mod", "B"], toggle: true },
{ id: "italic", label: "Italic", keys: ["mod", "I"], toggle: true },
{ id: "underline", label: "Underline", keys: ["mod", "U"], toggle: true },
{ id: "strike", label: "Strikethrough", keys: ["mod", "shift", "X"], toggle: true },
{ id: "save", label: "Save draft", keys: ["mod", "S"], toggle: false },
{ id: "undo", label: "Undo", keys: ["mod", "Z"], toggle: false },
];
function displayKey(token: string, mac: boolean): string {
if (token === "mod") return mac ? "⌘" : "Ctrl";
if (token === "shift") return mac ? "⇧" : "Shift";
if (token === "alt") return mac ? "⌥" : "Alt";
return token.toUpperCase();
}
function ariaKeyshortcuts(action: Action, mac: boolean): string {
return action.keys
.map((k) => {
if (k === "mod") return mac ? "Meta" : "Control";
if (k === "shift") return "Shift";
if (k === "alt") return "Alt";
return k.toUpperCase();
})
.join("+");
}
function Icon({ id }: { id: ActionId }) {
const common = {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
switch (id) {
case "bold":
return (
<svg {...common}>
<path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" />
</svg>
);
case "italic":
return (
<svg {...common}>
<path d="M19 5h-8M13 19H5M15 5 9 19" />
</svg>
);
case "underline":
return (
<svg {...common}>
<path d="M7 4v6a5 5 0 0 0 10 0V4M5 21h14" />
</svg>
);
case "strike":
return (
<svg {...common}>
<path d="M4 12h16M8 7a3.5 3.5 0 0 1 3.5-3h1A3.5 3.5 0 0 1 16 7M8 17a3.5 3.5 0 0 0 3.5 3h1a3.5 3.5 0 0 0 3.5-3" />
</svg>
);
case "save":
return (
<svg {...common}>
<path d="M5 3h11l3 3v15H5zM8 3v6h7M8 21v-6h8v6" />
</svg>
);
case "undo":
return (
<svg {...common}>
<path d="M9 7 4 12l5 5M4 12h11a5 5 0 0 1 0 10h-1" />
</svg>
);
default:
return <svg {...common} />;
}
}
function Keys({
tokens,
mac,
variant = "surface",
}: {
tokens: string[];
mac: boolean;
variant?: "surface" | "onDark";
}) {
const base =
"inline-flex h-5 min-w-[1.35rem] items-center justify-center rounded-[6px] px-1.5 font-mono text-[11px] font-semibold leading-none";
const tone =
variant === "onDark"
? "border border-white/25 bg-white/10 text-white"
: "border border-zinc-300 bg-zinc-50 text-zinc-600 shadow-[0_1px_0_rgba(0,0,0,0.05)] dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300";
return (
<span className="inline-flex items-center gap-1">
{tokens.map((t, i) => (
<kbd key={i} className={`${base} ${tone}`}>
{displayKey(t, mac)}
</kbd>
))}
</span>
);
}
function Switch({
id,
checked,
onToggle,
label,
}: {
id: string;
checked: boolean;
onToggle: () => void;
label: string;
}) {
return (
<button
type="button"
id={id}
role="switch"
aria-checked={checked}
onClick={onToggle}
className="group inline-flex items-center gap-2.5 rounded-lg px-1 py-1 text-sm font-medium text-zinc-700 outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-200 dark:focus-visible:ring-offset-zinc-900"
>
<span
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 ${
checked
? "bg-emerald-500"
: "bg-zinc-300 dark:bg-zinc-700"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? "translate-x-4" : "translate-x-0.5"
}`}
/>
</span>
<span>{label}</span>
</button>
);
}
export default function KeyboardHintTooltips() {
const uid = useId();
const prefersReduced = useReducedMotion();
const reduce = prefersReduced === true;
const [isMac, setIsMac] = useState(false);
const [format, setFormat] = useState<Format>({
bold: true,
italic: false,
underline: false,
strike: false,
});
const [openTip, setOpenTip] = useState<ActionId | null>(null);
const [focusIndex, setFocusIndex] = useState(0);
const [flashId, setFlashId] = useState<ActionId | null>(null);
const [shortcutsOn, setShortcutsOn] = useState(true);
const [pinHints, setPinHints] = useState(false);
const [savedCount, setSavedCount] = useState(0);
const [toast, setToast] = useState("");
const [announcement, setAnnouncement] = useState("");
const formatRef = useRef<Format>(format);
const historyRef = useRef<Format[]>([]);
const countRef = useRef(0);
const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
formatRef.current = format;
}, [format]);
useEffect(() => {
const src =
typeof navigator !== "undefined"
? navigator.platform || navigator.userAgent
: "";
setIsMac(/Mac|iPhone|iPad|iPod/i.test(src));
}, []);
const runAction = useCallback((id: ActionId) => {
setFlashId(id);
if (id === "save") {
countRef.current += 1;
const n = countRef.current;
setSavedCount(n);
setToast(`Draft saved · ${n} version${n > 1 ? "s" : ""}`);
setAnnouncement(`Draft saved, version ${n}`);
return;
}
if (id === "undo") {
const last = historyRef.current.pop();
if (last) {
setFormat(last);
setAnnouncement("Undid last formatting change");
} else {
setAnnouncement("Nothing to undo");
}
return;
}
const key = id as ToggleId;
const current = formatRef.current;
historyRef.current.push(current);
const nextVal = !current[key];
setFormat({ ...current, [key]: nextVal });
const label = ACTIONS.find((a) => a.id === id)?.label ?? id;
setAnnouncement(`${label} ${nextVal ? "enabled" : "disabled"}`);
}, []);
// Global keyboard shortcuts.
useEffect(() => {
if (!shortcutsOn) return;
function onKey(e: globalThis.KeyboardEvent) {
const modActive = isMac ? e.metaKey : e.ctrlKey;
if (!modActive) return;
const pressed = e.key.toLowerCase();
const match = ACTIONS.find((a) => {
const letter = a.keys[a.keys.length - 1].toLowerCase();
const needShift = a.keys.includes("shift");
return pressed === letter && e.shiftKey === needShift && !e.altKey;
});
if (match) {
e.preventDefault();
runAction(match.id);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [shortcutsOn, isMac, runAction]);
// Clear the flash ring.
useEffect(() => {
if (!flashId) return;
const t = window.setTimeout(() => setFlashId(null), 560);
return () => window.clearTimeout(t);
}, [flashId]);
// Auto-dismiss the save toast.
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(""), 1900);
return () => window.clearTimeout(t);
}, [toast]);
function onToolbarKeyDown(e: ReactKeyboardEvent<HTMLDivElement>) {
const count = ACTIONS.length;
let next = focusIndex;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
next = (focusIndex + 1) % count;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
next = (focusIndex - 1 + count) % count;
} else if (e.key === "Home") {
next = 0;
} else if (e.key === "End") {
next = count - 1;
} else if (e.key === "Escape") {
setOpenTip(null);
return;
} else {
return;
}
e.preventDefault();
setFocusIndex(next);
btnRefs.current[next]?.focus();
}
const tipTransition = reduce
? { duration: 0 }
: { duration: 0.15, ease: "easeOut" as const };
return (
<section className="relative w-full bg-gradient-to-b from-zinc-50 to-zinc-100 px-4 py-20 text-zinc-900 dark:from-zinc-950 dark:to-zinc-900 dark:text-zinc-50 sm:px-6 sm:py-24">
<style>{`
.tkh-flash {
animation: tkh-ping 0.56s ease-out forwards;
}
@keyframes tkh-ping {
0% { transform: scale(1); opacity: 0.55; }
100% { transform: scale(1.35); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.tkh-flash { animation: none; opacity: 0; }
}
`}</style>
<div className="mx-auto max-w-3xl">
<header className="mb-10 text-center">
<p className="mb-3 inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
Tooltips
<span aria-hidden className="text-indigo-400">
/
</span>
Keyboard hints
</p>
<h2 className="text-balance text-3xl font-bold tracking-tight sm:text-4xl">
Every control tells you its shortcut
</h2>
<p className="mx-auto mt-3 max-w-xl text-pretty text-sm text-zinc-600 dark:text-zinc-400 sm:text-base">
Hover or focus a button to reveal its keyboard shortcut, then press
the keys — the action genuinely runs. Move through the toolbar
with the arrow keys.
</p>
</header>
<div className="relative rounded-2xl border border-zinc-200 bg-white/90 p-5 shadow-[0_18px_40px_-24px_rgba(24,24,27,0.5)] backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 sm:p-7">
{/* Save toast */}
<AnimatePresence>
{toast && (
<motion.output
key={toast}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={tipTransition}
className="absolute right-4 top-4 z-30 inline-flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-1.5 text-xs font-semibold text-emerald-700 shadow-sm dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300"
>
<span
aria-hidden
className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500"
/>
{toast}
</motion.output>
)}
</AnimatePresence>
{/* Toolbar */}
<div
role="toolbar"
aria-label="Text formatting and document actions"
aria-orientation="horizontal"
onKeyDown={onToolbarKeyDown}
className="flex flex-wrap items-start gap-2"
>
{ACTIONS.map((action, i) => {
const active =
action.toggle && format[action.id as ToggleId];
const tipId = `${uid}-tip-${action.id}`;
const showTip = openTip === action.id;
return (
<div key={action.id} className="relative flex flex-col items-center">
<button
type="button"
ref={(el) => {
btnRefs.current[i] = el;
}}
tabIndex={i === focusIndex ? 0 : -1}
aria-pressed={action.toggle ? active : undefined}
aria-label={action.label}
aria-keyshortcuts={ariaKeyshortcuts(action, isMac)}
aria-describedby={tipId}
onClick={() => runAction(action.id)}
onMouseEnter={() => setOpenTip(action.id)}
onMouseLeave={() =>
setOpenTip((cur) => (cur === action.id ? null : cur))
}
onFocus={() => {
setOpenTip(action.id);
setFocusIndex(i);
}}
onBlur={() =>
setOpenTip((cur) => (cur === action.id ? null : cur))
}
className={`relative grid h-11 w-11 place-items-center rounded-xl border transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
active
? "border-indigo-600 bg-indigo-600 text-white shadow-sm dark:border-indigo-500 dark:bg-indigo-500"
: "border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-white"
}`}
>
<Icon id={action.id} />
{flashId === action.id && (
<span
aria-hidden
className="tkh-flash pointer-events-none absolute inset-0 rounded-xl ring-2 ring-indigo-400 dark:ring-indigo-300"
/>
)}
</button>
{pinHints && (
<div className="mt-1.5">
<Keys tokens={action.keys} mac={isMac} variant="surface" />
</div>
)}
<AnimatePresence>
{showTip && (
<motion.div
id={tipId}
role="tooltip"
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 4, scale: 0.96 }
}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: 4, scale: 0.96 }
}
transition={tipTransition}
className="absolute left-1/2 top-[calc(100%+0.55rem)] z-20 -translate-x-1/2 whitespace-nowrap rounded-lg bg-zinc-900 px-2.5 py-1.5 text-white shadow-lg ring-1 ring-black/5 dark:bg-zinc-800 dark:ring-white/10"
>
<span
aria-hidden
className="absolute -top-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 rounded-[1px] bg-zinc-900 dark:bg-zinc-800"
/>
<span className="relative flex items-center gap-2">
<span className="text-xs font-medium">
{action.label}
</span>
<Keys
tokens={action.keys}
mac={isMac}
variant="onDark"
/>
</span>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
{/* Preview */}
<div className="mt-6">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
Preview
</p>
<div className="rounded-xl border border-dashed border-zinc-300 bg-zinc-50/70 p-4 dark:border-zinc-700 dark:bg-zinc-950/40">
<p
className="text-lg text-zinc-800 dark:text-zinc-100 sm:text-xl"
style={{
fontWeight: format.bold ? 700 : 400,
fontStyle: format.italic ? "italic" : "normal",
textDecorationLine:
[
format.underline ? "underline" : "",
format.strike ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || "none",
}}
>
Ship the tooltip, then press a shortcut to watch it fire.
</p>
</div>
</div>
{/* Controls */}
<div className="mt-6 flex flex-col gap-4 border-t border-zinc-200 pt-5 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-6">
<Switch
id={`${uid}-sw-active`}
checked={shortcutsOn}
onToggle={() => setShortcutsOn((v) => !v)}
label="Shortcuts active"
/>
<Switch
id={`${uid}-sw-pin`}
checked={pinHints}
onToggle={() => setPinHints((v) => !v)}
label="Pin key hints"
/>
</div>
<div className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-full bg-indigo-500"
/>
<span>
Saved:{" "}
<span className="font-semibold text-zinc-900 dark:text-zinc-100 tabular-nums">
{savedCount}
</span>
</span>
</div>
</div>
<p className="mt-4 flex flex-wrap items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-500">
<span>Try it:</span>
<Keys tokens={["mod", "B"]} mac={isMac} variant="surface" />
<span>toggles bold,</span>
<Keys tokens={["mod", "S"]} mac={isMac} variant="surface" />
<span>saves,</span>
<Keys tokens={["mod", "Z"]} mac={isMac} variant="surface" />
<span>undoes.</span>
</p>
</div>
<p className="mt-6 text-center text-xs text-zinc-500 dark:text-zinc-500">
Shortcuts adapt to your platform — {isMac ? "⌘ Command" : "Ctrl"} on{" "}
{isMac ? "macOS" : "your OS"}.
</p>
{/* Screen-reader live region */}
<p aria-live="polite" className="sr-only">
{announcement}
</p>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Tooltip
Originalbasic tooltips on hover

Arrow Tooltip
Originaltooltips with an arrow

Directions Tooltip
Originaltooltips in four directions

Rich Tooltip
Originalrich tooltip with title and body

Follow Cursor Tooltip
Originaltooltip that follows the cursor

Popover Tooltip
Originalclick popover with content

Popover Form Tooltip
Originalpopover containing a mini form

Hover Card Tooltip
Originalprofile hover card

Click Tooltip
Originalclick-to-reveal tooltip

Color Tooltip
Originalcoloured semantic tooltips

Glass Tooltip
Originalglassmorphic tooltips

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

