One hand, no mouse
Hold Q and press the key on a slot. The keys sit around Q, so Q 3 always lands on Controls. Space goes back a level.
Home
GitHubq is a navigation wheel for the web. It replaces the navbar, the sidebar and the hamburger. This site has no menu, so try it here: every page is one gesture away.
Hold Q and press the key on a slot. The keys sit around Q, so Q 3 always lands on Controls. Space goes back a level.
Click the Q in the corner, then click a slot. Rest on a group and it opens by itself. Right click goes back. You can miss the ring: the space around it still counts.
Hold Q, point at a slot, and release. Tap Q instead and the wheel stays open until you choose or press Esc.
Almost nothing binds it. Browsers and operating systems leave a bare Q alone, and it sits under your left hand, next to the number row. You can change the key.
q is written in TypeScript, has no dependencies and needs no framework. Its styles are one plain CSS file.
Next: Get started, or hold Q and press 2.
npm install @ziran-project/q
import { createQ } from '@ziran-project/q';
import '@ziran-project/q/styles.css';
const q = createQ({
items: [
{ id: 'home', label: 'Home', href: '/' },
{
id: 'projects',
label: 'Projects',
description: 'Everything in motion.',
children: [
{ id: 'atlas', label: 'Atlas', href: '/projects/atlas' },
{ id: 'orbit', label: 'Orbit', href: '/projects/orbit' },
],
},
],
});
That is a working wheel. Hold Q and it opens; choose a slot and q follows its
href.
Pass onNavigate and q calls it instead of following the link.
createQ({
items,
onNavigate: ({ item, path }) => router.push(item.href),
});
Importing the package is safe on the server. Call createQ in the browser,
once per document, and call destroy when the page or component goes away.
q.destroy();
Next: Controls, or hold Q and press 3.
The wheel always opens at the top level, so the same keys always lead to the same place.
Slots without a key of their own take the next free one from 1 2 3 4 5 W E R A S D F Z X C V, all within reach of the hand holding Q.
Slots that appear under a resting pointer do nothing until the pointer moves. One long hover cannot fall through several levels.
When a slot has the focus, after the arrow keys or Tab, Space and Enter press it, as they do on any button.
q ignores its keys while you type in a field or in editable content, and while Ctrl, Alt or the Command key is held.
Next: Items, or hold Q and press 4 1.
The wheel draws a tree. An item with children is a group; any other item is a
destination.
| Field | Type | Purpose |
|---|---|---|
id |
string |
Required. Unique across the whole tree. |
label |
string |
Required. The name on the slot. |
description |
string |
Shown in the center while the slot is highlighted. |
icon |
string |
Text drawn above the label. Use renderItem for anything else. |
hotkey |
string |
One character, unique among its siblings. Defaults to the next free key. |
href |
string |
Where to go. A group cannot have one. |
disabled |
boolean |
The slot stays visible and cannot be chosen. |
children |
QItem[] |
Makes the item a group. There is no depth limit. |
data |
unknown |
Anything your app needs back in onNavigate. |
Between three and eight slots per level read well. The trigger key and the back key
cannot be used as a hotkey.
Treat the tree as immutable. To change it, call setItems with a new one.
Next: Options, or hold Q and press 4 2.
Everything createQ accepts. Only items is required.
| Option | Default | Purpose |
|---|---|---|
items |
Required | The tree of destinations and groups. |
onNavigate |
Follows href |
Receives { item, path } when a destination is chosen. |
triggerKey |
'q' |
The key that opens the wheel. |
backKey |
Space | Goes back one level. Backspace always works too. |
tapThreshold |
250 |
A press shorter than this, in milliseconds, leaves the wheel open. |
expandDelay |
450 |
How long the pointer rests on a group before it opens. |
backDelay |
600 |
The same, for Back. |
hoverMoveThreshold |
12 |
Pixels the pointer must travel before new slots react. |
trigger |
true |
Adds the corner button. |
target |
document.body |
Where the wheel is mounted. |
label |
'Quick navigation' |
Accessible name of the wheel and the corner button. |
className |
None | Extra class on the wheel and the corner button. |
renderItem |
Icon, label, key | Returns the content of a slot as a DOM node. |
renderCenter |
Title and hint | Returns the content of the center as a DOM node. |
You can feel the three delays before choosing them: Timing has a dial for each.
Content from renderItem should not be interactive. The slot is already a
button.
Next: Instance, or hold Q and press 4 3.
createQ returns the wheel. You rarely need more than destroy.
| Member | Purpose |
|---|---|
open() |
Opens at the top level and stays open. |
close() |
Closes without going anywhere. |
toggle() |
Opens or closes. |
select(id) |
Goes to a destination, or opens a group, of the current level. |
highlight(id) |
Highlights a slot. Pass null to clear. |
back() |
Back one level. |
setItems(items) |
Replaces the tree and returns to the top level. |
getState() |
The current state, frozen. |
subscribe(listener) |
Calls the listener now and on every change. Returns a function to stop. |
element |
The <dialog> of the wheel. |
destroy() |
Removes the DOM, the timers and every listener. |
interface QState {
isOpen: boolean;
path: string[]; // IDs of the open groups
parent: QItem | null; // the group being explored
items: QItem[]; // the slots of the current level
activeId: string | null; // the highlighted slot
canGoBack: boolean;
}
Next: Headless, or hold Q and press 4 4.
The controller holds the state and nothing else: no DOM, no listeners, no styles. Use it to draw the wheel yourself, in a framework or on a canvas.
import { createQController } from '@ziran-project/q/headless';
const navigation = createQController({ items, onNavigate });
const stop = navigation.subscribe((state) => render(state));
navigation.open();
navigation.select('projects'); // opens the group
navigation.select('atlas'); // calls onNavigate and closes
stop();
It has the same methods as the instance, except element and
destroy. It runs in Node.
Keys, hold and tap, resting the pointer and the corner button belong to
createQ. With the controller, they are yours to build.
Next: Browsers, or hold Q and press 4 5.
q needs <dialog> and Pointer Events, which every current browser has.
The closing animation needs @starting-style; without it the wheel closes
instantly.
The wheel is a modal dialog. While it is open, the page behind it is inert and the focus stays inside. When it closes, the focus returns to where it was.
Every slot is a button with a name and its key. Arrow keys and Tab reach all of them, Back included. The center announces the highlighted slot.
Animations are off for anyone who asks their system for reduced motion.
Contrast, if you change the colors. Labels, if you replace the content of a slot. And after navigating, move the focus to the new page, as this site does.
Next: Theming, or hold Q and press 5.
The stylesheet is optional and plain CSS. To restyle the wheel, override its variables.
.q-root,
.q-trigger {
--q-surface: #ffffff; /* center and corner button */
--q-slot: #ffffff;
--q-text: #16182a;
--q-muted: #565a75; /* descriptions */
--q-accent: #ffd23f; /* highlight and progress */
--q-on-accent: #16182a; /* text over the accent */
--q-backdrop: rgb(16 27 110 / 70%);
--q-font: 'Bricolage Grotesque', sans-serif;
--q-size: min(620px, calc(100vw - 16px), calc(100dvh - 16px));
}
Those are the values of Cobalt. To see them, hold Q and press W 1 2.
Each .q-slot says what is happening to it:
data-activedata-dwellingdata-selecteddata-group
Pass className to createQ and scope the variables to it, or
scope them to an attribute on the page, as this site does.
Next: Timing, or hold Q and press W 2.
Change a value, then open the wheel to feel the difference.
Next: the example app. Hold Q, press W 3 and keep going.
This is a page of an example app, several levels deep. In yours,
onNavigate receives the item and its path, and your router takes it from
there.
Back to Home, or hold Q and press 1.