Repository: JohannesKlauss/react-hotkeys-hook
Pull request #1247: feature: sequential combos
Author: login: prajjwaldimri
Created: 2025-05-02T04:12:16Z | Merged: 2025-05-14T11:25:57Z

- Currently supports sequential combos with custom timeout and custom splitter.

@JohannesKlauss I have not added info about this in the docs. Please let me know if I need to do that as well.

Ref: https://github.com/JohannesKlauss/react-hotkeys-hook/issues/989




Diff:
diff --git a/packages/documentation/docs/documentation/useHotkeys/basic-usage.mdx b/packages/documentation/docs/documentation/useHotkeys/basic-usage.mdx
index 1c5829e3..aa67170b 100644
--- a/packages/documentation/docs/documentation/useHotkeys/basic-usage.mdx
+++ b/packages/documentation/docs/documentation/useHotkeys/basic-usage.mdx
@@ -149,6 +149,47 @@ Since we pressed down one hotkey already the example above started at the count
 globally if we don't use its return value.
 :::
 
+***
+
+## Sequential Hotkeys
+
+We can also listen to keystrokes sequentially, meaning that the user has to press keys in a specific order before invoking the callback. To specify a sequential hotkey, we use the `>` character.
+
+```jsx live
+function ExampleComponent() {
+  const [count, setCount] = useState(0)
+  useHotkeys('g>h>i', () => setCount(prevCount => prevCount + 1))
+
+  return (
+    <span>Received the combination {count} times.</span>
+  )
+}
+```
+
+In the example above, the user has to press the `g` key, then the `h` key and finally the `i` key in order to trigger the callback.
+
+#### Timeout
+`useHotkeys` sets a default time window for the sequential hotkey of `1000ms` to prevent listening for incomplete sequences indefinitely. If the user doesn't press the next key in that given time, the sequence is aborted.
+
+We can override the timeout by passing the `sequenceTimeoutMs` option:
+
+```jsx live
+function ExampleComponent() {
+  const [count, setCount] = useState(0)
+  useHotkeys('g>h>i>j', () => setCount(prevCount => prevCount + 1), {
+    sequenceTimeoutMs: 10000,
+  })
+
+  return (
+    <span>Received the combination {count} times.</span>
+  )
+}
+```
+
+In the example above, the user has to press the `g` key, then the `h` key and then the `i` key, and finally the `j` key in order to trigger the callback.
+The timeout is 10 seconds, so the user has a lot of time to press the keys.
+
+
 ***
 
 ## Modifiers & Special keys
@@ -161,7 +202,7 @@ Of course, we also want to leverage more complex keystrokes. `useHotkeys` suppor
 * `meta`
 * `mod` (which listens for `ctrl` on Windows/Linux)
 
