KeyUX: a small library that makes web apps work properly from the keyboard
JS library to improve keyboard UI of web apps
At a glance
- What is it?
- KeyUX is a dependency-free JavaScript library that adds hotkeys, pressed states, arrow-key navigation in menus, focus jumps and hidden-content handling to a web application, all driven by the accessibility attributes the markup already has. It suits a team that wants keyboard behaviour without writing event handlers for each widget, and the detail to know first is that the whole thing is 2 KB minified and brotlied, with a size limit enforced in the build.
- Who is it for?
- Adopt KeyUX if your application has hotkeys or menus that work inconsistently for keyboard users, since the library reads the accessibility attributes you already have rather than asking you to maintain a second configuration.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 92 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One start call, and a list of features you choose
KeyUX is described as a JS library to improve the keyboard UI of web apps, and the positioning is more specific than that. It is designed not only for a11y, but also to create professions tools where users prefer to use the keyboard. The distinction matters, because an accessibility library and a power-user library optimise for different goals even when they overlap.
The architecture is a single entry point and a list of independent features. After `npm install keyux`, you add the `startKeyUX` call with the features you want to the main JS file:
import {
hiddenKeyUX,
hotkeyKeyUX,
hotkeyOverrides,
jumpKeyUX,
focusGroupKeyUX,
focusGroupPolyfill,
pressKeyUX,
startKeyUX
} from 'keyux'
const overrides = hotkeyOverrides({})
startKeyUX(window, [
hotkeyKeyUX([overrides]),
focusGroupKeyUX(),
focusGroupPolyfill(),
pressKeyUX('is-pressed'),
jumpKeyUX(),
hiddenKeyUX()
])That is the whole integration. Each feature is a function that returns something you pass to `startKeyUX`, and the array is the set of behaviours you enable. A project that only wants hotkeys passes only `hotkeyKeyUX()`.
The list of features covers the ground: 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, and hide content from assistive technology where appropriate through the `hiddenKeyUX` module.
The library is MIT licensed, written in TypeScript with the type definition at `index.d.ts` and the entry point at `index.js`, and the package is an ES module with `sideEffects` set to false. The author is Andrey Sitnik, the funding is set to the ai organisation's GitHub sponsors page, and the package is built by Evil Martians, a design and engineering consultancy for developer tools, AI and cybersecurity startups.
Hotkeys come from the markup, not from configuration
The hotkey design is the part of KeyUX worth understanding, because it is unusual.
When the user presses a hotkey, KeyUX clicks on the button or link with the same hotkey in `aria-keyshortcuts`. So the hotkey is not declared to the library at all. It is declared in the HTML, on the element, using the accessibility attribute:
<button aria-keyshortcuts="alt+b">Bold</button>With that in place, pressing Alt+B or the Mac equivalent clicks the button. The library does not maintain a mapping, and there is no central list to keep in sync.
You can also use a hotkey to move focus to a text input or textarea:
<input type="search" aria-keyshortcuts="s" placeholder="S" />So the same mechanism that clicks a button will move focus into a field, which is the behaviour a single-page application usually wants for a search box.
There is a strict rule about the modifier order. The hotkey pattern should contain modifiers like `meta+ctrl+alt+shift+b` in this exact order. That is not a stylistic preference, it is what the parser expects, so a pattern written as `shift+meta+b` will not match.
To enable the feature you call `hotkeyKeyUX`:
import { hotkeyKeyUX, startKeyUX } from 'keyux'
startKeyUX(window, [hotkeyKeyUX()])One-letter hotkeys are ignored when the user's focus is inside text inputs or focus groups, which is why the documentation recommends adding a modifier like Alt+B for general hotkeys. That rule exists to stop a bare B from eating keystrokes while someone is typing, and it is a good illustration of the design principle behind the whole library: keyboard shortcuts must not fight with text entry.
There is a corresponding rule for content that should not respond. Hotkeys inside a block with the `inert` or `aria-hidden` attribute will be ignored, and the documented use is to disable a page's hotkeys while a dialog is shown:
<main inert>
<button aria-keyshortcuts="h">Help</button>
<!-- Will be ignored -->
</main>
<dialog>…</dialog>The `inert` attribute is doing the work here, which means the behaviour is consistent with how the rest of the platform treats inert content rather than being a special case inside the library.
Rendering the hint, and the Mac modifier problem
The hotkey hint is a separate feature from the hotkey itself, and it is the part that turns a keyboard shortcut into something a user can discover.
You render the 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>
)
}Two functions do the work. `likelyWithKeyboard()` returns false on mobile devices where the user is unlikely to be able to use hotkeys, though it is still possible by connecting an external keyboard. That is a heuristic, and it exists so that a mobile user is not shown a keyboard hint they have no way to use.
`getHotKeyHint()` replaces modifiers for Mac and makes the text prettier. For `alt+b` it returns `Alt + B` on Windows and Linux, or the Mac symbol on Mac.
The Mac handling is a recurring theme in the library. It is common to use the Meta or ⌘ modifier for hotkeys on Mac, while Windows and Linux usually favour the Ctrl key, and there is a separate compatibility transform for that, enabled by `hotkeyMacCompat`, which appears in the package's public export list alongside the other features.
The overrides system interacts with the hint, and the documentation is specific that the same override config must be passed to both `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 + BThat is a small requirement with real consequences. If the two sides get out of step, the shortcut works but the hint displays something else, and a hint that lies is worse than no hint at all.
The pressed state is a third small piece. KeyUX can set a class to show pressed state for a button when the user presses a hotkey, which makes the UI more responsive:
import { hotkeyKeyUX, startKeyUX, pressKeyUX } from 'keyux'
startKeyUX(window, [pressKeyUX('is-pressed'), hotkeyKeyUX()])button {
&:active,
&.is-pressed {
transform: scale(0.95);
}
}The class name is a parameter, so the pressed state is styled with your own CSS. The documentation also points at a PostCSS plugin that can automatically add a class for every `:active` state, which is the general solution for the problem the pressed state addresses.
Hotkey overrides, and list items that share a shortcut
The override mechanism exists because of the browser, and it is the most practically important feature for a shipped application.
Many users want to override hotkeys because your hotkeys can conflict with their browser's extensions, system, or screen reader. A shortcut your application uses may already be taken by something the user has installed, and without an escape hatch the only outcome is that your shortcut silently does nothing.
KeyUX allows overriding hotkeys using transforms. The configuration object maps the hotkey you declared to the one the user prefers:
const overrides = {
'alt+b': 'b' // Override B to Alt + B
}The semantics are worth reading carefully because the comment is easy to skim. With that configuration, KeyUX clicks on `aria-keyshortcuts="b"` when Alt+B is pressed, and `getHotKeyHint(window, 'b', [hotkeyOverrides(overrides)])` returns `Alt + B` or the Mac equivalent.
So the mapping runs the other way from what the name suggests. The key is the hotkey you declared in the markup, and the value is the combination the user wants to press. The library then reports, and honours, the user's choice.
The documentation is explicit that you need to create some UI for users to fill this object, so the library provides the transform and the hint rendering and leaves the settings screen to the application. That is a sensible division for a 2 KB library, and it also means the override state has to be persisted somewhere by you.
The list case is a different problem and has its own mechanism. Websites may have hotkeys for each list element, for instance for an Add to cart button in a shopping list. The documented implementation has two steps.
First, hide the list item's buttons from global search using `data-keyux-ignore-hotkeys`, and make the list item focusable with `tabindex="0"`. When the item has 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>The second case is when you have a common panel with actions for the focused item, where you can use `data-keyux-hotkeys` with the ID of the 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>So there are two attributes doing two jobs. `data-keyux-ignore-hotkeys` marks a region whose hotkeys should be suppressed while some item has focus, and `data-keyux-hotkeys` points at the panel to apply instead. The net effect is that one shared action button serves whichever list item the user has focused, which is how a keyboard user operates a list without tabbing through every row's buttons.
Focus groups, focus jumps and the size budget
The remaining features are about focus rather than keys, and they follow the same principle of reading the accessibility markup rather than holding separate configuration.
The `focusgroup` attribute section describes grouping of focusable elements, with dedicated implementations for the `role="menu"`, `role="listbox"` and `role="tablist"` and `role="toolbar"` patterns. The menu support is what the feature list means by enabling navigation with keyboard arrows in `role="menu"` lists and by showing and hiding submenus of `role="menu"`.
There is a separate `focusGroupPolyfill()` in the public API, which is the part that matters for browser support. The focus groups behaviour depends on a platform feature that not every browser has, so the library ships a polyfill alongside the implementation, and the start call in the install example enables both.
The focus jumps feature is about the `aria-controls` attribute. You jump to the next section according to `aria-controls` and back with Esc. So a panel, a dialog or a section that declares what it controls becomes a jump target, and the Escape key returns you to where you were. That is a fairly bold default for a key, but it is the documented behaviour and it is consistent with how Escape is expected to behave in a panel context.
The `hiddenKeyUX` module is the last of the features in the example list, and the feature list describes it as allowing content to be hidden from assistive technology in the appropriate places.
The size discipline is the most striking thing in the package manifest. The description says 2 KB, minified and brotlied, and no dependencies. The manifest enforces it: the size-limit configuration has a single entry for all modules, with the import listing the ten public exports, and a limit of 2283 B.
That limit is a hard number rather than an aspiration, and it is checked by a test script. The scripts are `test:lint` running oxlint, `test:coverage` requiring 100 percent coverage and excluding the test directory, `test:build`, `test:size` running size-limit, and `test` running all of them together with a regular expression over the script names. The 100 percent coverage requirement is the other half of the discipline, and it is a strong statement about how a library of this size is meant to be maintained.
The rest of the tooling is visible too: a pnpm workspace with a lock file, Vite for the demo build, jsdom for the test environment, better-node-test as the test runner, React and ReactDOM for the demo page, and oxlint and oxfmt for linting and formatting. The repository is a pnpm monorepo layout with a `.devcontainer.json` at the root, and the demo lives in `test/demo/` with its own entry point, started with `vite test/demo/ --host 0.0.0.0`.
Editorial conclusion
Adopt KeyUX if your application has hotkeys or menus that work inconsistently for keyboard users, since the library reads the accessibility attributes you already have rather than asking you to maintain a second configuration. Do not adopt it if you need framework-specific components, because it is vanilla JavaScript that only touches the DOM, and check first how you want hotkey overrides to be surfaced, because the library provides the transform and the hint text but leaves the settings UI to the application.
Frequently asked questions
What is KeyUX?
It is a JS library to improve the keyboard UI of web apps, designed not only for accessibility but also for professional tools where users prefer the keyboard. It adds hotkeys with aria-keyshortcuts, pressed states, arrow key navigation in role=menu lists, focus jumps via aria-controls, submenu show and hide, user hotkey overrides and hidden content handling, with no dependencies.
How do I install and start KeyUX?
Run `npm install keyux`, then import the feature functions and call startKeyUX with the ones you want, for example `startKeyUX(window, [hotkeyKeyUX(), focusGroupKeyUX(), pressKeyUX('is-pressed'), jumpKeyUX()])`. It is vanilla JavaScript with no framework dependency, so it works with React, Vue, Svelte or anything else.
How do hotkeys work in KeyUX?
The hotkey is declared in the markup with the aria-keyshortcuts attribute, for example `<button aria-keyshortcuts="alt+b">Bold</button>`, and KeyUX clicks that button or link when the key combination is pressed. The pattern must list modifiers as meta+ctrl+alt+shift+b in that exact order, and one-letter hotkeys are ignored when the focus is inside a text input or focus group.
Can users override hotkeys in KeyUX?
Yes. Use the hotkeyOverrides transformer with hotkeyKeyUX and getHotKeyHint, passing a config object such as { 'alt+b': 'b' }. With that, pressing Alt+B clicks the element declaring aria-keyshortcuts="b", and the hint for it reads Alt + B. You are expected to build the settings UI and persist the object yourself.
How does KeyUX handle lists that share one action button?
Mark the region holding the buttons with data-keyux-ignore-hotkeys and make the list item focusable with tabindex="0", so the hotkeys are ignored while an item has focus. For a shared panel, use data-keyux-hotkeys with the panel's ID on the item, and mark the panel itself with data-keyux-ignore-hotkeys.
Is KeyUX still being maintained?
The repository is not archived and the last push was on 2026-07-23. Recent releases are 0.11.6 from 2026-07-08, 0.11.5 from 2026-04-16 and 0.11.4 from 2026-04-15, the package is MIT licensed by Andrey Sitnik and Evil Martians, and the size-limit configuration caps the whole public surface at 2283 B with 100 percent test coverage enforced in the scripts.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/ai-keyux)Was this useful?