q

Home

GitHub

Hold Q.
Point.
Let go.

q 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.

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.

Mouse, no keyboard

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.

Both

Hold Q, point at a slot, and release. Tap Q instead and the wheel stays open until you choose or press Esc.

Why Q

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.

Get started

Install

npm install @ziran-project/q

Mount a wheel

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.

Use your router

Pass onNavigate and q calls it instead of following the link.

createQ({
  items,
  onNavigate: ({ item, path }) => router.push(item.href),
});

Clean up

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.

Controls

The wheel always opens at the top level, so the same keys always lead to the same place.

Keyboard

Q hold
Open. Release on a slot to go there.
Q tap
Open and stay open. Tap again to close.
1 to 5, W E R
Go to that slot, or open that group.
Space Backspace
Back one level.
← →
Move around the ring.
Enter
Confirm the highlighted slot.
Esc
Close without going anywhere.

Pointer and touch

Corner button
Open and stay open.
Click or tap a slot
Go there, or open that group.
Rest on a group
It opens when the arc around it closes.
Rest on Back
Back one level.
Right click
Back one level. At the top level, close.
Click outside
Close without going anywhere.

Details that matter

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.

Items

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.

Options

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.

Instance

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.

State

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.

Headless

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.

Browsers

Support

q needs <dialog> and Pointer Events, which every current browser has. The closing animation needs @starting-style; without it the wheel closes instantly.

Accessibility

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.

What is yours to check

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.

Theming

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.

State

Each .q-slot says what is happening to it:

data-active
Highlighted by the pointer or the keyboard.
data-dwelling
The pointer is resting on it and it is about to open.
data-selected
Just chosen, while the wheel closes.
data-group
It has children.

More than one theme

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.

Timing

Change a value, then open the wheel to feel the difference.

Next: the example app. Hold Q, press W 3 and keep going.

Example app

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.