Copy for LLM
Navigator
A screen stack with system-back integration. Every push adds a real history entry, so the Display's back gesture pops it via popstate. The stack rides in history.state, so a mid-flow reload restores the screen, and opt-in paths mirror pushes into the URL. Pop restores focus to the row that pushed.
Installation
npx @glasskit-ui/cli add navigatorInstall the SDK (it provides GlassViewport, useDpad and the stylesheet), then copy these files into your project:
npm install @glasskit-ui/react// components/lib/utils.tsimport { clsx, type ClassValue } from "clsx";import { twMerge } from "tailwind-merge";export type { ClassValue };/** * Merge class names the shadcn way: clsx joins conditionals, tailwind-merge * de-dupes conflicting Tailwind utilities so a consumer's `className` override * wins (e.g. passing `px-2` beats the component's `px-6`). Lens components are * Tailwind utilities + `--gk-*` tokens, so this de-dupe matters. */export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs));}/** * Accessible name from a free-form `label` prop: the label itself when it's a * plain string, otherwise undefined (a ReactNode can't become an aria-label). */export function stringLabel(label: unknown): string | undefined { return typeof label === "string" ? label : undefined;}// components/lib/glass-history.ts/** * A tiny swappable history abstraction for <Navigator> — the React * Router / TanStack pattern. Navigator drives navigation through this seam * instead of touching globals directly, so production runs on the real * `window.history` (the Display's middle-pinch BACK gesture arrives as a real * `popstate` on `window`, OS v125.1+) while tests inject an in-memory stack * and stop racing on jsdom's shared `window.history`. * * `createBrowserHistory()` is the production default and behaves exactly like * the prior direct calls: same `window.history` methods, same real `window` * `popstate` listener. `createMemoryHistory()` mirrors those semantics over an * internal entry stack — a state object and URL carried per entry — and * notifies subscribers (via a microtask, matching how the tests `await`) when * `back()`/`go()` traverse, so Navigator behaves identically off-window. *//** A single history entry: the structured-clone-able state and its URL. */export type GlassHistoryEntry = { state: unknown; url: string;};/** * The slice of the history API <Navigator> needs. Mirrors `window.history` * (plus `location.pathname` via `pathname`) so the browser adapter is a thin * pass-through and the memory adapter is a faithful stand-in. */export type GlassHistory = { /** Current `history.state`. */ readonly state: unknown; /** Current `location.pathname`. */ readonly pathname: string; /** Push a new entry (adds a traversable entry — back returns here). */ pushState(state: unknown, url?: string | null): void; /** Replace the current entry in place (no new entry). */ replaceState(state: unknown, url?: string | null): void; /** Traverse back one entry (fires the popstate notification). */ back(): void; /** Traverse by `delta` entries (negative = back). */ go(delta: number): void; /** * Subscribe to popstate-style traversals; the callback receives a * PopStateEvent-shaped object carrying the entry's `state`. Returns an * unsubscribe. */ subscribe(onPopstate: (e: { state: unknown }) => void): () => void;};/** * Production default: a thin wrapper over the real `window.history` / * `window` `popstate` / `location`. Every method is a direct pass-through, so * on-device semantics are byte-for-byte identical to calling the globals. */export function createBrowserHistory(): GlassHistory { return { get state() { return window.history.state; }, get pathname() { return window.location.pathname; }, pushState(state, url) { window.history.pushState(state, "", url ?? undefined); }, replaceState(state, url) { window.history.replaceState(state, "", url ?? undefined); }, back() { window.history.back(); }, go(delta) { window.history.go(delta); }, subscribe(onPopstate) { const handler = (e: PopStateEvent) => onPopstate(e); window.addEventListener("popstate", handler); return () => window.removeEventListener("popstate", handler); }, };}/** * In-memory history for deterministic tests: an entry stack with its own * index, never touching `window`. `back()`/`go()` move the index and notify * subscribers on a microtask with the now-current entry's state — mirroring * how jsdom's real traversal resolves across queued tasks (the tests `await` a * settle before asserting). State objects are carried per entry exactly as the * browser does. */export function createMemoryHistory( initial?: { state?: unknown; url?: string },): GlassHistory { const entries: GlassHistoryEntry[] = [ { state: initial?.state ?? null, url: initial?.url ?? "/" }, ]; let index = 0; const listeners = new Set<(e: { state: unknown }) => void>(); const notify = () => { const state = entries[index]!.state; // Microtask, matching jsdom's async traversal (the tests await a settle). queueMicrotask(() => { for (const l of [...listeners]) l({ state }); }); }; return { get state() { return entries[index]!.state; }, get pathname() { return entries[index]!.url; }, pushState(state, url) { // Drop any forward entries, exactly like a real navigation. entries.splice(index + 1); entries.push({ state, url: url ?? entries[index]!.url }); index = entries.length - 1; }, replaceState(state, url) { entries[index] = { state, url: url ?? entries[index]!.url }; }, back() { this.go(-1); }, go(delta) { const next = index + delta; if (next < 0 || next >= entries.length || delta === 0) return; index = next; notify(); }, subscribe(onPopstate) { listeners.add(onPopstate); return () => { listeners.delete(onPopstate); }; }, };}// components/glasskit/navigator.tsx"use client";import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode,} from "react";import { getFocusables, seedFocus } from "@glasskit-ui/react";import { cn } from "../lib/utils";import { createBrowserHistory, type GlassHistory } from "../lib/glass-history";/** * <Navigator> — a screen stack for glasses apps (react-navigation semantics, * glasses-native mechanics). The Display's system back gesture (middle pinch) * pops browser history and the page receives `popstate` (glasses OS v125.1+), * so every push adds a real history entry and back — gesture, Escape in * desktop dev, or `pop()` — flows through one path: history → popstate → * stack. At the root the gesture falls through to the system (app * switcher/menu), exactly like Android's back contract. * * One screen renders at a time (one task per view); pushing unmounts nothing * until the entry is popped, but only the top screen is in the DOM so the * D-pad focus engine never sees covered screens. Focus seeds to the new * screen's first focusable (or its `data-autofocus` target) on push; popping * back restores the ring to the element that opened the screen — the row you * came from, not the top of the list (focus memory). * * Overlays intercept back with `useBackHandler` (last registered wins first; * return true to consume — e.g. close a sheet instead of leaving the screen). * * The stack itself rides in `history.state` (when params are * structured-cloneable), so a mid-flow reload restores the screen the wearer * was on instead of kicking them to the root. Optional `paths` mirrors the * stack into the URL (`/detail` on push, restored on back) — give the host a * catch-all route and pushed screens become deep-linkable. */type NavEntry = { name: string; params?: unknown; key: number };type NavAPI = { /** Current stack, root first. */ stack: readonly NavEntry[]; /** Push a screen (adds a history entry — system back returns here). */ push: (name: string, params?: unknown) => void; /** Pop one screen (no-op at the root). */ pop: () => void; /** Pop everything back to the root screen. */ popToTop: () => void; /** Swap the top screen without growing the stack. */ replace: (name: string, params?: unknown) => void;};const NavContext = createContext<NavAPI | null>(null);const BackChainContext = createContext<{ current: Array<() => boolean>;} | null>(null);/** Navigation API of the nearest <Navigator>. */export function useNavigator(): NavAPI { const api = useContext(NavContext); if (!api) throw new Error("useNavigator must be used inside <Navigator>"); return api;}/** * Intercept the back gesture while mounted (overlays, confirm sheets, * mid-flow guards). `handler` returns true to consume the back, false to let * it pop the screen. Handlers run newest-first. */export function useBackHandler(handler: () => boolean): void { const chain = useContext(BackChainContext); if (!chain) throw new Error("useBackHandler must be used inside <Navigator>"); const ref = useRef(handler); ref.current = handler; useEffect(() => { const entry = () => ref.current(); chain.current.push(entry); return () => { chain.current.splice(chain.current.indexOf(entry), 1); }; }, [chain]);}export function Navigator({ screens, initial, initialParams, paths, className, history: historyProp,}: { /** Screen renderers by name; receive the push params. */ screens: Record<string, (params?: unknown) => ReactNode>; /** Root screen name. */ initial: string; initialParams?: unknown; /** Screen name → URL segment. Mirrors the stack into the pathname * (appended to the pathname where the Navigator mounted). */ paths?: Record<string, string>; className?: string; /** * Advanced/testing seam: a swappable history adapter (the React * Router / TanStack pattern). Defaults to {@link createBrowserHistory}, * which drives the real `window.history` and `window` `popstate` exactly * as before — leave it unset in production. Inject `createMemoryHistory()` * in tests for a deterministic in-memory stack that never touches `window`. */ history?: GlassHistory;}) { // One stable adapter for the component's lifetime; the browser default is // created lazily so production behaviour is identical to direct globals. const historyRef = useRef<GlassHistory | undefined>(historyProp); if (!historyRef.current) historyRef.current = createBrowserHistory(); const history = historyRef.current; const [stack, setStack] = useState<NavEntry[]>(() => [ { name: initial, params: initialParams, key: 0 }, ]); const keyRef = useRef(0); const backChain = useRef<Array<() => boolean>>([]); // Focus memory: entry key → index (in D-pad order) of the element that was // focused when that screen pushed. Restored when the screen resurfaces. const focusMemory = useRef(new Map<number, number>()); // Mirror of the stack for event handlers (popstate/keydown read the latest // without re-subscribing per render). const stackRef = useRef(stack); stackRef.current = stack; // Pathname where the Navigator mounted — `paths` segments append to it. const basePath = useRef<string | null>(null); const pathsRef = useRef(paths); pathsRef.current = paths; const urlFor = (name: string): string | undefined => { const seg = pathsRef.current?.[name]; if (seg == null || basePath.current == null) return undefined; return seg === "" ? basePath.current : `${basePath.current}/${seg}`; }; /** history.state payload for a stack — drops params that can't be cloned * (a reload then restores the screen without them). */ const stateFor = (s: NavEntry[]) => { const entries = s.map((e) => ({ name: e.name, params: e.params })); try { structuredClone(entries); return { gkNavDepth: s.length - 1, gkStack: entries }; } catch { return { gkNavDepth: s.length - 1, gkStack: entries.map((e) => ({ name: e.name })), }; } }; /** Ask overlays first; true = back was consumed. */ const runBackChain = () => { for (let i = backChain.current.length - 1; i >= 0; i--) { if (backChain.current[i]!()) return true; } return false; }; useEffect(() => { basePath.current = history.pathname.replace(/\/$/, ""); const state = history.state as | { gkStack?: Array<{ name: string; params?: unknown }>; gkNavDepth?: number } | null | undefined; const saved = state?.gkStack; if (state?.gkNavDepth == null) { // Fresh entry — mark the root so popstate can tell our entries from // the host page's. history.replaceState({ ...(state ?? {}), ...stateFor(stackRef.current), }); } else if (saved && saved.length > 1 && stackRef.current.length === 1) { // Reload mid-flow: this entry carries a deeper stack — restore it so // the wearer lands back on the screen they were on. basePath must not // include the restored top's segment, so strip it when paths are on. const topSeg = pathsRef.current?.[saved[saved.length - 1]!.name]; if (topSeg && basePath.current.endsWith(`/${topSeg}`)) { basePath.current = basePath.current.slice(0, -(topSeg.length + 1)); } setStack( saved.map((e) => ({ name: e.name, params: e.params, key: ++keyRef.current, })), ); } const onPopstate = (e: { state: unknown }) => { const depth: number = (e.state as { gkNavDepth?: number } | null | undefined)?.gkNavDepth ?? 0; if (depth >= stackRef.current.length - 1) return; // not ours / no-op if (runBackChain()) { // An overlay consumed the back — restore the history entry the // system just popped so depth and stack stay in sync. history.pushState( stateFor([...stackRef.current]), urlFor(stackRef.current[stackRef.current.length - 1]!.name), ); return; } setStack((s) => s.slice(0, depth + 1)); }; // Desktop dev parity: the official simulator mapping is BACK = Escape; // on-device the gesture never reaches keydown (v125.1 sends popstate). // Routing Escape through the adapter's back() converges both worlds. const onKeydown = (e: KeyboardEvent) => { if (e.key !== "Escape") return; if (runBackChain()) return; if (stackRef.current.length > 1) history.back(); }; const unsubscribe = history.subscribe(onPopstate); window.addEventListener("keydown", onKeydown); return () => { unsubscribe(); window.removeEventListener("keydown", onKeydown); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const push = useCallback((name: string, params?: unknown) => { setStack((s) => { // Remember where the ring was on the outgoing screen (idempotent — // safe under StrictMode's double-invoke, like the pushState below). const idx = getFocusables().indexOf( document.activeElement as HTMLElement, ); if (idx !== -1) focusMemory.current.set(s[s.length - 1]!.key, idx); const next = [...s, { name, params, key: ++keyRef.current }]; history.pushState(stateFor(next), urlFor(name)); return next; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const pop = useCallback(() => { if (stackRef.current.length > 1) history.back(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const popToTop = useCallback(() => { const depth = stackRef.current.length - 1; if (depth > 0) history.go(-depth); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const replace = useCallback((name: string, params?: unknown) => { setStack((s) => { const next = [...s.slice(0, -1), { name, params, key: ++keyRef.current }]; history.replaceState(stateFor(next), urlFor(name)); return next; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const api = useMemo<NavAPI>( () => ({ stack, push, pop, popToTop, replace }), [stack, push, pop, popToTop, replace], ); // Stack top changed → move the D-pad ring. Returning to a remembered // screen restores the element that pushed (clamped if the list shrank); // a fresh screen seeds normally. const top = stack[stack.length - 1]!; useEffect(() => { const remembered = focusMemory.current.get(top.key); if (remembered != null) { focusMemory.current.delete(top.key); const els = getFocusables(); if (els.length > 0) { els[Math.min(remembered, els.length - 1)]!.focus(); return; } } seedFocus(); }, [top.key]); return ( <NavContext.Provider value={api}> <BackChainContext.Provider value={backChain}> <div key={top.key} className={cn("gk-nav", className)}> {screens[top.name]?.(top.params) ?? null} </div> </BackChainContext.Provider> </NavContext.Provider> );}Usage
const nav = useNavigator(); // inside a screennav.push("detail", { id }); nav.pop(); nav.popToTop(); nav.replace("done");<Navigator initial="home" screens={{ home: () => <Home />, detail: (params) => <Detail {...params} />, }}/>// overlays intercept the back gesture:useBackHandler(() => { if (open) { setOpen(false); return true; } return false; });Props
| Prop | Type | Default | Description |
|---|---|---|---|
screens | Record<string, (params?) => ReactNode> | — | Screen renderers by name; receive the push params. |
initial | string | — | Root screen name. |
initialParams | unknown | — | Params for the root. |
paths | Record<string, string> | — | Screen name → URL segment. Mirrors the stack into the pathname; with a host catch-all route, pushed screens deep-link. |
How to use it
Navigator is a screen stack with react-navigation semantics and
glasses-native mechanics. Use it whenever screens form a hierarchy: a list that
opens a detail, a settings tree, anything where back should mean "up one level."
It is the only navigation model that makes the back gesture pop a screen instead
of leaving the app.
Set up the stack
You give Navigator a map of screen renderers and the name of the root screen. One screen renders at a time (one task per view), and only the top screen is in the DOM, so the D-pad focus engine never sees covered screens.
import { Navigator, useNavigator } from "@/components/ui/navigator";
<Navigator
initial="list"
screens={{
list: () => <TrailList />,
detail: (params) => <TrailDetail id={(params as { id: string }).id} />,
}}
/>;Move around with useNavigator
Inside any screen, useNavigator() gives you the stack API. The names match
react-navigation so it reads the way you expect.
function TrailList() {
const nav = useNavigator();
return (
<List items={trails} onSelect={(t) => nav.push("detail", { id: t.id })} />
);
}push(name, params)adds a screen and a history entry, so the back gesture returns here.paramsrides with the screen.pop()goes back one screen (a no-op at the root).popToTop()returns to the root in one step.replace(name, params)swaps the top screen without growing the stack (good after a one-way step like "sign in" so back does not return to it).
You almost never wire pop() to a control. The system back gesture already
flows through history to popstate to the stack. Reach for pop() only when a
button inside a screen should also go back.
Params and reload survival
The stack rides in history.state when params are structured-cloneable, so a
mid-flow reload restores the screen the wearer was on instead of dropping them
at the root. Keep pushed params plain (ids, strings, numbers) and this works for
free. Pass a non-cloneable value (a function, a class instance) and that entry
falls back to memory-only, so a reload there returns to the root.
Deep links with paths
By default the URL does not change as you push. Opt into URL mirroring with
paths, a map from screen name to a URL segment:
<Navigator initial="list" paths={{ detail: "detail" }} screens={{ /* ... */ }} />Now a push to detail appends /detail to the pathname where the Navigator
mounted, and back restores it. Give the host route a catch-all and pushed
screens become deep-linkable.
Overlays consume back with useBackHandler
A sheet or dialog should close on the back gesture rather than leaving the
screen. Register a handler; return true to consume the gesture, false to let
it fall through. The last handler registered runs first.
function FilterSheet({ onClose }: { onClose: () => void }) {
useBackHandler(() => {
onClose();
return true; // consume back: close the sheet, stay on the screen
});
return <Sheet>{/* ... */}</Sheet>;
}Focus memory
Focus seeds to the new screen's first focusable (or its data-autofocus
target) on push. When you pop back, Navigator restores the ring to the element
that opened the screen, the row you came from, not the top of the list. This is
automatic; you get it by using Navigator.
Overview
One nav model per screen. How to choose between Navigator (hierarchy), Tabs (peers), Deck (linear flow), and Launcher (front door), plus why the back gesture is a history router on the Display.
Tabs
A top-level tab strip (the home's quick-controls, home, apps pager). Each tab is D-pad-focusable; the active one gets an accent indicator. Controlled via value and onChange. Tabs do not touch history, so back exits the app.