Shortcuts Dialog
A grouped keyboard cheat sheet on the ? key — platform-correct glyphs, live filtering, focus trap and a body-counted scroll lock.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/shortcuts-dialog.jsonPrompt
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;
bindTriggerexists 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-hiddenand the phrase is visually hidden, so the two channels never leak into each other. - Portable modifier (
mod) — authoringmodinstead ofcmdorctrlkeeps one data set for both platforms; the platform is resolved after mount through an external-store snapshot, never by readingnavigatorduring render. - Scroll lock counted on
document.body— the count and the savedoverflow/paddingRightare data attributes onbody, 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 theinnerWidth - clientWidthguess that misfires underscrollbar-gutter: stable. - Innermost layer owns Escape — Esc is a panel
onKeyDownwithstopPropagation, 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 track —
auto-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.
Loading Overlay
A scrim that freezes one region or the whole viewport while work is in flight — delay-gated so short requests never flash, min-duration held so it never blinks out, and inert underneath so Tab cannot walk into the frozen form.
Top Loading Bar
A thin route and request bar that climbs asymptotically while work is in flight, with a delay grace period, a minimum visible duration, a controlled mode and an optional spinner.