Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
Hotkeys API Reference
App Hotkeys API Reference
Key hold & held keys API Reference
Hotkey Recorder API Reference
Normalization & format API Reference

Preact Example: Scopes

import { createContext, render } from 'preact'
import { createPortal } from 'preact/compat'
import { useCallback, useContext, useState } from 'preact/hooks'
import {
  HotkeysProvider,
  formatForDisplay,
  useHotkey,
} from '@tanstack/preact-hotkeys'
import { hotkeysDevtoolsPlugin } from '@tanstack/preact-hotkeys-devtools'
import { TanStackDevtools } from '@tanstack/preact-devtools'
import type { ComponentChildren } from 'preact'
import type { Hotkey } from '@tanstack/preact-hotkeys'
import './index.css'

const plugins = [hotkeysDevtoolsPlugin()]

// Every hotkey below is a plain `useHotkey` call with no `scope` option. It
// registers in the scope of the nearest `HotkeysProvider` that sets one.

function App() {
  return (
    <>
      <HotkeysProvider>
        <ActivityProvider>
          <div className="app">
            <header>
              <h1>Hotkey scopes</h1>
              <p>
                Each nested <code>HotkeysProvider</code> with a{' '}
                <code>scope</code> is a scope. Its hotkeys fire while it is
                mounted and enabled. Open the hotkeys devtools to see which
                scopes are active, suspended, or allowed.
              </p>
            </header>
            <ScopeFrame>
              <Scope scope="palette">
                <Palette />
              </Scope>
              <Scope scope="document">
                <NestedDemo />
              </Scope>
              <Scope scope="mail">
                <TabsDemo />
              </Scope>
              <Scope scope="settings">
                <DialogDemo />
              </Scope>
              <ActivityLog />
            </ScopeFrame>
          </div>
        </ActivityProvider>
      </HotkeysProvider>
      <TanStackDevtools plugins={plugins} />
    </>
  )
}

// ---------------------------------------------------------------------------
// Providers drawn as boxes
// ---------------------------------------------------------------------------

type ExclusiveOption = boolean | { allow: ReadonlyArray<string> }

interface ScopeProps {
  scope: string
  exclusive?: ExclusiveOption
  enabled?: boolean
  children: ComponentChildren
}

/** A `HotkeysProvider` with a box around its children, labeled with its props. */
function Scope({ children, ...props }: ScopeProps) {
  return (
    <HotkeysProvider {...props}>
      <ScopeFrame {...props}>{children}</ScopeFrame>
    </HotkeysProvider>
  )
}

/** The box alone, for content rendered apart from its provider, such as a dialog. */
function ScopeFrame({
  scope = 'global',
  exclusive,
  enabled,
  children,
}: Partial<ScopeProps>) {
  const props = [
    scope !== 'global' && `scope="${scope}"`,
    exclusive &&
      `exclusive${exclusive === true ? '' : `={{ allow: [${exclusive.allow.map((s) => `'${s}'`).join(', ')}] }}`}`,
    enabled !== undefined && `enabled={${enabled}}`,
  ].filter(Boolean)
  return (
    <fieldset
      className="scope-frame"
      data-exclusive={!!exclusive}
      data-enabled={enabled !== false}
    >
      <legend>
        <code>{`<HotkeysProvider${props.map((p) => ` ${p}`).join('')}>`}</code>
      </legend>
      {children}
    </fieldset>
  )
}

// ---------------------------------------------------------------------------
// A hotkey in its own scope, so a dialog can allow just this one
// ---------------------------------------------------------------------------

function Palette() {
  const log = useLog()
  const [opened, setOpened] = useState(0)
  useHotkey('Mod+K', () => {
    setOpened((n) => n + 1)
    log('palette', 'Command palette opened')
  })
  return (
    <section className="demo-section">
      <h2>Command palette</h2>
      <p>
        <Key hotkey="Mod+K" /> lives in its own <code>palette</code> scope, so
        an exclusive dialog can allow it without allowing everything else.
      </p>
      <div className="counter">
        Opened {opened} {opened === 1 ? 'time' : 'times'}
      </div>
    </section>
  )
}

// ---------------------------------------------------------------------------
// Nested scopes beat their ancestors
// ---------------------------------------------------------------------------

