Shortcut Cheatsheet

A searchable overlay that lists keyboard shortcuts by section, opened with a key.

@drift-ui/shortcut-cheatsheetRegistry JSON

Or press ?

On this page

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

shortcut-cheatsheet-usage.tsx
"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
sectionsRequired{ title: string; shortcuts: { keys: string; label: string }[] }[]Shortcuts by section. keys uses the ShortcutRecorder format, such as "Mod+K".
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.
openbooleanControlled open state.
defaultOpenbooleanDefaultfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => voidCalled when the cheatsheet opens or closes.

Other props go to the DialogContent.

filterSections, matchesTrigger

filterSections(sections: CheatsheetSection[], query: string): CheatsheetSection[]; matchesTrigger(event: KeyboardEvent, trigger: string, isMac: boolean): boolean

Import from @/lib/shortcut-cheatsheet to reuse the search or the trigger check outside the dialog.

Source

components/ui/shortcut-cheatsheet.tsx
"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 }