A searchable keyboard-shortcuts reference overlay in a dialog, grouped by section with physical keycaps and chord sequences, openable via the ? hotkey.
npx shadcn@latest add @paragon/shortcut-sheetAlso installs: dialog, kbd
"use client";
import * as React from "react";
import { Search } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/registry/paragon/ui/dialog";
import { Kbd } from "@/registry/paragon/ui/kbd";
import { cn } from "@/lib/utils";
export interface Shortcut {
label: string;
/** Keys as tokens — glyphs (⌘, ⇧, ↵) or letters. Rendered as keycaps. */
keys: string[];
/** Optional "then" chord: a second key group. */
then?: string[];
}
export interface ShortcutGroup {
title: string;
shortcuts: Shortcut[];
}
export interface ShortcutSheetProps {
groups?: ShortcutGroup[];
/** Trigger content; defaults to a labelled button. */
children?: React.ReactNode;
/** Register the ? hotkey to open the sheet. Defaults on. */
hotkey?: boolean;
}
const DEFAULT_GROUPS: ShortcutGroup[] = [
{
title: "General",
shortcuts: [
{ label: "Open command menu", keys: ["⌘", "K"] },
{ label: "Search", keys: ["/"] },
{ label: "Toggle sidebar", keys: ["⌘", "\\"] },
{ label: "Show shortcuts", keys: ["?"] },
],
},
{
title: "Navigation",
shortcuts: [
{ label: "Go to inbox", keys: ["G"], then: ["I"] },
{ label: "Go to my issues", keys: ["G"], then: ["M"] },
{ label: "Go to dashboard", keys: ["G"], then: ["D"] },
{ label: "Next / previous item", keys: ["J"], then: ["K"] },
],
},
{
title: "Issues",
shortcuts: [
{ label: "Create issue", keys: ["C"] },
{ label: "Assign to me", keys: ["I"] },
{ label: "Set priority", keys: ["P"] },
{ label: "Change status", keys: ["S"] },
{ label: "Add label", keys: ["L"] },
],
},
{
title: "Editing",
shortcuts: [
{ label: "Bold", keys: ["⌘", "B"] },
{ label: "Italic", keys: ["⌘", "I"] },
{ label: "Insert block", keys: ["/"] },
{ label: "Save", keys: ["⌘", "↵"] },
],
},
];
function KeyGroup({ keys }: { keys: string[] }) {
return (
<span className="inline-flex items-center gap-1">
{keys.map((key, i) => (
<Kbd key={i}>{key}</Kbd>
))}
</span>
);
}
/**
* A keyboard-shortcuts reference overlay. Opens from its trigger (or the "?"
* hotkey) into a centered dialog with a live search that filters shortcuts by
* label and key across grouped sections. Empty groups drop out as you type.
* Keys render as physical keycaps via the `kbd` primitive, with "then" chords
* shown for sequences. Focus lands in the search field on open; Escape and
* outside-click dismiss (inherited from the dialog).
*/
export function ShortcutSheet({
groups = DEFAULT_GROUPS,
children,
hotkey = true,
}: ShortcutSheetProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (!hotkey) return;
const onKey = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const typing =
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable);
if (event.key === "?" && !typing && !event.metaKey && !event.ctrlKey) {
event.preventDefault();
setOpen(true);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [hotkey]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return groups;
return groups
.map((group) => ({
...group,
shortcuts: group.shortcuts.filter((shortcut) =>
[shortcut.label, ...shortcut.keys, ...(shortcut.then ?? [])]
.join(" ")
.toLowerCase()
.includes(q),
),
}))
.filter((group) => group.shortcuts.length > 0);
}, [groups, query]);
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery("");
}}
>
<DialogTrigger asChild>
{children ?? (
<button
type="button"
className={cn(
"pressable inline-flex items-center gap-2 rounded-lg bg-card px-3 py-2 text-sm shadow-border outline-none",
"transition-[box-shadow] duration-(--duration-fast) hover:shadow-border-hover",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
Keyboard shortcuts
<Kbd static>?</Kbd>
</button>
)}
</DialogTrigger>
<DialogContent
showClose
className="max-w-lg gap-0 p-0"
onOpenAutoFocus={(event) => {
event.preventDefault();
inputRef.current?.focus();
}}
>
<DialogTitle className="px-4 pt-4 pb-3 text-sm">
Keyboard shortcuts
</DialogTitle>
<div className="border-y border-border px-4 py-2">
<div className="flex items-center gap-2 text-muted-foreground">
<Search className="size-4 shrink-0" aria-hidden />
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search shortcuts…"
aria-label="Search shortcuts"
className="h-7 w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="max-h-[60vh] overflow-y-auto p-4 [scrollbar-width:thin]">
{filtered.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No shortcuts match “{query}”
</p>
) : (
<div className="grid gap-x-8 gap-y-5 sm:grid-cols-2">
{filtered.map((group) => (
<section key={group.title}>
<h3 className="mb-2 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
{group.title}
</h3>
<ul className="space-y-1.5">
{group.shortcuts.map((shortcut) => (
<li
key={shortcut.label}
className="flex items-center justify-between gap-3"
>
<span className="text-sm">{shortcut.label}</span>
<span className="flex shrink-0 items-center gap-1.5">
<KeyGroup keys={shortcut.keys} />
{shortcut.then && (
<>
<span className="text-[10px] text-muted-foreground/70">
then
</span>
<KeyGroup keys={shortcut.then} />
</>
)}
</span>
</li>
))}
</ul>
</section>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}