function NestedDemo() {
  const log = useLog()
  const [showComments, setShowComments] = useState(true)
  useHotkey('Mod+S', () => log('document', 'Document saved'))

  return (
    <section className="demo-section">
      <h2>Nested scopes</h2>
      <p>
        The document and its comment box both register <Key hotkey="Mod+S" />.
        The comment box is nested, so it wins while it is mounted. Hide it and
        the document handles the key again.
      </p>
      <label>
        <input
          type="checkbox"
          checked={showComments}
          onChange={(e) => setShowComments(e.currentTarget.checked)}
        />{' '}
        Show comment box
      </label>
      {showComments && (
        <Scope scope="comments">
          <CommentBox />
        </Scope>
      )}
    </section>
  )
}

function CommentBox() {
  const log = useLog()
  const [comments, setComments] = useState(0)
  useHotkey('Mod+S', () => {
    setComments((n) => n + 1)
    log('comments', 'Comment posted')
  })
  return (
    <div className="panel">
      <strong>Comments</strong>
      <p>{comments} posted</p>
    </div>
  )
}

// ---------------------------------------------------------------------------
// enabled turns a scope off without unmounting it
// ---------------------------------------------------------------------------

const folders = ['Inbox', 'Archive', 'Spam'] as const
type Folder = (typeof folders)[number]

function TabsDemo() {
  const [current, setCurrent] = useState<Folder>('Inbox')
  useHotkey('Alt+[Digit1]', () => setCurrent('Inbox'))
  useHotkey('Alt+[Digit2]', () => setCurrent('Archive'))
  useHotkey('Alt+[Digit3]', () => setCurrent('Spam'))

  return (
    <section className="demo-section">
      <h2>Enabled scopes</h2>
      <p>
        All three folders stay mounted and register <Key hotkey="Alt+N" />. Only
        the selected folder&apos;s provider is <code>enabled</code>, so the key
        adds to that one. Switch with <Key hotkey="Alt+1" />,{' '}
        <Key hotkey="Alt+2" />, and <Key hotkey="Alt+3" />.
      </p>
      <div className="folders">
        {folders.map((name) => (
          <Scope
            key={name}
            scope={name.toLowerCase()}
            enabled={current === name}
          >
            <FolderPane
              name={name}
              current={current === name}
              onSelect={() => setCurrent(name)}
            />
          </Scope>
        ))}
      </div>
    </section>
  )
}

function FolderPane({
  name,
  current,
  onSelect,
}: {
  name: Folder
  current: boolean
  onSelect: () => void
}) {
  const log = useLog()
  const [items, setItems] = useState(0)
  useHotkey('Alt+[KeyN]', () => {
    setItems((n) => n + 1)
    log(name.toLowerCase(), `Added to ${name}`)
  })
  return (
    <button className="panel folder" data-current={current} onClick={onSelect}>
      <strong>{name}</strong>
      <span>{items} items</span>
    </button>
  )
}

// ---------------------------------------------------------------------------
// exclusive suspends every other scope, except the ones it allows
// ---------------------------------------------------------------------------

const allowOptions = ['global', 'palette'] as const

function DialogDemo() {
  const log = useLog()
  const [open, setOpen] = useState(false)
  const [allow, setAllow] = useState<Array<string>>(['palette'])
  useHotkey('Alt+[KeyD]', () => setOpen(true))

  const exclusive = allow.length > 0 ? { allow } : true

  return (
    <section className="demo-section">
      <h2>Exclusive scopes</h2>
      <p>
        An exclusive provider suspends every other scope while it is mounted,
        including <code>global</code>. Scopes nested inside it keep working.
        List scopes in <code>allow</code> to keep them working too.
      </p>
      <fieldset>
        <legend>Allow while the dialog is open</legend>
        {allowOptions.map((scope) => (
          <label key={scope}>
            <input
              type="checkbox"
              checked={allow.includes(scope)}
              onChange={(e) =>
                setAllow((prev) =>
                  e.currentTarget.checked
                    ? [...prev, scope]
                    : prev.filter((s) => s !== scope),
                )
              }
            />{' '}
            <code>{scope}</code>
          </label>
        ))}
      </fieldset>
      <button onClick={() => setOpen(true)}>
        Open dialog <Key hotkey="Alt+D" />
      </button>
      {open && (
        <HotkeysProvider scope="dialog" exclusive={exclusive}>
          <Dialog
            exclusive={exclusive}
            onClose={() => setOpen(false)}
            onSave={() => {
              log('dialog', 'Dialog saved')
              setOpen(false)
            }}
          />
        </HotkeysProvider>
      )}
    </section>
  )
}

