Feedback

Shortcuts Dialog

A grouped keyboard cheat sheet on the ? key — platform-correct glyphs, live filtering, focus trap and a body-counted scroll lock.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { Search, X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * The entry animation ships with the component: React 19 hoists <style href> into
 * head and dedupes by href, so several sheets on one page share one keyframes copy.
 */
const KEYFRAMES = `@keyframes zg-shortcuts-overlay-in{from{opacity:0}to{opacity:1}}
@keyframes zg-shortcuts-panel-in{from{opacity:0;transform:translateY(-8px) scale(0.98)}to{opacity:1;transform:none}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/shortcuts-dialog.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ShortcutsDialog" component (lucide-react
for the search and close icons, react-dom's createPortal, no Radix — it must be
a single self-contained file a buyer can drop in).

Contract
- Exports: ShortcutsDialog (named + default) and the types Shortcut,
  ShortcutGroup, ShortcutsDialogProps.
- Shortcut: keys: string[] (pressed together, in press order, e.g.
  ["mod","shift","p"]); description: string; platform?: "mac" | "win" | "all"
  (default "all" — a row scoped to a platform only appears there).
- ShortcutGroup: heading: string; shortcuts: Shortcut[].
- Props: groups: ShortcutGroup[]; open: boolean; onOpenChange: (open: boolean)
  => void; bindTrigger?: boolean (default true — listen on window for "?");
  search?: boolean (default true — render the filter row); platform?: "auto" |
  "mac" | "win" (default "auto"); footer?: ReactNode (replaces the default hint
  row); className merged onto the panel with cn().
- Fully controlled: the component never keeps its own open state. It renders
  null when closed and also on the server / first client render, because
  createPortal needs a document — that also keeps it out of hydration.
- Rows are a read-only reference. Nothing in the list is clickable, so no
  onSelect exists; if you want selectable, action-firing rows you want a
  command palette, not this.

Behavior
- Key names are authored semantically and resolved at render into two things:
  the glyph to paint and the words to speak. "mod" is the portable primary
  modifier (⌘ on mac, Ctrl elsewhere); cmd/command/meta, ctrl/control,
  alt/option and shift each have a mac glyph and a windows word. A second table
  covers OS-independent keys (enter ↵, esc, tab ⇥, backspace ⌫, arrows, space,
  comma, slash, question mark…). A single character is upper-cased so ["mod","k"]
  paints ⌘ K. Unknown names pass through untouched.
- Platform detection goes through useSyncExternalStore with a "win" server
  snapshot, never a render-phase navigator read: reading navigator while
  rendering makes the two sides emit different strings and hydration explodes.
  Setting platform explicitly is what lets docs and screenshots show the other
  OS's glyph set on demand.
- Row table build: filter by platform scope, drop empty key names, then
  de-duplicate on `keys.join("+") + description` — a repeated row would produce
  a duplicate React key, and deleting one of them unmounts the wrong element.
  Groups that end up with zero rows disappear instead of rendering an empty
  heading.
- Search splits the query on whitespace into tokens and requires every token to
  be a substring of a per-row haystack made of the description, the raw key
  names, the glyphs and the spoken names — so "cmd", "⌘" and "command" all find
  the same row. A permanently mounted role="status" reports "N of M match" or
  "No matches for x"; mounting the live region only when there are results is
  the classic way to lose the first announcement.
- The query resets on the closed→open transition using a render-phase
  prevOpen comparison, not a setState inside an effect.
- Global "?" (bindTrigger): a window keydown listener that bails on
  defaultPrevented, on ⌘/Ctrl/Alt combos, and — critically — inside any typing
  context: input (except button/checkbox/color/file/image/radio/range/reset/
  submit types), textarea, select, contenteditable (inherited, so nested nodes
  count) and role=textbox/searchbox. "?" is a printable character; binding it
  without that guard pops the sheet open every time someone types a question
  mark into a comment box. Only ever let one instance on a page bind it —
  several instances racing for the same key stack sheets on top of each other.
- Callbacks live in refs (latest-ref) so an inline onOpenChange arrow function
  does not tear down and re-add the window listener on every render.
- While open, focus moves to the search box, else the close button, else the
  panel itself — and the keyboard is handled on the PANEL's own React
  onKeyDown, not on window. Escape closes and calls stopPropagation(); Tab and
  Shift+Tab are trapped inside the panel (aria-modal claims the background is
  unreachable, so focus must not leave). Escape must not be a window listener
  here: this sheet is summoned with "?" while other overlays are open, so a
  bubbling window handler makes one Esc press close both this sheet and the
  drawer underneath it, dumping the user out of the work they were doing. A
  panel handler that stops propagation closes exactly the innermost surface.
  (The only window listener that remains is the global "?" trigger, which by
  definition has to fire while this panel is closed.)
- On close or unmount, focus returns to whatever was focused before — but only
  after checking isConnected, since the trigger is often unmounted by then and
  focusing a detached node drops focus to body.
- Body scroll lock — the reentrancy count AND the pre-lock snapshot live on
  `document.body` as data attributes (`body.dataset.zyScrollLocks`,
  `.zyScrollLockOverflow`, `.zyScrollLockPadding`), never in module-level
  variables. The 0 → 1 edge snapshots body's current inline `overflow` /
  `paddingRight` and freezes; later locks only increment; only the 1 → 0
  release restores the snapshot and deletes all three attributes. Module scope
  is not enough because every component here is installed as its own copy: a
  page runs several independent copies of this same lock (this sheet, a
  drawer, a popover), each with a private counter blind to the others. Nest
  two and the outer one restores "" on close while the inner one later writes
  back the "hidden" it recorded as the original — page frozen until reload,
  with nothing left on screen to explain it. A body attribute is the one
  namespace independent copies already share; keep the three names
  byte-identical wherever this code is pasted.
- Scrollbar compensation is MEASURED, not predicted: read
  `document.documentElement.clientWidth`, set `overflow: hidden`, read it
  again, and add the positive difference to body's computed `paddingRight`.
  The `innerWidth - clientWidth` shortcut is wrong on any page with
  `scrollbar-gutter: stable` — the gutter never goes away, so it pads ~15px
  that was never reclaimed and the page behind the sheet jumps LEFT on open.
  The measurement also correctly yields 0 under macOS overlay scrollbars,
  which is why a Mac-only test proves nothing here.
- prefers-reduced-motion: the overlay and panel entrance animations become
  none. Opening, filtering, trapping and closing are untouched.
- Cleanup: the "?" window listener removed, scroll lock decremented, focus
  restored. Nothing else is bound globally — the in-panel keys die with the
  panel because they are React handlers on it.

Rendering & styling
- Semantic tokens only: overlay bg-background/80 + backdrop-blur-sm; panel
  bg-popover / text-popover-foreground with border, rounded-xl, shadow-2xl;
  keycaps bg-muted + border + border-b-2 + text-muted-foreground; secondary text
  text-muted-foreground; focus rings focus-visible:ring-2 ring-ring. No hex, no
  rgb(), no oklch(), no palette class names, and no chart tokens on text.
- Portal to document.body with a fixed inset-0 overlay. That is the whole
  clipping story: a sheet rendered inside an overflow-hidden card (a docs
  preview stage, a settings panel) would otherwise be in the DOM and invisible.
- Groups are laid out with grid-cols-[repeat(auto-fit,minmax(min(18rem,100%),1fr))]
  rather than a hard sm:grid-cols-2, so one group (or one surviving group after
  filtering) collapses the empty track and spans the full width instead of
  wrapping long descriptions into a narrow half-width column.
- Only the middle region scrolls (max-h-85vh panel, overflow-y-auto +
  overscroll-contain), so the title, filter and footer stay put; group headings
  are sticky inside that scroller.
- Accessibility: role="dialog" + aria-modal="true" + aria-labelledby on the
  title; each group is role="group" with aria-labelledby on its heading (a
  section's default region role would litter the dialog with landmarks); rows
  are a description list — the div wrapping each name/value pair is
  role="presentation" so the pairs stay owned by the list; each row carries a
  visually hidden "Command plus K" phrase while the glyph row is aria-hidden,
  because screen readers read ⌘ ⇧ ⌥ as noise.
- Entrance keyframes ship inside the component in a style element with an href
  and precedence, which React 19 hoists into head and de-duplicates, so ten
  instances still install one copy.

Customization levers
- Density and size: keycap height (h-6), row padding (py-2), panel width
  (max-w-3xl) and height (max-h-85vh) — override the last two through className.
- Chrome: search={false} for a short list; footer replaces the bottom hint row
  entirely (put a link to your full docs there); drop the header subtitle if you
  do not want a shortcut count.
- Key vocabulary: extend the two lookup tables to add F-keys, media keys or
  app-specific glyphs — each entry is just { label, spoken }.
- Trigger: bindTrigger={false} and drive `open` from your own hotkey hook if you
  want ⌘/ or F1 instead of "?" — or change the single key comparison in the
  window listener.
- Layout: raise the 18rem track minimum for wider group columns, or set the grid
  to one column for a tall single-column sheet.
- Platform: force platform="mac" / "win" for documentation screenshots; leave it
  "auto" in the product.

Concepts

  • Printable-key hotkey? is not a modifier combo, it is a character someone might be typing. A global binding for it is only safe behind a typing-context guard (input / textarea / select / contenteditable / ARIA text widgets); without it the cheat sheet interrupts every question a user writes.
  • Single hotkey owner — a global key belongs to exactly one mounted instance. Two sheets listening for the same key both open, stacking overlays; bindTrigger exists so the extra instances stay silent and are driven by their own triggers.
  • Glyph vs spoken name — a keycap paints ⌘ ⇧ ⌥ but a screen reader must hear "Command plus Shift plus P". Every key resolves to both, the glyph row is aria-hidden and the phrase is visually hidden, so the two channels never leak into each other.
  • Portable modifier (mod) — authoring mod instead of cmd or ctrl keeps one data set for both platforms; the platform is resolved after mount through an external-store snapshot, never by reading navigator during render.
  • Scroll lock counted on document.body — the count and the saved overflow / paddingRight are data attributes on body, not module-level variables, because every component here is installed as its own copy: the drawer this sheet was opened on top of is running a different copy of the same lock with its own private counter. Two blind counters is how pages get permanently frozen — the first closes and restores "", the second closes and hands back the "hidden" it recorded as the original. A DOM attribute is the one namespace independent copies already share, and the padding it restores is a measured value, never the innerWidth - clientWidth guess that misfires under scrollbar-gutter: stable.
  • Innermost layer owns Escape — Esc is a panel onKeyDown with stopPropagation, not a window listener, because "?" can summon this sheet on top of another overlay and a bubbling handler would close both with one press. The only global binding left is the "?" trigger itself, which must fire while the panel is closed.
  • Collapsing trackauto-fit + minmax(min(18rem,100%),1fr) gives two columns when there are several groups and one full-width column when there is only one, so long descriptions never get squeezed into half a panel while the other half sits empty.

On This Page