Skip to content

ai/keyux

Repository files navigation

KeyUX

JS library to improve the keyboard UI of web apps. It is designed not only for a11y, but also to create professions tools where users prefer to use the keyboard.

  • Add hotkeys with aria-keyshortcuts.
  • Show a button’s :active state when a hotkey is pressed.
  • Enable navigation with keyboard arrows in role="menu" lists.
  • Jump to the next section according to aria-controls and back with Esc.
  • Show and hide submenus of role="menu".
  • Allow users to override hotkeys.
  • 2 KB (minified and brotlied). No dependencies.
  • Vanilla JS and works with any framework including React, Vue, Svelte.
export const Button = ({ hotkey, children }) => {
  return <button aria-keyshortcuts={hotkey}>
    {children}
    {likelyWithKeyboard(window) && <kbd>{getHotKeyHint(window, hotkey)}</kbd>}
  </button>
}

See demo page and example:

keyux_demo.mp4


Made at Evil Martians, product consulting for developer tools.


Install

npm install keyux

Then add the startKeyUX call with the necessary features to the main JS file.

import {
  hiddenKeyUX,
  hotkeyKeyUX,
  hotkeyOverrides,
  jumpKeyUX,
  focusGroupKeyUX,
  pressKeyUX,
  startKeyUX
} from 'keyux'

const overrides = hotkeyOverrides({})

startKeyUX(window, [
  hotkeyKeyUX([overrides]),
  focusGroupKeyUX(),
  pressKeyUX('is-pressed'),
  jumpKeyUX(),
  hiddenKeyUX()
])

Hotkeys

When the user presses a hotkey, KeyUX will click on the button or link with the same hotkey in aria-keyshortcuts.

For instance, KeyUX will click on this button if user press Alt+B or B.

<button aria-keyshortcuts="alt+b">Bold</button>

You can use hotkey to move focus to text input or textarea:

<input type="search" aria-keyshortcuts="s" placeholder="S" />

The hotkey pattern should contain modifiers like meta+ctrl+alt+shift+b in this exact order.

To enable this feature, call hotkeyKeyUX:

import { hotkeyKeyUX, startKeyUX } from 'keyux'

startKeyUX(window, [
  hotkeyKeyUX()
])

Hotkeys inside block with inert or aria-hidden attribute will be ignored. You can use it, to disable page’s hotkeys when dialog is shown:

<main inert>
  <button aria-keyshortcuts="h">Help</button> <!-- Will be ignored -->
</main>
<dialog></dialog>

Hotkeys Hint

You can render the hotkey hint from the aria-keyshortcuts pattern in a prettier way:

import { likelyWithKeyboard, getHotKeyHint } from 'keyux'

export const Button = ({ hokey, children }) => {
  return <button aria-keyshortcuts={hotkey}>
    {children}
    {likelyWithKeyboard(window) && <kbd>{getHotKeyHint(window, hotkey)}</kbd>}
  </button>
}

likelyWithKeyboard() returns false on mobile devices where user is unlikely to be able to use hotkeys (but it is still possible by connecting an external keyboard).

getHotKeyHint() replaces modifiers for Mac and makes text prettier. For instance, for alt+b it will return Alt + B on Windows/Linux or ⌥ B on Mac.

If you’re using overrides, pass the same override config both to hotkeyKeyUX() and getHotKeyHint() for accurate hints:

import {
  getHotKeyHint,
  hotkeyOverrides,
  hotkeyKeyUX,
  startKeyUX
} from 'keyux'

let config = { 'alt+b': 'b' }

startKeyUX(window, [
  hotkeyKeyUX([hotkeyOverrides(config)]) // Override B to Alt + B
])
getHotKeyHint(window, 'b', [hotkeyOverrides(config)]) // Alt + B

One-letter hotkeys (like B) will be ignored if user’s focus is inside text inputs or focus groups. This is why for general hotkeys we recommend add some modifier like Alt+B.

Pressed State

KeyUX can set class to show pressed state for a button when user presses a hotkey. It will make the UI more responsive.

import { hotkeyKeyUX, startKeyUX, pressKeyUX } from 'keyux'

startKeyUX(window, [
  pressKeyUX('is-pressed'),
  hotkeyKeyUX()
])
button {
  &:active,
  &.is-pressed {
    transform: scale(0.95);
  }
}

overriding You can use postcss-pseudo-classes to automatically add class for every :active state in your CSS.

Hotkeys Override

Many users want to override hotkeys because your hotkeys can conflict with their browser’s extensions, system, or screen reader.

KeyUX allows overriding hotkeys using tranforms. Use the hotkeyOverrides() tranformer with hotkeyKeyUX() and getHotKeyHint().

You will need to create some UI for users to fill this object like:

const overrides = {
  'alt+b': 'b' // Override B to Alt + B
}

Then KeyUX will click on aria-keyshortcuts="b" when Alt+B is pressed, and getHotKeyHint(window, 'b', [hotkeyOverrides(overrides)]) will return Alt + B/⌥ B.

Hotkeys for List

Websites may have hotkeys for each list element. For instance, for “Add to card” button in shopping list.

To implement it:

  1. Hide list item’s buttons by data-keyux-ignore-hotkeys from global search.
  2. Make list item focusable by tabindex="0". When item has a focus, KeyUX ignores data-keyux-ignore-hotkeys.
<li data-keyux-ignore-hotkeys tabIndex={0}>
  {product.title}
  <button aria-keyshortcuts="a" tabIndex={-1}>Add to card</button>