function Dialog({
  exclusive,
  onClose,
  onSave,
}: {
  exclusive: ExclusiveOption
  onClose: () => void
  onSave: () => void
}) {
  useHotkey('Escape', onClose)
  useHotkey('Mod+S', onSave)

  // Portaled to the body, outside the boxes above. The scope still comes from
  // the provider around this component, not from where it renders.
  return createPortal(
    <div className="backdrop" onClick={onClose}>
      <div
        className="dialog"
        role="dialog"
        aria-modal="true"
        aria-label="Exclusive dialog"
        onClick={(e) => e.stopPropagation()}
      >
        <ScopeFrame scope="dialog" exclusive={exclusive}>
          <h2>Exclusive dialog</h2>
          <p>
            <Key hotkey="Mod+S" /> saves this dialog instead of the document.{' '}
            <Key hotkey="Escape" /> closes it. The folder hotkeys are suspended,
            and so is anything you did not allow above.
          </p>
          <Scope scope="dialog-steps">
            <Steps />
          </Scope>
          <div className="actions">
            <button className="secondary" onClick={onClose}>
              Cancel
            </button>
            <button onClick={onSave}>Save</button>
          </div>
        </ScopeFrame>
      </div>
    </div>,
    document.body,
  )
}

/** Nested in the exclusive scope, so it stays active without being allowed. */
function Steps() {
  const log = useLog()
  const [step, setStep] = useState(1)
  useHotkey('Alt+[KeyN]', () => {
    setStep((n) => (n % 3) + 1)
    log('dialog-steps', 'Next step')
  })
  return (
    <div className="panel">
      Step {step} of 3. <Key hotkey="Alt+N" /> moves to the next step. It is in
      the nested <code>dialog-steps</code> scope, so it still works, and it
      takes the key from the suspended folders.
    </div>
  )
}

// ---------------------------------------------------------------------------
// Activity log
// ---------------------------------------------------------------------------

interface Entry {
  id: number
  scope: string
  message: string
}

const ActivityContext = createContext<{
  entries: Array<Entry>
  log: (scope: string, message: string) => void
  clear: () => void
} | null>(null)

let nextEntryId = 0

function ActivityProvider({ children }: { children: ComponentChildren }) {
  const [entries, setEntries] = useState<Array<Entry>>([])
  const log = useCallback((scope: string, message: string) => {
    setEntries((prev) =>
      [{ id: nextEntryId++, scope, message }, ...prev].slice(0, 20),
    )
  }, [])
  const clear = useCallback(() => setEntries([]), [])
  return (
    <ActivityContext.Provider value={{ entries, log, clear }}>
      {children}
    </ActivityContext.Provider>
  )
}

function useActivity() {
  const activity = useContext(ActivityContext)
  if (!activity) throw new Error('Missing ActivityProvider')
  return activity
}

function useLog() {
  return useActivity().log
}

/** Registered under the root provider, so it is in the `global` scope. */
function ActivityLog() {
  const { entries, clear } = useActivity()
  useHotkey('Alt+[KeyL]', clear)
  return (
    <section className="demo-section">
      <h2>Activity</h2>
      <p>
        <Key hotkey="Alt+L" /> clears the log. It is registered under the root
        provider, so it is in the <code>global</code> scope.
      </p>
      <div className="log">
        {entries.length === 0 ? (
          <div className="hint">Press a hotkey above.</div>
        ) : (
          entries.map((entry) => (
            <div key={entry.id} className="log-entry">
              <code className="log-scope">{entry.scope}</code>
              {entry.message}
            </div>
          ))
        )}
      </div>
    </section>
  )
}

function Key({ hotkey }: { hotkey: Hotkey }) {
  return <kbd>{formatForDisplay(hotkey)}</kbd>
}

