Foundations

Keyboard navigation

Moving focus among the items of a control with the arrow keys, in any layout and reading direction.

Markdown

Keyboard navigation moves focus among the items of a composite control — a list, a toolbar, a grid — with the arrow keys. Tab moves between controls; the arrow keys move within one.

Movement along an axis

Focus an item and use the arrow keys, Home and End. Change the axis, the reading direction and what happens at the ends. Four is disabled.

Axis
Direction
At the ends

Focus an item, then use the arrow keys, Home and End.

Code
"use client";

import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { moveForKey, step } from "@publictheta/ui/primitives/navigation.ts";
import type { Direction, Move, Orientation } from "@publictheta/ui/primitives/navigation.ts";
import { useRef, useState } from "react";
import { Controls, Readout } from "../../ui/controls.tsx";

const ITEMS = ["One", "Two", "Three", "Four", "Five", "Six"];
const DISABLED = new Set(["Four"]);
const KEYS = ITEMS.filter((item) => !DISABLED.has(item));

const KEY_LABEL: Record<string, string> = {
  ArrowUp: "↑",
  ArrowDown: "↓",
  ArrowLeft: "←",
  ArrowRight: "→",
  Home: "Home",
  End: "End",
};

export default function NavigationAxis() {
  const [orientation, setOrientation] = useState<Orientation>("horizontal");
  const [direction, setDirection] = useState<Direction>("ltr");
  const [loop, setLoop] = useState<"stop" | "loop">("stop");
  const [focused, setFocused] = useState("One");
  const [last, setLast] = useState<{ key: string; move: Move | null } | null>(null);
  const refs = useRef(new Map<string, HTMLButtonElement>());

  return (
    <div className="w-full">
      <Controls>
        <RadioGroup label="Axis" size="small" value={orientation} onValueChange={setOrientation}>
          <Radio value="horizontal">Horizontal</Radio>
          <Radio value="vertical">Vertical</Radio>
        </RadioGroup>
        <RadioGroup label="Direction" size="small" value={direction} onValueChange={setDirection}>
          <Radio value="ltr">LTR</Radio>
          <Radio value="rtl">RTL</Radio>
        </RadioGroup>
        <RadioGroup label="At the ends" size="small" value={loop} onValueChange={setLoop}>
          <Radio value="stop">Stop</Radio>
          <Radio value="loop">Wrap</Radio>
        </RadioGroup>
      </Controls>

      <div
        role="toolbar"
        aria-label="Navigation demo"
        aria-orientation={orientation}
        tabIndex={-1}
        dir={direction}
        onKeyDown={(event) => {
          if (!(event.key in KEY_LABEL)) return;
          const move = moveForKey(event.key, orientation, direction);
          setLast({ key: event.key, move });
          if (move === null) return;
          event.preventDefault();
          const target = step(KEYS, focused, move, { loop: loop === "loop" });
          if (target !== null) refs.current.get(target)?.focus();
        }}
        className={`mx-auto flex w-fit gap-2 ${orientation === "vertical" ? "flex-col" : "flex-row"}`}
      >
        {ITEMS.map((item) => (
          <button
            key={item}
            ref={(node) => {
              if (node) refs.current.set(item, node);
              else refs.current.delete(item);
            }}
            type="button"
            disabled={DISABLED.has(item)}
            tabIndex={item === focused ? 0 : -1}
            onFocus={() => setFocused(item)}
            className="min-w-20 rounded-lg border border-border-muted bg-surface px-3 py-2 text-sm text-fg focus-visible:border-transparent focus-visible:bg-selected focus-visible:text-on-selected focus-visible:outline-none disabled:text-fg-disabled disabled:opacity-60"
          >
            {item}
          </button>
        ))}
      </div>

      <Readout>
        {last === null
          ? "Focus an item, then use the arrow keys, Home and End."
          : `key ${KEY_LABEL[last.key]} · move ${last.move ?? "none on this axis"} · focus ${focused}`}
      </Readout>
    </div>
  );
}

Best practices

Make each composite control a single Tab stop. Tab enters the control and Tab leaves it, however many items it holds. On entry, focus goes to the item that was last focused, or else to the first selected item, or else to the first item.

Follow the axis the items are laid out on. Up and Down move through a vertical arrangement; Left and Right through a horizontal one. Keys along the other axis are left for other uses.

Mirror horizontal movement in right-to-left text. Where text reads right to left, the next item is to the left, and Left Arrow moves to it.

Provide Home and End to reach the first and last items directly.

Stop at the ends of a list; consider wrapping in a short cyclic set. Stopping tells people they have reached the end of a list. In a menu or a small toolbar, moving from the last item to the first saves a key press and loses nothing.

Pass over items that cannot be used. Disabled items are skipped, so every stop is one where something can happen.

Keep the focused item in view. When focus moves to an item outside the visible region, scroll by the least amount that reveals it.

Behaviour

Keys

A vertical axis answers ArrowUp and ArrowDown and ignores ArrowLeft and ArrowRight
expect(moveForKey("ArrowDown", "vertical", "ltr")).toBe("next");
expect(moveForKey("ArrowUp", "vertical", "ltr")).toBe("previous");
expect(moveForKey("ArrowRight", "vertical", "ltr")).toBeNull();
A horizontal axis answers ArrowLeft and ArrowRight, mirrored right-to-left
expect(moveForKey("ArrowRight", "horizontal", "ltr")).toBe("next");
expect(moveForKey("ArrowRight", "horizontal", "rtl")).toBe("previous");
expect(moveForKey("ArrowLeft", "horizontal", "rtl")).toBe("next");
expect(moveForKey("ArrowDown", "horizontal", "ltr")).toBeNull();
Home and End go to the ends on either axis
expect(moveForKey("Home", "horizontal", "rtl")).toBe("first");
expect(moveForKey("End", "vertical", "ltr")).toBe("last");