</li>

If you have common panel with actions for focused item, you can use data-keyux-hotkeys with ID of item’s panel.

<ul>
  {products.map(product => {
    return <li data-keyux-hotkeys="panel" tabIndex={0} key={product.id}>
      {product.title}
    </li>
  })}
</ul>
<div id="panel" data-keyux-ignore-hotkeys>
  <button aria-keyshortcuts="a" tabIndex={-1}>Add to card</button>
</div>

Meta instead of Ctrl on Mac

It’s common to use the Meta (or ) modifier for hotkeys on Mac, while Windows and Linux usually favor the Ctrl key. To provide familiar experience on all platforms, enable the Mac compatibility transform:

import {
  hotkeyMacCompat,
  hotkeyKeyUX,
  startKeyUX,
  getHotKeyHint
} from 'keyux'

const mac = hotkeyMacCompat();
startKeyUX(window, [hotkeyKeyUX([mac])])
getHotKeyHint(window, 'ctrl+b', [mac]) // Ctrl+B on Windows/Linux and ⌘+b on Mac

Hotkeys pressed with the Meta modifier will work as if the Ctrl modifier was pressed.

Focus Groups

Using only Tab for navigation is not very useful. User may need to press it too many times to get to their button (also non-screen-reader users don’t have quick navigation).

Menu

To reduce Tab-list you can group website’s menu into role="menu" with arrow navigation.

<nav role="menu">
  <a href="/" role="menuitem">Home</a>
  <a href="/catalog" role="menuitem">Catalog</a>
  <a href="/contacts" role="menuitem">Contacts</a>
</nav>

Users will use Tab to get inside the menu, and will use either arrows or Home, End or an item name to navigate inside. User can search the menu item by typing the first characters of the item text.

To enable this feature, call focusGroupKeyUX.

import { focusGroupKeyUX } from 'keyux'

startKeyUX(window, [
  focusGroupKeyUX()
])

Listbox

The role="listbox" is used for lists from which a user may select one or more items which are static and, unlike HTML <select> elements, may contain images.

<ul role="listbox">
  <li tabindex="0" role="option">Pizza</li>
  <li tabindex="0" role="option">Sushi</li>
  <li tabindex="0" role="option">Ramen</li>
</ul>

Users will use Tab to get inside the listbox, and will use either arrows or Home, End or an item name to navigate inside.

To enable this feature, call focusGroupKeyUX.

import { focusGroupKeyUX } from 'keyux'

startKeyUX(window, [
  focusGroupKeyUX()
])

Tablist

The role="tablist" identifies the element that serves as the container for a set of tabs. The tab content should be marked by [role="tabpanel'].

<div role="tablist">
  <button role="tab">Home</button>
  <button role="tab">About</button>
  <button role="tab">Contact</button>
</div>

Users will use Tab to get inside the tablist, and will use either arrows or Home, End.

To enable this feature, call focusGroupKeyUX.

import { focusGroupKeyUX } from 'keyux'

startKeyUX(window, [
  focusGroupKeyUX()
])

Toolbar

The role="toolbar" defines the containing element as a collection of commonly used function buttons or controls represented in a compact visual forms. Buttons inside the toolbar must have type="button" attribute because the default one is submit.

<div role="toolbar">
  <div>
    <button type="button">Copy</button>
    <button type="button">Paste</button>
    <button type="button">Cut</button>
  </div>
  <div>
    <input type="checkbox" />
  </div>
</div>

Users will use Tab to get inside the tablist, and will use either arrows or Home, End.

To enable this feature, call focusGroupKeyUX.

import { focusGroupKeyUX } from 'keyux'

startKeyUX(window, [
  focusGroupKeyUX()
])

Focus Jumps

After finishing in one section, you can move user’s focus to the next step to save time. For example, you can move the cursor to the page after the user selects it from the menu. Or, you can move the focus to the item’s form after the user selects an item in the list.

You can control where the focus moves next with aria-controls.

<div role="menu">
  {products.map(({ id, name }) =>
    <button role="menuitem" aria-controls="product_form">{name}</button>
  )}
</div>

<div id="product_form">
  
</div>

On Esc the focus will jump back.

You can add aria-controls to <input> to make the focus jump on Enter.

<input type="search" aria-controls="search_results" />

To enable this feature, call jumpKeyUX.

import { focusGroupKeyUX, jumpKeyUX } from 'keyux'

startKeyUX(window, [
  focusGroupKeyUX(),
  jumpKeyUX()
])

Nested Menu

You can make nested menus with KeyUX with aria-controls and aria-hidden="true".

<button aria-controls="edit" aria-haspopup="menu">Edit</button>

<div id="edit" hidden aria-hidden="true" role="menu">
  <button role="menuitem">Undo</button>
  <button role="menuitem" aria-controls="find" aria-haspopup="menu">Find</button>
</div>

<div id="find" hidden aria-hidden="true" role="menu">
  <button role="menuitem">Find…</button>
  <button role="menuitem">Replace…</button>
</div>

You can make the nested menu visible by diabling hidden, but you will have to set tabindex="-1" manually.

To enable this feature, call hiddenKeyUX.

import { focusGroupKeyUX, jumpKeyUX, hiddenKeyUX } from 'keyux'

startKeyUX(window, [
  focusGroupKeyUX(),
  jumpKeyUX(),
  hiddenKeyUX()
])