render(<App />, document.getElementById('root')!)
import { createContext, render } from 'preact'
import { createPortal } from 'preact/compat'
import { useCallback, useContext, useState } from 'preact/hooks'
import {
  HotkeysProvider,
  formatForDisplay,
  useHotkey,
} from '@tanstack/preact-hotkeys'
import { hotkeysDevtoolsPlugin } from '@tanstack/preact-hotkeys-devtools'
import { TanStackDevtools } from '@tanstack/preact-devtools'
import type { ComponentChildren } from 'preact'
import type { Hotkey } from '@tanstack/preact-hotkeys'
import './index.css'

const plugins = [hotkeysDevtoolsPlugin()]

// Every hotkey below is a plain `useHotkey` call with no `scope` option. It
// registers in the scope of the nearest `HotkeysProvider` that sets one.

function App() {
  return (
    <>
      <HotkeysProvider>
        <ActivityProvider>
          <div className="app">
            <header>
              <h1>Hotkey scopes</h1>
              <p>
                Each nested <code>HotkeysProvider</code> with a{' '}
                <code>scope</code> is a scope. Its hotkeys fire while it is
                mounted and enabled. Open the hotkeys devtools to see which
                scopes are active, suspended, or allowed.
              </p>
            </header>
            <ScopeFrame>
              <Scope scope="palette">
                <Palette />
              </Scope>
              <Scope scope="document">
                <NestedDemo />
              </Scope>
              <Scope scope="mail">
                <TabsDemo />
              </Scope>
              <Scope scope="settings">
                <DialogDemo />
              </Scope>
              <ActivityLog />
            </ScopeFrame>
          </div>
        </ActivityProvider>
      </HotkeysProvider>
      <TanStackDevtools plugins={plugins} />
    </>
  )
}

// ---------------------------------------------------------------------------
// Providers drawn as boxes
// ---------------------------------------------------------------------------

type ExclusiveOption = boolean | { allow: ReadonlyArray<string> }

interface ScopeProps {
  scope: string
  exclusive?: ExclusiveOption
  enabled?: boolean
  children: ComponentChildren
}

/** A `HotkeysProvider` with a box around its children, labeled with its props. */
function Scope({ children, ...props }: ScopeProps) {
  return (
    <HotkeysProvider {...props}>
      <ScopeFrame {...props}>{children}</ScopeFrame>
    </HotkeysProvider>
  )
}

/** The box alone, for content rendered apart from its provider, such as a dialog. */
function ScopeFrame({
  scope = 'global',
  exclusive,
  enabled,
  children,
}: Partial<ScopeProps>) {
  const props = [
    scope !== 'global' && `scope="${scope}"`,
    exclusive &&
      `exclusive${exclusive === true ? '' : `={{ allow: [${exclusive.allow.map((s) => `'${s}'`).join(', ')}] }}`}`,
    enabled !== undefined && `enabled={${enabled}}`,
  ].filter(Boolean)
  return (
    <fieldset
      className="scope-frame"
      data-exclusive={!!exclusive}
      data-enabled={enabled !== false}
    >
      <legend>
        <code>{`<HotkeysProvider${props.map((p) => ` ${p}`).join('')}>`}</code>
      </legend>
      {children}
    </fieldset>
  )
}

// ---------------------------------------------------------------------------
// A hotkey in its own scope, so a dialog can allow just this one
// ---------------------------------------------------------------------------

function Palette() {
  const log = useLog()
  const [opened, setOpened] = useState(0)
  useHotkey('Mod+K', () => {
    setOpened((n) => n + 1)
    log('palette', 'Command palette opened')
  })
  return (
    <section className="demo-section">
      <h2>Command palette</h2>
      <p>
        <Key hotkey="Mod+K" /> lives in its own <code>palette</code> scope, so
        an exclusive dialog can allow it without allowing everything else.
      </p>
      <div className="counter">
        Opened {opened} {opened === 1 ? 'time' : 'times'}
      </div>
    </section>
  )
}

// ---------------------------------------------------------------------------
// Nested scopes beat their ancestors
// ---------------------------------------------------------------------------

