GlassKit UI
Navigation
Copy for LLM

Deck

A horizontal paged flow (wizard, onboarding). Shows one page with step dots beneath. Uncontrolled with Neural Band swipe to advance, or controlled via index and onIndexChange. Pages advance on pinch or D-pad, never scroll.

Step 1

Connect band

Swipe the band (or press Next) to advance

Installation

npx @glasskit-ui/cli add deck

Install 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/glasskit/progress.tsximport type { ReactNode } from "react";import { cn, stringLabel } from "../lib/utils";/** * <Progress> — emitted progress, two shapes: *   - "linear"  a continuous bar (also covers countdown: feed a decreasing *               value and a time label). Uses a native <progress>, so the *               dynamic fill needs no inline style. *   - "step"    discrete step-of-N dots (wizard / pinch-advance). * * `value` is clamped to [0, max]. For "step", value = completed steps. */export function Progress({  value,  max = 100,  variant = "linear",  label,  className,}: {  value: number;  max?: number;  variant?: "linear" | "step";  /** Optional caption shown with the bar (e.g. "3 of 5", "0:42 left"). */  label?: ReactNode;  className?: string;}) {  const clamped = Math.max(0, Math.min(value, max));  if (variant === "step") {    // Steps must be a sane integer count — a fractional or negative max    // would make Array.from misrender the dots.    const steps = Math.max(0, Math.floor(max));    return (      <div        className={cn("flex items-center gap-2", className)}        role="progressbar"        aria-valuenow={clamped}        aria-valuemin={0}        aria-valuemax={steps}        aria-label={stringLabel(label)}      >        {Array.from({ length: steps }, (_, i) => (          <span            key={i}            className={cn(              "size-3 rounded-full transition-[background,box-shadow] duration-[250ms] ease-in-out",              i < clamped                ? "bg-primary [box-shadow:0_0_7px_color-mix(in_oklab,var(--accent)_45%,transparent)]"                : "bg-[rgba(255,255,255,0.16)]",            )}          />        ))}      </div>    );  }  return (    <div className={cn("flex w-full flex-col gap-2", className)}>      <progress        className="gk-progress__el"        value={clamped}        max={max}        aria-label={stringLabel(label)}      />      {label != null ? (        <div className="t-caption flex items-center justify-between text-foreground-faint [font-variant-numeric:tabular-nums]">          {label}        </div>      ) : null}    </div>  );}
// components/glasskit/deck.tsx"use client";import { Children, useEffect, useRef, useState, type ReactNode } from "react";import { seedFocus, useNeuralBand } from "@glasskit-ui/react";import { cn } from "../lib/utils";import { Progress } from "./progress";/** * <Deck> — a horizontal paged flow (wizard / onboarding). Shows one page at a * time with step dots beneath (reusing Progress). Self-connects to * useNeuralBand: a wristband `swipe` advances to the next page (clamped, no * wrap). Pass `index` to control paging yourself — a controlled Deck never * self-advances; swipes surface through `onIndexChange` instead. * One nav model per screen: pages advance on pinch / D-pad, never scroll. * * Platform note (2026-06): the Display delivers web apps only arrow/Enter * keys + history-back — it does not (yet) dispatch custom gesture events, so * the `neuralband` CustomEvent is a forward-compat seam you (or a future OS) * dispatch. For paging that works on-device today, drive `index` from a * focusable control (Button, Stepper) or your own ArrowLeft/ArrowRight * handling. */export function Deck({  index,  defaultIndex = 0,  onIndexChange,  children,  className,}: {  /** Controlled page. Omit for uncontrolled (Neural Band swipes advance). */  index?: number;  /** Starting page when uncontrolled. */  defaultIndex?: number;  /** Fires with the next page on every swipe (both modes). */  onIndexChange?: (index: number) => void;  /** One node per page. Falsy children (`{cond && <Page/>}`) are dropped, which   *  changes the page count — render every page and gate inside it instead. */  children: ReactNode;  className?: string;}) {  const pages = Children.toArray(children);  const count = pages.length;  const controlled = index != null;  const [internal, setInternal] = useState(defaultIndex);  const current = Math.max(    0,    Math.min(controlled ? index : internal, count - 1),  );  // useNeuralBand is a one-shot (the gesture clears on the next microtask),  // so an effect keyed on the gesture alone fires exactly once per physical  // swipe — consecutive identical swipes both advance. Everything else is  // read through a latest-values ref: with it in the deps, the re-render the  // advance itself causes would re-run the effect while the gesture string is  // still set and double-advance.  const gesture = useNeuralBand();  const latest = useRef({ current, count, controlled, onIndexChange });  latest.current = { current, count, controlled, onIndexChange };  useEffect(() => {    if (gesture !== "swipe") return;    const { current, count, controlled, onIndexChange } = latest.current;    const next = Math.min(current + 1, count - 1);    if (next === current) return;    if (!controlled) setInternal(next);    onIndexChange?.(next);  }, [gesture]);  // If the focused element lived inside the outgoing page it unmounts on  // advance, stranding focus on <body> — reseed the D-pad ring. Only when  // orphaned: a focus that survived (e.g. an external Next button) keeps it.  const mounted = useRef(false);  useEffect(() => {    if (!mounted.current) {      mounted.current = true;      return;    }    const active = document.activeElement;    if (!active || active === document.body) seedFocus();  }, [current]);  return (    <div className={cn("flex w-full flex-col items-center gap-5", className)}>      <div        className="flex flex-col items-center gap-3 text-center"        role="group"        aria-label={`Page ${current + 1} of ${count}`}      >        {pages[current]}      </div>      {count > 1 ? (        <Progress variant="step" value={current + 1} max={count} />      ) : null}    </div>  );}

Usage

// self-wired: Neural Band swipes advance<Deck>  <OnboardConnect />  <OnboardCalibrate />  <OnboardReady /></Deck>// or controlled<Deck index={step} onIndexChange={setStep}>…</Deck>

Props

PropTypeDefaultDescription
indexnumberControlled page (clamped). Omit for uncontrolled, where Neural Band swipes advance.
defaultIndexnumber0Starting page when uncontrolled.
onIndexChange(index: number) => voidFires with the next page on every swipe (both modes).
childrenReactNodeOne node per page. Falsy children are dropped, which changes the page count, so render every page and gate inside it instead.
classNamestringClasses merged onto the outer wrapper.

How to use it

Deck is forward-leaning paging for a linear, ordered flow: onboarding, a setup wizard, a workout sequence. It shows one page at a time with step dots and no random access. Use it when the steps have an order the wearer moves through, not when content simply overflows (that is a List).

Uncontrolled: swipe to advance

By default Deck manages its own page. The Neural Band swipe gesture advances to the next page (clamped at the ends), so the wearer flips through with the wristband. Pass defaultIndex to start somewhere other than the first page, and onIndexChange to observe each move.

import { Deck } from "@/components/ui/deck";

<Deck onIndexChange={(i) => track("onboarding_step", i)}>
  <Welcome />
  <Permissions />
  <Done />
</Deck>;

Controlled: you own the index

Pass index to drive the page yourself, for example from your own "Next" button. In controlled mode Deck never self-advances; swipes surface through onIndexChange and you decide what to do.

const [step, setStep] = useState(0);

<Deck index={step} onIndexChange={setStep}>
  <Welcome />
  <Permissions />
  <Done />
</Deck>;

Deck and the back gesture

Deck does not push history, so the back gesture leaves the app rather than stepping back a page. If a flow needs back to mean "previous step," either drive the index yourself and register a useBackHandler in the hosting Navigator, or rethink whether the flow belongs inside a Navigator screen. The common shape is a Deck inside a Navigator screen: back pops the whole flow at once, which is usually what the wearer wants.

On this page