A searchable shortcut cheat sheet, inline or in a dialog opened by pressing ?, with Command or Ctrl keys chosen for the viewer's platform and spoken key names.
General
- Open command menuCtrl plus K
- Show shortcuts?
- Toggle sidebarCtrl plus B
- SaveCtrl plus S
Navigation
- Go to inboxG then I
- Go to projectsG then P
- Next itemJ
- Previous itemK
Editing
- BoldCtrl plus B
- Move line upAlt plus Up arrow
- Duplicate lineShift plus Alt plus Down arrow
- UndoCtrl plus Z
import { KeyboardShortcuts } from "@/components/ballmac/keyboard-shortcuts"
export default function KeyboardShortcutsDemo() {
return (
<div className="w-full max-w-2xl rounded-xl border bg-card p-5">
<KeyboardShortcuts
groups={[
{
title: "General",
shortcuts: [
{ label: "Open command menu", keys: ["Mod", "K"] },
{ label: "Show shortcuts", keys: ["?"] },
{ label: "Toggle sidebar", keys: ["Mod", "B"] },
{ label: "Save", keys: ["Mod", "S"] },
],
},
{
title: "Navigation",
shortcuts: [
{ label: "Go to inbox", keys: ["G", "I"], sequence: true },
{ label: "Go to projects", keys: ["G", "P"], sequence: true },
{ label: "Next item", keys: ["J"] },
{ label: "Previous item", keys: ["K"] },
],
},
{
title: "Editing",
shortcuts: [
{ label: "Bold", keys: ["Mod", "B"] },
{ label: "Move line up", keys: ["Alt", "Up"] },
{ label: "Duplicate line", keys: ["Shift", "Alt", "Down"] },
{ label: "Undo", keys: ["Mod", "Z"] },
],
},
]}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/keyboard-shortcutsInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/dialog @ballmac/kbd @ballmac/i18nCopy the source into your project.
components/ballmac/keyboard-shortcuts.tsx// Ballmac UI: Keyboard Shortcuts. https://ui.ballmac.com/components/keyboard-shortcuts "use client" import * as React from "react" import { Keyboard, Search } from "lucide-react" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ballmac/dialog" import { Kbd, KbdGroup } from "@/components/ballmac/kbd" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type ShortcutPlatform = "mac" | "other" type Shortcut = { /** What the shortcut does. */ label: string /** * The keys. Use "Mod" for Command on Mac and Ctrl elsewhere, plus "Alt", "Shift", "Enter", "Esc", * "Up", "Down", "Left", "Right", "Backspace", "Tab" and "Space". Anything else is shown as typed. */ keys: string[] /** Keys are pressed one after another ("G" then "I") instead of together. */ sequence?: boolean /** A second line of detail. */ description?: string } type ShortcutGroup = { /** Group heading, such as "Navigation". */ title: string shortcuts: Shortcut[] } const MAC: Record<string, string> = { Mod: "⌘", Alt: "⌥", Shift: "⇧", Ctrl: "⌃", Enter: "↵", Esc: "Esc", Up: "↑", Down: "↓", Left: "←", Right: "→", Backspace: "⌫", Tab: "⇥", Space: "Space" } const OTHER: Record<string, string> = { ...MAC, Mod: "Ctrl", Alt: "Alt", Shift: "Shift", Ctrl: "Ctrl", Enter: "Enter", Backspace: "Backspace", Tab: "Tab" } const SPOKEN: Record<string, string> = { "⌘": "Command", "⌥": "Option", "⇧": "Shift", "⌃": "Control", "↵": "Enter", "↑": "Up arrow", "↓": "Down arrow", "←": "Left arrow", "→": "Right arrow", "⌫": "Backspace", "⇥": "Tab" } const noop = () => () => {} function usePlatform(platform: ShortcutPlatform | "auto"): ShortcutPlatform { // The server cannot know the platform, so it renders the non-Mac keys and corrects after hydration. const detected = React.useSyncExternalStore( noop, () => (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? "mac" : "other"), () => "other" as ShortcutPlatform ) return platform === "auto" ? detected : platform } function label(key: string, platform: ShortcutPlatform) { return (platform === "mac" ? MAC : OTHER)[key] ?? (key.length === 1 ? key.toUpperCase() : key) } function ShortcutKeys({ shortcut, platform }: { shortcut: Shortcut; platform: ShortcutPlatform }) { const shown = shortcut.keys.map((k) => label(k, platform)) const spoken = shown.map((k) => SPOKEN[k] ?? k).join(shortcut.sequence ? " then " : " plus ") return ( <span className="flex shrink-0 items-center gap-1.5"> <span className="sr-only">{spoken}</span> <span aria-hidden="true" className="flex items-center gap-1.5"> {shortcut.sequence ? ( shown.map((k, i) => ( <React.Fragment key={i}> {i > 0 && <span className="text-[11px] text-muted-foreground">then</span>} <Kbd>{k}</Kbd> </React.Fragment> )) ) : ( <KbdGroup> {shown.map((k, i) => ( <Kbd key={i}>{k}</Kbd> ))} </KbdGroup> )} </span> </span> ) } type KeyboardShortcutsProps = Omit<React.ComponentProps<"div">, "children"> & { /** Shortcuts, grouped. */ groups: ShortcutGroup[] /** Show a search box that filters by action or key. */ searchable?: boolean /** Which key labels to draw. "auto" picks Mac or other after hydration. */ platform?: ShortcutPlatform | "auto" /** Placeholder of the search box. */ searchPlaceholder?: string /** Number of columns on wide containers. */ columns?: 1 | 2 } function KeyboardShortcuts({ groups, searchable = true, platform: platformProp = "auto", searchPlaceholder, columns = 2, className, ...props }: KeyboardShortcutsProps) { const msg = useMessages() searchPlaceholder ??= msg("keyboard-shortcuts.searchPlaceholder", "Search shortcuts") const platform = usePlatform(platformProp) const [query, setQuery] = React.useState("") const q = query.trim().toLowerCase() const filtered = groups .map((g) => ({ ...g, shortcuts: g.shortcuts.filter( (s) => !q || s.label.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q) || s.keys.some((k) => label(k, platform).toLowerCase().includes(q) || k.toLowerCase().includes(q)) ), })) .filter((g) => g.shortcuts.length > 0) const count = filtered.reduce((n, g) => n + g.shortcuts.length, 0) return ( <div data-slot="keyboard-shortcuts" className={cn("@container w-full", className)} {...props}> {searchable && ( <div className="relative mb-4"> <Search aria-hidden="true" className="pointer-events-none absolute top-1/2 start-3 size-4 -translate-y-1/2 text-muted-foreground" /> <input type="search" value={query} onChange={(e) => setQuery(e.target.value)} aria-label={searchPlaceholder} placeholder={searchPlaceholder} className="h-10 w-full rounded-lg border bg-background pe-3 ps-9 text-sm outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" /> <span className="sr-only" role="status" aria-live="polite"> {q ? `${count} ${count === 1 ? "shortcut" : "shortcuts"} found` : ""} </span> </div> )} {filtered.length === 0 ? ( <p className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground"> {msg("keyboard-shortcuts.noShortcutMatches", "No shortcut matches “{query}”.", { query })} </p> ) : ( <div className={cn("grid gap-x-10 gap-y-6", columns === 2 && "@xl:grid-cols-2")}> {filtered.map((group) => ( <section key={group.title} aria-label={group.title} className="min-w-0"> <h3 className="mb-1 text-xs font-semibold tracking-wide text-muted-foreground uppercase">{group.title}</h3> <ul className="divide-y"> {group.shortcuts.map((s) => ( <li key={s.label} className="flex min-h-10 items-center justify-between gap-4 py-1.5"> <span className="min-w-0"> <span className="block text-sm text-foreground">{s.label}</span> {s.description && <span className="block text-xs text-muted-foreground">{s.description}</span>} </span> <ShortcutKeys shortcut={s} platform={platform} /> </li> ))} </ul> </section> ))} </div> )} </div> ) } type KeyboardShortcutsDialogProps = Omit<KeyboardShortcutsProps, "className"> & { /** Controlled open state. */ open?: boolean /** Initial open state. */ defaultOpen?: boolean /** Called when the dialog opens or closes. */ onOpenChange?: (open: boolean) => void /** Key that opens the dialog from anywhere outside a text field. Set to `null` to turn it off. */ hotkey?: string | null /** Dialog heading. */ title?: string /** Line under the heading. */ description?: string /** Classes for the dialog panel. */ className?: string } /** The cheat sheet in a dialog, opened by pressing "?" (or another `hotkey`) anywhere on the page. */ function KeyboardShortcutsDialog({ open: openProp, defaultOpen = false, onOpenChange, hotkey = "?", title, description, className, ...props }: KeyboardShortcutsDialogProps) { const msg = useMessages() title ??= msg("keyboard-shortcuts.title", "Keyboard shortcuts") description ??= msg("keyboard-shortcuts.description", "Work faster without leaving the keyboard.") const [internal, setInternal] = React.useState(defaultOpen) const open = openProp ?? internal const onOpenChangeRef = React.useRef(onOpenChange) React.useEffect(() => { onOpenChangeRef.current = onOpenChange }) const setOpen = React.useCallback((next: boolean) => { setInternal(next) onOpenChangeRef.current?.(next) }, []) React.useEffect(() => { if (!hotkey) return function onKey(event: KeyboardEvent) { if (event.key !== hotkey || event.metaKey || event.ctrlKey || event.altKey) return const el = event.target as HTMLElement | null if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return event.preventDefault() setOpen(!open) } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) }, [hotkey, open, setOpen]) return ( <Dialog open={open} onOpenChange={setOpen}> <DialogContent className={cn("max-h-[85dvh] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-3xl sm:p-0", className)}> <DialogHeader className="border-b px-5 py-4 pe-12"> <DialogTitle className="flex items-center gap-2"> <Keyboard aria-hidden="true" className="size-4 text-muted-foreground" /> {title} </DialogTitle> <DialogDescription>{description}</DialogDescription> </DialogHeader> <div className="overflow-y-auto p-5"> <KeyboardShortcuts {...props} /> </div> {hotkey && ( <p className="border-t bg-muted/40 px-5 py-2.5 text-xs text-muted-foreground"> {msg.rich("keyboard-shortcuts.press", "Press {key} anywhere to open this list.", { key: <Kbd size="sm">{hotkey}</Kbd> })} </p> )} </DialogContent> </Dialog> ) } export { KeyboardShortcuts, KeyboardShortcutsDialog, type KeyboardShortcutsProps, type KeyboardShortcutsDialogProps, type Shortcut, type ShortcutGroup, type ShortcutPlatform, }Update the import paths to match your project setup.
Usage
import { KeyboardShortcuts, KeyboardShortcutsDialog } from "@/components/ballmac/keyboard-shortcuts"The full example is in the Code tab above.
Examples
Dialog opened with ?
"use client"
import * as React from "react"
import { Kbd } from "@/components/ballmac/kbd"
import { KeyboardShortcutsDialog } from "@/components/ballmac/keyboard-shortcuts"
export default function KeyboardShortcutsDialogExample() {
const [open, setOpen] = React.useState(false)
return (
<div className="grid justify-items-center gap-3">
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-9 items-center gap-2 rounded-md border bg-background px-4 text-sm font-medium shadow-xs outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Keyboard shortcuts
<Kbd>?</Kbd>
</button>
<KeyboardShortcutsDialog
open={open}
onOpenChange={setOpen}
groups={[
{ title: "General", shortcuts: [{ label: "Open command menu", keys: ["Mod", "K"] }, { label: "Show shortcuts", keys: ["?"] }, { label: "Save", keys: ["Mod", "S"] }] },
{ title: "Navigation", shortcuts: [{ label: "Go to inbox", keys: ["G", "I"], sequence: true }, { label: "Go to settings", keys: ["G", "S"], sequence: true }, { label: "Close panel", keys: ["Esc"] }] },
]}
/>
</div>
)
}API reference
<KeyboardShortcuts>
| Prop | Type | Default |
|---|---|---|
groups*Shortcuts, grouped. | ShortcutGroup[] | — |
searchableShow a search box that filters by action or key. | boolean | true |
platformWhich key labels to draw. "auto" picks Mac or other after hydration. | ShortcutPlatform | "auto" | — |
searchPlaceholderPlaceholder of the search box. | string | — |
columnsNumber of columns on wide containers. | 1 | 2 | 2 |
<KeyboardShortcutsDialog>
| Prop | Type | Default |
|---|---|---|
openControlled open state. | boolean | — |
defaultOpenInitial open state. | boolean | false |
onOpenChangeCalled when the dialog opens or closes. | (open: boolean) => void | — |
hotkeyKey that opens the dialog from anywhere outside a text field. Set to `null` to turn it off. | string | null | "?" |
titleDialog heading. | string | — |
descriptionLine under the heading. | string | — |
classNameClasses for the dialog panel. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| ? | Opens the dialog when focus is not in a text field |
| Escape | Closes the dialog |
| Screen readers | Keys read as 'Command plus K', sequences as 'G then I'; the filtered count is announced |
Use with AI
groups is [{ title, shortcuts: [{ label, keys, sequence?, description? }] }]. Use 'Mod' for Cmd on Mac and Ctrl elsewhere. <KeyboardShortcutsDialog hotkey='?'> opens from anywhere outside a text field. platform='auto' corrects after hydration. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Keyboard Shortcuts (@ballmac/keyboard-shortcuts) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A help overlay for apps with many shortcuts
- Docs pages that list hotkeys
Not for
- A single hint beside a button (shortcut-hint)
- Binding the shortcuts themselves; this only displays them
Registry JSON: https://ui.ballmac.com/r/keyboard-shortcuts.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Kbd
Keyboard key and shortcut display built on the semantic <kbd> element, in three sizes, with KbdGroup for combinations like ⌘ ⇧ K. Adapts inside tooltips.
Shortcut Hint
A compact accessible keycap sequence paired with the action it performs.
Command Bar
A command palette with pages: a search-field style trigger, grouped commands, drill-in pages with a breadcrumb, Backspace to go back and a footer of key hints.
Dialog
A Radix modal dialog with a blurred overlay, centered card surface that fits 360 px screens, header and footer parts, optional close button and animations.