function NestedDemo() {
  const log = useLog()
  const [showComments, setShowComments] = useState(true)
  useHotkey('Mod+S', () => log('document', 'Document saved'))

  return (
    <section className="demo-section">
      <h2>Nested scopes</h2>
      <p>
        The document and its comment box both register <Key hotkey="Mod+S" />.
        The comment box is nested, so it wins while it is mounted. Hide it and
        the document handles the key again.
      </p>
      <label>
        <input
          type="checkbox"
          checked={showComments}
          onChange={(e) => setShowComments(e.currentTarget.checked)}
        />{' '}
        Show comment box
      </label>
      {showComments && (
        <Scope scope="comments">
          <CommentBox />
        </Scope>
      )}
    </section>
  )
}

function CommentBox() {
  const log = useLog()
  const [comments, setComments] = useState(0)
  useHotkey('Mod+S', () => {
    setComments((n) => n + 1)
    log('comments', 'Comment posted')
  })
  return (
    <div className="panel">
      <strong>Comments</strong>
      <p>{comments} posted</p>
    </div>
  )
}

// ---------------------------------------------------------------------------
// enabled turns a scope off without unmounting it
// ---------------------------------------------------------------------------

const folders = ['Inbox', 'Archive', 'Spam'] as const
type Folder = (typeof folders)[number]

function TabsDemo() {
  const [current, setCurrent] = useState<Folder>('Inbox')
  useHotkey('Alt+[Digit1]', () => setCurrent('Inbox'))
  useHotkey('Alt+[Digit2]', () => setCurrent('Archive'))
  useHotkey('Alt+[Digit3]', () => setCurrent('Spam'))

  return (
    <section className="demo-section">
      <h2>Enabled scopes</h2>
      <p>
        All three folders stay mounted and register <Key hotkey="Alt+N" />. Only
        the selected folder&apos;s provider is <code>enabled</code>, so the key
        adds to that one. Switch with <Key hotkey="Alt+1" />,{' '}
        <Key hotkey="Alt+2" />, and <Key hotkey="Alt+3" />.
      </p>
      <div className="folders">
        {folders.map((name) => (
          <Scope
            key={name}
            scope={name.toLowerCase()}
            enabled={current === name}
          >
            <FolderPane
              name={name}
              current={current === name}
              onSelect={() => setCurrent(name)}
            />
          </Scope>
        ))}
      </div>
    </section>
  )
}

function FolderPane({
  name,
  current,
  onSelect,
}: {
  name: Folder
  current: boolean
  onSelect: () => void
}) {
  const log = useLog()
  const [items, setItems] = useState(0)
  useHotkey('Alt+[KeyN]', () => {
    setItems((n) => n + 1)
    log(name.toLowerCase(), `Added to ${name}`)
  })
  return (
    <button className="panel folder" data-current={current} onClick={onSelect}>
      <strong>{name}</strong>
      <span>{items} items</span>
    </button>
  )
}

// ---------------------------------------------------------------------------
// exclusive suspends every other scope, except the ones it allows
// ---------------------------------------------------------------------------

const allowOptions = ['global', 'palette'] as const

function DialogDemo() {
  const log = useLog()
  const [open, setOpen] = useState(false)
  const [allow, setAllow] = useState<Array<string>>(['palette'])
  useHotkey('Alt+[KeyD]', () => setOpen(true))

  const exclusive = allow.length > 0 ? { allow } : true

  return (
    <section className="demo-section">
      <h2>Exclusive scopes</h2>
      <p>
        An exclusive provider suspends every other scope while it is mounted,
        including <code>global</code>. Scopes nested inside it keep working.
        List scopes in <code>allow</code> to keep them working too.
      </p>
      <fieldset>
        <legend>Allow while the dialog is open</legend>
        {allowOptions.map((scope) => (
          <label key={scope}>
            <input
              type="checkbox"
              checked={allow.includes(scope)}
              onChange={(e) =>
                setAllow((prev) =>
                  e.currentTarget.checked
                    ? [...prev, scope]
                    : prev.filter((s) => s !== scope),
                )
              }
            />{' '}
            <code>{scope}</code>
          </label>
        ))}
      </fieldset>
      <button onClick={() => setOpen(true)}>
        Open dialog <Key hotkey="Alt+D" />
      </button>
      {open && (
        <HotkeysProvider scope="dialog" exclusive={exclusive}>
          <Dialog
            exclusive={exclusive}
            onClose={() => setOpen(false)}
            onSave={() => {
              log('dialog', 'Dialog saved')
              setOpen(false)
            }}
          />
        </HotkeysProvider>
      )}
    </section>
  )
}

