Shortcut Cheatsheet
A searchable overlay that lists keyboard shortcuts by section, opened with a key.
@drift-ui/shortcut-cheatsheetRegistry JSONOr press ?
Installation
The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.
Usage
"use client"
import { useState } from "react"
import { ShortcutCheatsheet } from "@/components/ui/shortcut-cheatsheet"
import { Button } from "@/components/ui/button"
const sections = [
{
title: "Navigation",
shortcuts: [
{ keys: "Mod+K", label: "Search" },
{ keys: "G", label: "Go to inbox" },
],
},
]
export function HelpMenu() {
const [open, setOpen] = useState(false)
return (
<>
<Button variant="ghost" onClick={() => setOpen(true)}>Keyboard shortcuts</Button>
<ShortcutCheatsheet sections={sections} open={open} onOpenChange={setOpen} />
</>
)
}Examples
Customised bindings
Pass the values from a ShortcutRecorder into sections and the cheatsheet shows what the user actually bound.
Another trigger
trigger takes a recorded shortcut as well as a character, such as "Mod+/". Set it to an empty string to open the cheatsheet only from your own controls.
Press Ctrl/
API reference
ShortcutCheatsheet
A dialog that lists shortcuts by section with a search field. It renders no button of its own: press the trigger, or set open from your own control.
| Prop | Type | Default |
|---|---|---|
sectionsRequired{ title: string; shortcuts: { keys: string; label: string }[] }[]Shortcuts by section. keys uses the ShortcutRecorder format, such as "Mod+K". | { title: string; shortcuts: { keys: string; label: string }[] }[] | |
triggerstringDefault"?"A character, or a shortcut string such as "Mod+/", that opens the cheatsheet from anywhere on the page except text fields. An uppercase letter reads as a shortcut, so "G" is the G key without Shift. An empty string turns it off. | string | "?" |
openbooleanControlled open state. | boolean | |
defaultOpenbooleanDefaultfalseInitial open state when uncontrolled. | boolean | false |
onOpenChange(open: boolean) => voidCalled when the cheatsheet opens or closes. | (open: boolean) => void |
Other props go to the DialogContent.
filterSections, matchesTrigger
filterSections(sections: CheatsheetSection[], query: string): CheatsheetSection[]; matchesTrigger(event: KeyboardEvent, trigger: string, isMac: boolean): booleanImport from @/lib/shortcut-cheatsheet to reuse the search or the trigger check outside the dialog.
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
filterSections,
matchesTrigger,
type CheatsheetSection,
} from "@/lib/shortcut-cheatsheet"
import { useControllableState } from "@/hooks/use-controllable-state"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { ShortcutKeys } from "@/components/ui/shortcut-recorder"
function ShortcutCheatsheet({
sections,
trigger = "?",
open,
defaultOpen = false,
onOpenChange,
className,
...props
}: React.ComponentProps<typeof DialogContent> & {
sections: CheatsheetSection[]
trigger?: string
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}) {
const { value: isOpen, setValue: setIsOpen } = useControllableState({
value: open,
defaultValue: defaultOpen,
onChange: onOpenChange,
})
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
if (event.defaultPrevented || !matchesTrigger(event, trigger, isMac))
return
event.preventDefault()
setIsOpen(true)
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [trigger, setIsOpen])
React.useEffect(() => {
if (!isOpen) return
const opener = document.activeElement
return () => {
if (opener instanceof HTMLElement) opener.focus({ preventScroll: true })
}
}, [isOpen])
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent
data-slot="shortcut-cheatsheet"
className={cn("sm:max-w-xl", className)}
{...props}
>
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
<DialogDescription className="sr-only">
Search by action or section. Press Escape to close.
</DialogDescription>
</DialogHeader>
<ShortcutCheatsheetResults sections={sections} />
</DialogContent>
</Dialog>
)
}
function ShortcutCheatsheetResults({
sections,
}: {
sections: CheatsheetSection[]
}) {
const [query, setQuery] = React.useState("")
const visibleSections = filterSections(sections, query)
const count = visibleSections.reduce(
(total, section) => total + section.shortcuts.length,
0
)
return (
<>
<Input
type="search"
aria-label="Search shortcuts"
placeholder="Search shortcuts"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.repeat && !query) event.preventDefault()
}}
/>
<div
role="region"
aria-label="Shortcuts"
tabIndex={0}
data-slot="shortcut-cheatsheet-sections"
className="flex max-h-[60vh] flex-col gap-5 overflow-y-auto rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{visibleSections.map((section, sectionIndex) => (
<section key={`${section.title}-${sectionIndex}`}>
<h3 className="mb-1 text-xs font-medium text-muted-foreground">
{section.title}
</h3>
<dl>
{section.shortcuts.map((shortcut, shortcutIndex) => (
<div
key={`${shortcut.label}-${shortcutIndex}`}
className="flex items-center justify-between gap-4 py-1 text-sm"
>
<dt>{shortcut.label}</dt>
<dd>
<ShortcutKeys shortcut={shortcut.keys} />
</dd>
</div>
))}
</dl>
</section>
))}
<p
role="status"
className={cn(
"py-6 text-center text-sm text-muted-foreground",
count > 0 && "sr-only"
)}
>
{count === 0 && "No shortcuts found"}
{count === 1 && "1 shortcut"}
{count > 1 && `${count} shortcuts`}
</p>
</div>
</>
)
}
export { ShortcutCheatsheet }