SaaS & Ops

Shortcut Sheet

A searchable keyboard-shortcuts reference overlay in a dialog, grouped by section with physical keycaps and chord sequences, openable via the ? hotkey.

Install

npx shadcn@latest add @paragon/shortcut-sheet

Also installs: dialog, kbd

shortcut-sheet.tsx

"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>
  );
}