Web InnoventixFreeCode

Keyboard Hint Tooltip

Original · free

keyboard shortcut hint tooltips

byWeb InnoventixReact + Tailwind
tipkeyboardhinttooltips
Open

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.json
tip-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 &mdash; 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 &mdash; {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 quote

Similar components

Browse all →