Steps

next and previous move by one
expect(step(KEYS, "a", "next")).toBe("b");
expect(step(KEYS, "c", "previous")).toBe("b");
Without loop, a step past either end stays at the end
expect(step(KEYS, "c", "next")).toBe("c");
expect(step(KEYS, "a", "previous")).toBe("a");
With loop, a step past either end comes round to the other
expect(step(KEYS, "c", "next", { loop: true })).toBe("a");
expect(step(KEYS, "a", "previous", { loop: true })).toBe("c");
From nowhere, next lands first and previous lands last
expect(step(KEYS, null, "next")).toBe("a");
expect(step(KEYS, "gone", "previous")).toBe("c");
An empty set has nowhere to land
expect(step([], null, "first")).toBeNull();

Pages

A page lands on the farthest item that a view starting at the current one holds whole
// A view 100 long holds three items of 30 and part of a fourth.
expect(page(TEN, 0, 1, 100)).toBe(2);
expect(page(TEN, 2, 1, 100)).toBe(4);
expect(page(TEN, 9, -1, 100)).toBe(7);
A page moves at least one item on, however long that item is
const uneven = laid([30, 500, 30]);
expect(page(uneven, 0, 1, 100)).toBe(1);
expect(page(uneven, 2, -1, 100)).toBe(1);
A page stops at either end
expect(page(TEN, 8, 1, 100)).toBe(9);
expect(page(TEN, 1, -1, 100)).toBe(0);
expect(page(TEN, 9, 1, 100)).toBe(9);
From no item, a page onward lands first and a page back lands last
expect(page(TEN, -1, 1, 100)).toBe(0);
expect(page(TEN, -1, -1, 100)).toBe(9);
expect(page([], -1, 1, 100)).toBe(-1);
Items may be measured in any frame, such as one that runs right to left
// Mirrored: each item starts where, read left to right, it would end.
const mirrored = TEN.map(({ start, end }) => ({ start: -end, end: -start })).toReversed();
expect(page(mirrored, 9, -1, 100)).toBe(7);
Among ten thousand items, a page measures only the items it passes
const extents = counted(laid(Array.from({ length: 10_000 }, () => 30)));
expect(page(extents.array, 5000, 1, 100)).toBe(5002);
// The one it starts from, the two it lands on and passes, and the one that does not fit.
expect(extents.accesses()).toBe(4);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/navigation.json
Source

src/primitives/navigation.ts

/** Moving through an ordered set of keys: which key a key press asks for, and where that lands. */

export type Orientation = "horizontal" | "vertical";
export type Direction = "ltr" | "rtl";
export type Move = "next" | "previous" | "first" | "last";

/** The move a key asks for along one axis; horizontal arrows mirror in right-to-left text. */
export function moveForKey(key: string, orientation: Orientation, direction: Direction): Move | null {
  switch (key) {
    case "Home":
      return "first";
    case "End":
      return "last";
    case "ArrowDown":
      return orientation === "vertical" ? "next" : null;
    case "ArrowUp":
      return orientation === "vertical" ? "previous" : null;
    case "ArrowRight":
      if (orientation !== "horizontal") return null;
      return direction === "rtl" ? "previous" : "next";
    case "ArrowLeft":
      if (orientation !== "horizontal") return null;
      return direction === "rtl" ? "next" : "previous";
    default:
      return null;
  }
}

export interface StepOptions {
  /** Whether moving past either end comes round to the other. */
  readonly loop?: boolean;
}

/**
 * The key a move lands on. `keys` holds only the keys that can be landed on. From
 * no key (or one no longer present), `next` lands first and `previous` lands last.
 */
export function step<K>(
  keys: readonly K[],
  from: K | null,
  move: Move,
  { loop = false }: StepOptions = {},
): K | null {
  if (keys.length === 0) return null;
  const last = keys.length - 1;
  const index = from === null ? -1 : keys.indexOf(from);

  switch (move) {
    case "first":
      return keys[0]!;
    case "last":
      return keys[last]!;
    case "next":
      if (index === -1) return keys[0]!;
      if (index === last) return loop ? keys[0]! : keys[last]!;
      return keys[index + 1]!;
    case "previous":
      if (index === -1) return keys[last]!;
      if (index === 0) return loop ? keys[last]! : keys[0]!;
      return keys[index - 1]!;
  }
}

/** Where an item lies along the axis its collection runs on, in any one frame of reference. */
export interface Extent {
  readonly start: number;
  readonly end: number;
}

/**
 * The index a page away from `from`: the farthest item that a view `reach` long,
 * starting at the item at `from`, still holds whole — and always at least one
 * item onward, however long that one is. Only the items passed are measured.
 * From no item (`-1`) a page lands on the first item going onward, the last
 * going back.
 */
export function pageStep(
  count: number,
  from: number,
  direction: 1 | -1,
  extent: (index: number) => Extent,
  reach: number,
): number {
  if (count === 0) return -1;
  if (from < 0 || from >= count) return direction === 1 ? 0 : count - 1;
  const origin = extent(from);
  let target = from;
  for (let index = from + direction; index >= 0 && index < count; index += direction) {
    const box = extent(index);
    const span = Math.max(origin.end, box.end) - Math.min(origin.start, box.start);
    if (span > reach && target !== from) break;
    target = index;
  }
  return target;
}