-## MacOS and Windows/Linux compatability
+## MacOS and Windows/Linux compatibility
 Since version 4 `alt` and `option` are identical. The `meta` key is the same as `cmd` on macOS and `os` key on Windows.
 
 ```jsx
diff --git a/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts b/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts
index c36ee0c4..db9d5d4b 100644
--- a/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts
+++ b/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts
@@ -33,11 +33,22 @@ export function parseKeysHookInput(keys: string, delimiter = ','): string[] {
   return keys.toLowerCase().split(delimiter)
 }
 
-export function parseHotkey(hotkey: string, splitKey = '+', useKey = false, description?: string): Hotkey {
-  const keys = hotkey
-    .toLocaleLowerCase()
-    .split(splitKey)
-    .map((k) => mapCode(k))
+export function parseHotkey(hotkey: string, splitKey = '+', sequenceSplitKey = '>', useKey = false, description?: string): Hotkey {
+  let keys: string[] = []
+  let isSequence = false
+
+  if (hotkey.includes(sequenceSplitKey)) {
+    isSequence = true
+    keys = hotkey
+      .toLocaleLowerCase()
+      .split(sequenceSplitKey)
+      .map((k) => mapCode(k))
+  } else {
+    keys = hotkey
+      .toLocaleLowerCase()
+      .split(splitKey)
+      .map((k) => mapCode(k))
+  }
 
   const modifiers: KeyboardModifiers = {
     alt: keys.includes('alt'),
@@ -54,5 +65,6 @@ export function parseHotkey(hotkey: string, splitKey = '+', useKey = false, desc
     ...modifiers,
     keys: singleCharKeys,
     description,
+    isSequence,
   }
 }
diff --git a/packages/react-hotkeys-hook/src/lib/types.ts b/packages/react-hotkeys-hook/src/lib/types.ts
index 41b0357a..79bfd77c 100644
--- a/packages/react-hotkeys-hook/src/lib/types.ts
+++ b/packages/react-hotkeys-hook/src/lib/types.ts
@@ -28,6 +28,7 @@ export type Hotkey = KeyboardModifiers & {
   keys?: readonly string[]
   scopes?: Scopes
   description?: string
+  isSequence?: boolean
 }
 
 export type HotkeysEvent = Hotkey
@@ -63,10 +64,14 @@ export type Options = {
   document?: Document
   // Ignore modifiers when matching hotkeys. (Default: false)
   ignoreModifiers?: boolean
-  // Listen to the produced key instead of the code. (Default: false)
-  eventListenerOptions?: EventListenerOptions
   // Pass through event listener options. (Default: undefined)
+  eventListenerOptions?: EventListenerOptions
+  // Listen to the produced key instead of the code. (Default: false)
   useKey?: boolean
+  // The timeout to wait for the next key to be pressed. (Default: 1000ms)
+  sequenceTimeoutMs?: number
+  // The character to split the sequence of keys. (Default: >)
+  sequenceSplitKey?: string
 }
 
 export type OptionsOrDependencyArray = Options | DependencyList
diff --git a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts
index 91c80c40..3a69b717 100644
--- a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts
+++ b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts
@@ -1,6 +1,6 @@
 import { HotkeyCallback, Keys, Options, OptionsOrDependencyArray, RefType } from './types'
 import { DependencyList, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
-import { mapCode, parseHotkey, parseKeysHookInput } from './parseHotkeys'
+import { mapCode, parseHotkey, parseKeysHookInput, isHotkeyModifier } from './parseHotkeys'
 import {
   isHotkeyEnabled,
   isHotkeyEnabledOnTag,
@@ -59,6 +59,9 @@ export default function useHotkeys<T extends HTMLElement>(
       return
     }
 
+    let recordedKeys: string[] = []
+    let sequenceTimer: NodeJS.Timeout | undefined
+
     const listener = (e: KeyboardEvent, isKeyUp = false) => {
       if (isKeyboardEventTriggeredByInput(e) && !isHotkeyEnabledOnTag(e, memoisedOptions?.enableOnFormTags)) {
         return
@@ -84,30 +87,71 @@ export default function useHotkeys<T extends HTMLElement>(
       }
 
       parseKeysHookInput(_keys, memoisedOptions?.delimiter).forEach((key) => {
-        const hotkey = parseHotkey(key, memoisedOptions?.splitKey, memoisedOptions?.useKey, memoisedOptions?.description)
+        if (key.includes(memoisedOptions?.splitKey ?? '+') && key.includes(memoisedOptions?.sequenceSplitKey ?? '>')) {
+          console.warn(`Hotkey ${key} contains both ${memoisedOptions?.splitKey ?? '+'} and ${memoisedOptions?.sequenceSplitKey ?? '>'} which is not supported.`)
+          return
+        }
+
+        const hotkey = parseHotkey(key, memoisedOptions?.splitKey, memoisedOptions?.sequenceSplitKey, memoisedOptions?.useKey, memoisedOptions?.description)
 
-        if (isHotkeyMatchingKeyboardEvent(e, hotkey, memoisedOptions?.ignoreModifiers) || hotkey.keys?.includes('*')) {
-          if (memoisedOptions?.ignoreEventWhen?.(e)) {
+        if (hotkey.isSequence) {
+          // Set a timeout to check post which the sequence should reset
+          sequenceTimer = setTimeout(() => {
+            recordedKeys = []
+          }, memoisedOptions?.sequenceTimeoutMs ?? 1000)
+
+          const currentKey = hotkey.useKey ? e.key : mapCode(e.code)
+
+          // TODO: Make modifiers work with sequences
+          if (isHotkeyModifier(currentKey.toLowerCase())) {
             return
           }
 
-          if (isKeyUp && hasTriggeredRef.current) {
+          recordedKeys.push(currentKey)
+
+          const expectedKey = hotkey.keys?.[recordedKeys.length - 1]
+          if (currentKey !== expectedKey) {
+            recordedKeys = []
+            if (sequenceTimer) {
+              clearTimeout(sequenceTimer)
+            }
             return
           }
 
-          maybePreventDefault(e, hotkey, memoisedOptions?.preventDefault)
+          // If the sequence is complete, trigger the callback
+          if (recordedKeys.length === hotkey.keys?.length) {
+            cbRef.current(e, hotkey)
 
-          if (!isHotkeyEnabled(e, hotkey, memoisedOptions?.enabled)) {
-            stopPropagation(e)
+            if (sequenceTimer) {
+              clearTimeout(sequenceTimer)
+            }
 
-            return
+            recordedKeys = []
           }
+        } else {
+          if (isHotkeyMatchingKeyboardEvent(e, hotkey, memoisedOptions?.ignoreModifiers) || hotkey.keys?.includes('*')) {
+            if (memoisedOptions?.ignoreEventWhen?.(e)) {
+              return
+            }
+
+            if (isKeyUp && hasTriggeredRef.current) {
+              return
+            }
+
+            maybePreventDefault(e, hotkey, memoisedOptions?.preventDefault)
+
+            if (!isHotkeyEnabled(e, hotkey, memoisedOptions?.enabled)) {
+              stopPropagation(e)
 
-          // Execute the user callback for that hotkey
-          cbRef.current(e, hotkey)
+              return
+            }
 
-          if (!isKeyUp) {
-            hasTriggeredRef.current = true
+            // Execute the user callback for that hotkey
+            cbRef.current(e, hotkey)
+
+            if (!isKeyUp) {
+              hasTriggeredRef.current = true
+            }
           }
         }
       })
@@ -151,7 +195,7 @@ export default function useHotkeys<T extends HTMLElement>(
     if (proxy) {
       parseKeysHookInput(_keys, memoisedOptions?.delimiter).forEach((key) =>
         proxy.addHotkey(
-          parseHotkey(key, memoisedOptions?.splitKey, memoisedOptions?.useKey, memoisedOptions?.description)
+          parseHotkey(key, memoisedOptions?.splitKey, memoisedOptions?.sequenceSplitKey, memoisedOptions?.useKey, memoisedOptions?.description)
         )
       )
     }
@@ -165,10 +209,15 @@ export default function useHotkeys<T extends HTMLElement>(
       if (proxy) {
         parseKeysHookInput(_keys, memoisedOptions?.delimiter).forEach((key) =>
           proxy.removeHotkey(
-            parseHotkey(key, memoisedOptions?.splitKey, memoisedOptions?.useKey, memoisedOptions?.description)
+            parseHotkey(key, memoisedOptions?.splitKey, memoisedOptions?.sequenceSplitKey, memoisedOptions?.useKey, memoisedOptions?.description)
           )
         )
       }
+
+      recordedKeys = []
+      if (sequenceTimer) {
+        clearTimeout(sequenceTimer)
+      }
     }
   }, [_keys, memoisedOptions, activeScopes])
 
diff --git a/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx b/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx
index dceffbcf..349264c8 100644
--- a/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx
+++ b/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx
@@ -27,6 +27,9 @@ type HookParameters = {
 
 beforeEach(() => {
   window.dispatchEvent(new Event('DOMContentLoaded'))
+  vi.useFakeTimers({
+    shouldAdvanceTime: true,
+  })
 })
 
 test('should listen to esc modifier for escape key', async () => {
@@ -368,6 +371,196 @@ test('should listen to multiple combinations with modifiers', async () => {
   expect(callback).toHaveBeenCalledTimes(2)
 })
 
+test('should listen to sequences', async () => {
+  const user = userEvent.setup()
+  const callback = vi.fn()
+
+  const { rerender } = renderHook<void, HookParameters>(({ keys }) => useHotkeys(keys, callback), {
+    initialProps: {
+      keys: 'y>e>e>t',
+    },
+  })
+
+  await user.keyboard('y')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('t')
+
+  expect(callback).toHaveBeenCalledTimes(1)
+
+  vi.runAllTimers()
+
+  rerender({ keys: 'y>e>e>t' })
+
+  await user.keyboard('y')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('t')
+
+  expect(callback).toHaveBeenCalledTimes(2)
+})
+
+test('should not trigger when sequence is incomplete', async () => {
+  const user = userEvent.setup()
+  const callback = vi.fn()
+
+  renderHook(() => useHotkeys('y>e>e>t', callback))
+
+  await user.keyboard('y')
+  vi.advanceTimersByTime(500)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(500)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(500)
+
+  expect(callback).not.toHaveBeenCalled()
+})
+
+test('should not trigger when sequence and combination are mixed', async () => {
+  console.warn = vi.fn()
+  const user = userEvent.setup()
+  const callback = vi.fn()
+
+  renderHook(() => useHotkeys('y>e+e>t', callback))
+
+  await user.keyboard('y')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('t')
+
+  expect(console.warn).toHaveBeenCalledWith(
+    'Hotkey y>e+e>t contains both + and > which is not supported.',
+  )
+
+  expect(callback).not.toHaveBeenCalled()
+})
+
+test('should work with sequences and other hotkeys together', async () => {
+  const user = userEvent.setup()
+  const callback = vi.fn()
+
+  renderHook<void, HookParameters>(({ keys }) => useHotkeys(keys, callback), {
+    initialProps: {
+      keys: ['y>e>e>t', 'y', 'meta+a'],
+    },
+  })
+
+  await user.keyboard('y')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('t')
+
+  expect(callback).toHaveBeenCalledTimes(2)
+
+  await user.keyboard('{Meta>}A{/Meta}')
+
+  expect(callback).toHaveBeenCalledTimes(3)
+})
+
+test('should reset sequence when timeout occurs', async () => {
+  const user = userEvent.setup()
+  const callback = vi.fn()
+
+  renderHook(() => useHotkeys('y>e>e>t', callback))
+
+  await user.keyboard('y')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(1100)
+
+  await user.keyboard('y')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('t')
+
+  expect(callback).toHaveBeenCalledTimes(1)
+})
+
+test('should handle component unmount during sequence detection', async () => {
+  const user = userEvent.setup()
+  const callback = vi.fn()
+
+  const { unmount } = renderHook(() => useHotkeys('y>e>e>t', callback))
+
+  await user.keyboard('y')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+
+  unmount()
+
+  vi.advanceTimersByTime(1100)
+})
+
+test('should reset sequence when incorrect key is pressed', async () => {
+  const user = userEvent.setup()
+  const callback = vi.fn()
+
+  renderHook(() => useHotkeys('y>e>e>t', callback))
+
+  await user.keyboard('y')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('x')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('t')
+
+  expect(callback).not.toHaveBeenCalled()
+
+  vi.runAllTimers()
+
+  await user.keyboard('y')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('e')
+  vi.advanceTimersByTime(200)
+  await user.keyboard('t')
+
+  expect(callback).toHaveBeenCalledTimes(1)
+})
+
+test('should trigger sequence with useKey', async () => {
+  const user = userEvent.setup()
+  const callback = vi.fn()
+
+  renderHook(() => useHotkeys('%>!', callback, { useKey: true }))
+
+  await user.keyboard(`{Shift>}{%}{/Shift}`)
+  vi.advanceTimersByTime(200)
+  await user.keyboard(`{Shift>}{!}{/Shift}`)
+
+  expect(callback).toHaveBeenCalledTimes(1)
+})
+
+test('should not trigger sequence without useKey', async () => {
+  const user = userEvent.setup()
+  const callback = vi.fn()
+
+  renderHook(() => useHotkeys('%>!', callback, { useKey: false }))
+
+  await user.keyboard(`{Shift>}{%}{/Shift}`)
+  vi.advanceTimersByTime(200)
+  await user.keyboard(`{Shift>}{!}{/Shift}`)
+
+  expect(callback).toHaveBeenCalledTimes(0)
+})
+
 test('should reflect set delimiter character', async () => {
   const user = userEvent.setup()
   const callback = vi.fn()
@@ -902,6 +1095,7 @@ test('should pass keyboard event and hotkey object to callback', async () => {
     meta: false,
     mod: false,
     useKey: false,
+    isSequence: false,
   })
 })
 
@@ -922,6 +1116,7 @@ test('should set shift to true in hotkey object if listening to shift', async ()
     meta: false,
     mod: false,
     useKey: false,
+    isSequence: false,
   })
 })
 
@@ -942,6 +1137,7 @@ test('should set ctrl to true in hotkey object if listening to ctrl', async () =
     meta: false,
     mod: false,
     useKey: false,
+    isSequence: false,
   })
 })
 
@@ -962,6 +1158,7 @@ test('should set alt to true in hotkey object if listening to alt', async () =>
     meta: false,
     mod: false,
     useKey: false,
+    isSequence: false,
   })
 })
 
@@ -982,6 +1179,7 @@ test('should set mod to true in hotkey object if listening to mod', async () =>
     meta: false,
     mod: true,
     useKey: false,
+    isSequence: false,
   })
 })
 
@@ -1002,6 +1200,7 @@ test('should set meta to true in hotkey object if listening to meta', async () =
     meta: true,
     mod: false,
     useKey: false,
+    isSequence: false,
   })
 })
 
@@ -1022,6 +1221,7 @@ test('should set multiple modifiers to true in hotkey object if listening to mul
     meta: false,
     mod: true,
     useKey: false,
+    isSequence: false,
   })
 })
 
@@ -1122,6 +1322,7 @@ test('should call preventDefault option function with hotkey and keyboard event'
     meta: false,
     mod: false,
     useKey: false,
+    isSequence: false,
   })
 })
 


Reviews:
user: login: JohannesKlauss
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-05T07:28:26Z
user: login: prajjwaldimri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T06:06:14Z
user: login: JohannesKlauss
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-08T13:42:30Z

Review comments:
diff_hunk: @@ -33,11 +33,22 @@ export function parseKeysHookInput(keys: string, delimiter = ','): string[] {
   return keys.toLowerCase().split(delimiter)
 }
 
-export function parseHotkey(hotkey: string, splitKey = '+', useKey = false, description?: string): Hotkey {
-  const keys = hotkey
-    .toLocaleLowerCase()
-    .split(splitKey)
-    .map((k) => mapCode(k))
+export function parseHotkey(hotkey: string, splitKey = '+', sequenceSplitKey = '>', useKey = false, description?: string): Hotkey {
+  let keys: string[] = []
+  let isSequence = false
+
+  if (hotkey.includes(sequenceSplitKey)) {
+    isSequence = true
+    keys = hotkey
+      .toLocaleLowerCase()
+      .split(sequenceSplitKey)
+      .map((k) => mapCode(k))
+  } else {
+    keys = hotkey
+      .toLocaleLowerCase()
+      .split(splitKey)
+      .map((k) => mapCode(k))
path: packages/react-hotkeys-hook/src/lib/parseHotkeys.ts
user: login: JohannesKlauss
body: This can be simplified since only the split call is different
created_at: 2025-05-05T07:22:50Z
author_association: OWNER
diff_hunk: @@ -33,11 +33,22 @@ export function parseKeysHookInput(keys: string, delimiter = ','): string[] {
   return keys.toLowerCase().split(delimiter)
 }
 
-export function parseHotkey(hotkey: string, splitKey = '+', useKey = false, description?: string): Hotkey {
-  const keys = hotkey
-    .toLocaleLowerCase()
-    .split(splitKey)
-    .map((k) => mapCode(k))
+export function parseHotkey(hotkey: string, splitKey = '+', sequenceSplitKey = '>', useKey = false, description?: string): Hotkey {
+  let keys: string[] = []
+  let isSequence = false
+
+  if (hotkey.includes(sequenceSplitKey)) {
+    isSequence = true
+    keys = hotkey
+      .toLocaleLowerCase()
+      .split(sequenceSplitKey)
+      .map((k) => mapCode(k))
+  } else {
+    keys = hotkey
+      .toLocaleLowerCase()
+      .split(splitKey)
+      .map((k) => mapCode(k))
path: packages/react-hotkeys-hook/src/lib/parseHotkeys.ts
user: login: prajjwaldimri
body: I can change this to assign a var for the splitKey and isSequence bool based on if the hotkey includes sequence split key or not, but I think this version is easier to read. 
created_at: 2025-05-06T06:06:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,6 +149,47 @@ Since we pressed down one hotkey already the example above started at the count
 globally if we don't use its return value.
 :::
 
+***
+
+## Sequential Hotkeys
+
+We can also listen to keystrokes in a sequential way. This means that the user has to press the keys in the order we specify. To specify a sequential hotkey, we use the `>` character.
path: packages/documentation/docs/documentation/useHotkeys/basic-usage.mdx
user: login: JohannesKlauss
body: ```suggestion
We can also listen to keystrokes sequentially, meaning that the user has to press keys in a specific order before invoking the callback. To specify a sequential hotkey, we use the `>` character.
```
created_at: 2025-05-08T13:36:30Z
author_association: OWNER
diff_hunk: @@ -149,6 +149,47 @@ Since we pressed down one hotkey already the example above started at the count
 globally if we don't use its return value.
 :::
 
+***
+
+## Sequential Hotkeys
+
+We can also listen to keystrokes in a sequential way. This means that the user has to press the keys in the order we specify. To specify a sequential hotkey, we use the `>` character.
+
+```jsx live
+function ExampleComponent() {
+  const [count, setCount] = useState(0)
+  useHotkeys('g>h>i', () => setCount(prevCount => prevCount + 1))
+
+  return (
+    <span>Received the combination {count} times.</span>
+  )
+}
+```
+
+In the example above, the user has to press the `g` key, then the `h` key and then the `i` key in order to trigger the callback.
path: packages/documentation/docs/documentation/useHotkeys/basic-usage.mdx
user: login: JohannesKlauss
body: ```suggestion
In the example above, the user has to press the `g` key, then the `h` key and finally the `i` key in order to trigger the callback.
```
created_at: 2025-05-08T13:36:48Z
author_association: OWNER
diff_hunk: @@ -149,6 +149,47 @@ Since we pressed down one hotkey already the example above started at the count
 globally if we don't use its return value.
 :::
 
+***
+
+## Sequential Hotkeys
+
+We can also listen to keystrokes in a sequential way. This means that the user has to press the keys in the order we specify. To specify a sequential hotkey, we use the `>` character.
+
+```jsx live
+function ExampleComponent() {
+  const [count, setCount] = useState(0)
+  useHotkeys('g>h>i', () => setCount(prevCount => prevCount + 1))
+
+  return (
+    <span>Received the combination {count} times.</span>
+  )
+}
+```
+
+In the example above, the user has to press the `g` key, then the `h` key and then the `i` key in order to trigger the callback.
+
+#### Timeout
+The default timeout for the sequential hotkey is `1000ms`. If the user doesn't press the next key in time, the sequence is aborted.
path: packages/documentation/docs/documentation/useHotkeys/basic-usage.mdx
user: login: JohannesKlauss
body: ```suggestion
`useHotykes` sets a default time window for the sequential hotkey of `1000ms` to prevent listening for incomplete sequences indefinitely. If the user doesn't press the next key in that given time, the sequence is aborted.
```
created_at: 2025-05-08T13:40:51Z
author_association: OWNER
diff_hunk: @@ -33,11 +33,22 @@ export function parseKeysHookInput(keys: string, delimiter = ','): string[] {
   return keys.toLowerCase().split(delimiter)
 }
 
-export function parseHotkey(hotkey: string, splitKey = '+', useKey = false, description?: string): Hotkey {
-  const keys = hotkey
-    .toLocaleLowerCase()
-    .split(splitKey)
-    .map((k) => mapCode(k))
+export function parseHotkey(hotkey: string, splitKey = '+', sequenceSplitKey = '>', useKey = false, description?: string): Hotkey {
+  let keys: string[] = []
+  let isSequence = false
+
+  if (hotkey.includes(sequenceSplitKey)) {
+    isSequence = true
+    keys = hotkey
+      .toLocaleLowerCase()
+      .split(sequenceSplitKey)
+      .map((k) => mapCode(k))
+  } else {
+    keys = hotkey
+      .toLocaleLowerCase()
+      .split(splitKey)
+      .map((k) => mapCode(k))
path: packages/react-hotkeys-hook/src/lib/parseHotkeys.ts
user: login: JohannesKlauss
body: fair enough
created_at: 2025-05-08T13:42:13Z
author_association: OWNER

PR comments:
user: login: vercel[bot]
created_at: 2025-05-02T04:12:21Z
body: [vc]: #3Uks0faURDQnbA+wPrxhk5MVXnfp98+KVK2U9tblvoA=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJyZWFjdC1ob3RrZXlzLWhvb2siLCJyb290RGlyZWN0b3J5IjoicGFja2FnZXMvZG9jdW1lbnRhdGlvbiIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9qb2hhbm5lcy1rbGF1c3MtcHJvamVjdHMvcmVhY3QtaG90a2V5cy1ob29rL0dtVDZjbjJLZ0p0RlRKMUI0TnhzQ25mc051WnYiLCJwcmV2aWV3VXJsIjoicmVhY3QtaG90a2V5cy1ob29rLWdpdC1mb3JrLXdhci01OGFkMmEtam9oYW5uZXMta2xhdXNzLXByb2plY3RzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoicmVhY3QtaG90a2V5cy1ob29rLWdpdC1mb3JrLXdhci01OGFkMmEtam9oYW5uZXMta2xhdXNzLXByb2plY3RzLnZlcmNlbC5hcHAifX1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **react-hotkeys-hook** | ✅ Ready ([Inspect](https://vercel.com/johannes-klauss-projects/react-hotkeys-hook/GmT6cn2KgJtFTJ1B4NxsCnfsNuZv)) | [Visit Preview](https://react-hotkeys-hook-git-fork-war-58ad2a-johannes-klauss-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/react-hotkeys-hook-git-fork-war-58ad2a-johannes-klauss-projects.vercel.app?via=pr-comment-feedback-link) | May 14, 2025 4:07am |


author_association: NONE
user: login: strafe
created_at: 2025-05-04T22:14:34Z
body: This is awesome 🙌
author_association: NONE
user: login: JohannesKlauss
created_at: 2025-05-05T07:29:11Z
body: Thank you for this PR, @prajjwaldimri !
It looks quite good. Please add a section to the docs, then I can merge this :)
author_association: OWNER
user: login: prajjwaldimri
created_at: 2025-05-06T07:40:42Z
body: @JohannesKlauss Added a section in the useHotkeys docs.
author_association: CONTRIBUTOR
user: login: suryaoruganti
created_at: 2025-05-08T06:18:47Z
body: excited to have this go live - thanks @prajjwaldimri and @JohannesKlauss!
author_association: NONE
user: login: JohannesKlauss
created_at: 2025-05-08T13:43:06Z
body: @prajjwaldimri Thank you for the hard work! I added a few suggestions to the docs text, other than that it looks ready to go to me 🙂 
author_association: OWNER
user: login: prajjwaldimri
created_at: 2025-05-14T04:05:30Z
body: @JohannesKlauss All comments are resolved. This is good to go.
author_association: CONTRIBUTOR
user: login: JohannesKlauss
created_at: 2025-05-14T11:25:48Z
body: @prajjwaldimri Awesome work! Thank you so much!
author_association: OWNER