function Dialog({
  exclusive,
  onClose,
  onSave,
}: {
  exclusive: ExclusiveOption
  onClose: () => void
  onSave: () => void
}) {
  useHotkey('Escape', onClose)
  useHotkey('Mod+S', onSave)

  // Portaled to the body, outside the boxes above. The scope still comes from
  // the provider around this component, not from where it renders.
  return createPortal(
    <div className="backdrop" onClick={onClose}>
      <div
        className="dialog"
        role="dialog"
        aria-modal="true"
        aria-label="Exclusive dialog"
        onClick={(e) => e.stopPropagation()}
      >
        <ScopeFrame scope="dialog" exclusive={exclusive}>
          <h2>Exclusive dialog</h2>
          <p>
            <Key hotkey="Mod+S" /> saves this dialog instead of the document.{' '}
            <Key hotkey="Escape" /> closes it. The folder hotkeys are suspended,
            and so is anything you did not allow above.
          </p>
          <Scope scope="dialog-steps">
            <Steps />
          </Scope>
          <div className="actions">
            <button className="secondary" onClick={onClose}>
              Cancel
            </button>
            <button onClick={onSave}>Save</button>
          </div>
        </ScopeFrame>
      </div>
    </div>,
    document.body,
  )
}

/** Nested in the exclusive scope, so it stays active without being allowed. */
function Steps() {
  const log = useLog()
  const [step, setStep] = useState(1)
  useHotkey('Alt+[KeyN]', () => {
    setStep((n) => (n % 3) + 1)
    log('dialog-steps', 'Next step')
  })
  return (
    <div className="panel">
      Step {step} of 3. <Key hotkey="Alt+N" /> moves to the next step. It is in
      the nested <code>dialog-steps</code> scope, so it still works, and it
      takes the key from the suspended folders.
    </div>
  )
}

// ---------------------------------------------------------------------------
// Activity log
// ---------------------------------------------------------------------------

interface Entry {
  id: number
  scope: string
  message: string
}

const ActivityContext = createContext<{
  entries: Array<Entry>
  log: (scope: string, message: string) => void
  clear: () => void
} | null>(null)

let nextEntryId = 0

function ActivityProvider({ children }: { children: ComponentChildren }) {
  const [entries, setEntries] = useState<Array<Entry>>([])
  const log = useCallback((scope: string, message: string) => {
    setEntries((prev) =>
      [{ id: nextEntryId++, scope, message }, ...prev].slice(0, 20),
    )
  }, [])
  const clear = useCallback(() => setEntries([]), [])
  return (
    <ActivityContext.Provider value={{ entries, log, clear }}>
      {children}
    </ActivityContext.Provider>
  )
}

function useActivity() {
  const activity = useContext(ActivityContext)
  if (!activity) throw new Error('Missing ActivityProvider')
  return activity
}

function useLog() {
  return useActivity().log
}

/** Registered under the root provider, so it is in the `global` scope. */
function ActivityLog() {
  const { entries, clear } = useActivity()
  useHotkey('Alt+[KeyL]', clear)
  return (
    <section className="demo-section">
      <h2>Activity</h2>
      <p>
        <Key hotkey="Alt+L" /> clears the log. It is registered under the root
        provider, so it is in the <code>global</code> scope.
      </p>
      <div className="log">
        {entries.length === 0 ? (
          <div className="hint">Press a hotkey above.</div>
        ) : (
          entries.map((entry) => (
            <div key={entry.id} className="log-entry">
              <code className="log-scope">{entry.scope}</code>
              {entry.message}
            </div>
          ))
        )}
      </div>
    </section>
  )
}

function Key({ hotkey }: { hotkey: Hotkey }) {
  return <kbd>{formatForDisplay(hotkey)}</kbd>
}

render(<App />, document.getElementById('root')!)