Scoping hotkeys to components
Global hotkeys and scoped components
In the previous section we used two keystrokes in two different examples (ctrl+shift+a+c
and shift+c
).
If we press down one of the keystroke it will trigger both components. Why is that?
Hotkeys are attached globally, so there is no default scoping mechanism for them to only trigger if the component is
focused. To emphasize the issue, check out these two components:
function UnscopedHotkey() {
const [count, setCount] = useState(0)
useHotkeys('c', () => setCount(prevCount => prevCount + 1))
return (
<p>The count is {count}.</p>
)
}
function SecondUnscopedHotkey() {
const [count, setCount] = useState(0)
useHotkeys('c', () => setCount(prevCount => prevCount + 1))
return (
<p>The count is {count}.</p>
)
}
render(
<div>
<UnscopedHotkey/>
<SecondUnscopedHotkey/>
</div>
)
Everytime we press down the c
key, both component trigger the callback. But how can we separate those two components
and their assigned hotkeys? The answer is Refs
. useHotkeys
returns a React ref callback function that we
can attach to any component that takes a ref. This way we can tell the hook which element should receive the users focus
before it triggers its callback.
function ScopedHotkey() {
const [count, setCount] = useState(0)
const ref = useHotkeys('shift+a', () => setCount(prevCount => prevCount + 1))
return (
<>
<p>The count is {count}. Click anywhere except for the button to disable the hotkey.</p>
<button ref={ref}>
Click me to enable the hotkey
</button>
</>
)
}
function SecondScopedHotkey() {
const [count, setCount] = useState(0)
const ref = useHotkeys('shift+a', () => setCount(prevCount => prevCount + 1))
return (
<>
<p>The count is {count}. Click anywhere except for the button to disable the hotkey.</p>
<button ref={ref}>
Click me to enable the hotkey
</button>
</>
)
}
render(
<div>
<ScopedHotkey/>
<hr/>
<SecondScopedHotkey/>
</div>
)
As we can see only if the button receives focus the hotkey gets enabled. We also added a second component that
listens to the same hotkey but only triggers if an assigned component receives focus.
We successfully scoped our duplicate global hotkey to their own components. This practice isn't restricted to duplicate
hotkeys, we can use this technique to scope any hotkey we like.
Scoping with non-focusable components
Receiving focus on a button to enable a hotkey in a real world application is not very useful. Instead, we generally would
like to set the focus to a modal or let the user click on an area which then receives the focus and enables
its attached hotkeys. However, tags like <div>
, <section>
, <span>
, etc. cannot receive focus by default. To let them
receive focus we have to use the tabIndex
attribute:
function SecondScopedHotkey() {
const [count, setCount] = useState(0)
const ref = useHotkeys('shift+a', () => setCount(prevCount => prevCount + 1))
return (
<div ref={ref} tabIndex={-1} style={{border: '2px solid #9e768f'}}>
<p>The count is {count}. Click inside this area to enable the hotkey.</p>
</div>
)
}
To ensure that we don't accidentally break sequential keyboard navigation of your page elements, we recommend to always
set the tabIndex
prop to -1
. This way the element is not reachable via keyboard navigation. For more information on
this topic, check out the MDN page.
Nesting scoped hotkeys
This of course also works with nesting components:
function NestedScopedHotkey() {
const [count, setCount] = useState(0)
const ref = useHotkeys('shift+a', () => setCount(prevCount => prevCount + 1))
return (
<div ref={ref} tabIndex={-1} style={{border: '2px solid deeppink'}}>
<p>The count is {count}. Click inside this area to enable the hotkey.</p>
</div>
)
}
function ScopedHotkey({children}) {
const [count, setCount] = useState(0)
const ref = useHotkeys('shift+a', () => setCount(prevCount => prevCount + 1))
return (
<div ref={ref} tabIndex={-1} style={{border: '2px solid #9e768f', padding: '3px'}}>
<p>The count is {count}. Click inside this area to enable the hotkey.</p>
{children}
</div>
)
}
render(
<ScopedHotkey>
<NestedScopedHotkey/>
</ScopedHotkey>
)